Skip to content

Choosing Consistency After Read/Write Splitting

Published: June 26, 2026

In Minichat, read requests far outnumber writes. Chatroom list queries, message lookups, user profile fetches — most APIs are read operations.

To offload read traffic from the Master, I implemented DataSource Routing. Transactions marked with @Transactional(readOnly = true) are routed to a Replica; everything else goes to the Master. The setup is one Master and three Replicas.

Data written to the Master takes time to propagate to Replicas.

If a user sends a message and immediately opens the chatroom list:

Message sent → Master UPDATE (lastWrittenMessageId = 170)
Chatroom list query → @Transactional(readOnly = true) → Replica
Replica has not yet replicated → returns lastWrittenMessageId = 150
Latest message does not appear in the list

Because the write and the read hit different databases, data is inconsistent for the duration of the replication lag. The user performed an action but cannot see its result.

Validation — Reproducing Read Inconsistency Under Replication Lag

Section titled “Validation — Reproducing Read Inconsistency Under Replication Lag”

To confirm that replication lag actually produces different read results, I ran a test against a real Master-Replica environment.

Test Setup

  • Master: localhost:3301
  • Replica: localhost:3307, 3308, 3309
  • MySQL Replication (GTID-based)

Scenario: Artificially induce lag by stopping the Replica’s SQL_THREAD

  1. Initial state: Master and Replica hold identical data
  2. Stop Replica’s SQL_THREAD → replication pauses
  3. UPDATE lastWrittenMessageId on Master
  4. Query both sides and compare
  5. Restart SQL_THREAD → re-query after replication catches up
StepQuery targetReturned valueStatus
After Master UPDATEMaster170Fresh
After Master UPDATEReplica124000000000010000Stale — pre-update value
After SQL_THREAD restartMaster170Fresh

After updating lastWrittenMessageId = 170 on the Master, querying the Replica at the same moment returned the pre-update value. In an environment where @Transactional(readOnly = true) routes to a Replica, users see stale data for as long as the replication lag persists.

Once the SQL_THREAD was restarted, the Master’s changes propagated and both sides returned the same value. The inconsistency is not permanent — it exists only within the lag window. But during that window, user experience is not guaranteed.

Initial Assumption — Route All Reads to Master

Section titled “Initial Assumption — Route All Reads to Master”

The most obvious fix was to send all reads to the Master. Replication lag disappears entirely.

But that defeats the purpose of having Replicas. All traffic concentrates on a single node, and the original goal — offloading read load — is nullified.

Before choosing a consistency strategy, I stepped back and examined the nature of the read requests themselves.

Analyzing the Data — Not every read is about freshness.

Section titled “Analyzing the Data — Not every read is about freshness.”

Not all reads require the same level of consistency.

Data that demands immediate freshness: A message the user just sent, the chatroom’s latest message, read status. These are direct results of the user’s own actions. Any delay is immediately noticeable and breaks the experience.

Data that tolerates delay: Profile information, search results, user lists. If replication lags by a few hundred milliseconds, the user does not notice.

Different reads have different consistency requirements. Applying the same strategy to all of them either over-pays for freshness or under-delivers on user experience.

I compared strategies for handling replication lag.

StrategyData freshnessImplementation complexityMaster load
Master-only readsAlways guaranteedVery lowVery high (Replica becomes useless)
Replica readsNot guaranteedLowLow
Sticky ReadMostly guaranteedMediumMedium
Read-after-writeGuaranteedHighMedium

Master-only reads: Freshness is guaranteed, but read load distribution disappears.

Replica reads: Load is distributed, but stale data may be returned during replication lag.

Sticky Read: All reads are routed to Master for a fixed time window (e.g. 2 seconds) after a write.

Read-after-write: Only the specific read that follows a write is routed to Master.

Minichat currently applies only DataSource Routing based on @Transactional(readOnly = true). Writes go to Master, reads go to Replica.

@Override
protected Object determineCurrentLookupKey() {
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) {
return masterReplicaKeyRouter.getReplicaKey();
}
return masterReplicaKeyRouter.getMasterKey();
}

Sticky Read and Read-after-write are not implemented. At the current project scale, not every read requires immediate freshness, so Replica Routing is sufficient.

Future Considerations — Read-after-write vs Sticky Read

Section titled “Future Considerations — Read-after-write vs Sticky Read”

If the service scales or if more APIs require immediate visibility of a user’s own writes, the read strategy will need to evolve. Two strategies are worth considering.

Read-after-write

Only the specific reads that follow a user’s write are routed to Master. All other reads remain on Replica.

// pseudo code
if (hasRecentWrite(userId)) {
return masterDataSource; // user's own write just happened → Master
}
return replicaDataSource; // everything else → Replica

In a chat system, this means: chatroom list query immediately after sending a message goes to Master. A friend list query at the same moment stays on Replica.

This minimizes Master load while precisely covering the user experience gap. The trade-off is complexity — tracking “which user just wrote” requires state management via Redis TTL, ThreadLocal, or a separate context store. It is the most accurate strategy, but also the most complex. Defining the boundary of “immediately after a write” can become ambiguous as the number of write-then-read patterns grows.

Sticky Read

All reads from a user are routed to Master for a fixed duration after any write.

// pseudo code
if (isWithinStickyWindow(userId, Duration.ofSeconds(2))) {
return masterDataSource; // within 2s after write → Master
}
return replicaDataSource; // otherwise → Replica

The advantage is simplicity — no need to distinguish between API endpoints, minimal state tracking, straightforward implementation. The disadvantage is that it over-covers: reads that do not require freshness are also sent to Master during the window, increasing Master traffic and creating potential bottlenecks under high load.

CriterionRead-after-writeSticky Read
GranularityPer action/dataPer time window
PrecisionHighLow
Master loadLowerHigher
Implementation complexityHigherLower

Across this series, the same principle keeps appearing: consistency strategy should be chosen based on what the data actually requires.

PartLayerData characteristicDecision
Part 1RedisMonotonic increase (max)Lua Script instead of distributed lock
Part 2KafkaOrdering requirement differs by topicOnly chat-message uses a key
Part 3DatabaseFreshness requirement differs by readR/W Routing now, stronger strategies deferred

The question was never “How can I guarantee the strongest consistency?” The question was: “What consistency does this data actually require?”

Replication lag is inevitable once reads and writes are split. The important question is not how to avoid it, but where freshness actually matters. For now, Replica Routing provides the right balance for Minichat.