Replication & Consistency
A fan pays for two tickets at 10:00:41. The confirmation screen says "Order 981 confirmed". She taps My tickets β and the list is empty. In a panic she buys again. Nothing was lost and nothing was buggy in the usual sense: the payment was written to the PostgreSQL primary, while My tickets was served by a read replica that had not yet received the write. BoxOffice had silently chosen a consistency model, and it was the wrong one for that screen.
This page explains how data is copied between nodes (replication) and what a reader is allowed to see while the copies disagree (consistency models). The two are inseparable: the replication design decides which consistency models are cheap, which are expensive and which are impossible.
Why replicate?
Keeping a copy of the same data on several nodes serves three different goals, and it helps to say which one you want:
| Goal | How replication helps | BoxOffice |
|---|---|---|
| Availability / durability | If one node dies, another has the data | The inventory database survives the loss of a machine or a zone |
| Read throughput | Reads are spread over many copies | The event catalogue is read ~100Γ more than it is written (illustrative ratio) |
| Latency | A copy close to the user answers faster | A catalogue copy in a European region for fans in Lisbon |
All three are easy if the data never changes. The difficulty is entirely in handling changes: each write must reach every copy, and while it travels, the copies disagree.
Replication models
There are three families, distinguished by who accepts writes1.
Single-leader (primaryβreplica)
One node, the leader (primary), accepts all writes and streams a log of changes to the followers (replicas). Reads can go to the leader or to followers. This is the default in PostgreSQL, MySQL, MongoDB, Redis and Kafka (one leader per partition).
flowchart LR
w([order: write]) -->|"INSERT order 981"| L[("leader<br/>(primary)")]
L -->|"WAL stream"| F1[("follower 1")]
L -->|"WAL stream"| F2[("follower 2")]
r1([My tickets: read]) -->|"SELECT"| F1
r2([catalogue: read]) -->|"SELECT"| F2 Multi-leader
Several nodes accept writes β typically one leader per data centre or region β and replicate to each other asynchronously. Writes are fast and local everywhere, but two leaders can accept conflicting writes to the same record, which must be resolved later. Typical uses: multi-region deployments, offline-first apps (each phone is a "leader"), collaborative editing.
Leaderless
Any replica accepts reads and writes. The client (or a coordinator) sends each write to several replicas and each read to several replicas, and quorums decide what counts as success (R + W > N, see Consensus & Quorums). This is the design of Amazon's Dynamo paper2, Cassandra and ScyllaDB.
| Single-leader | Multi-leader | Leaderless | |
|---|---|---|---|
| Who accepts writes | One leader | One leader per region/site | Any replica |
| Write conflicts | None (the leader orders writes) | Yes β must be resolved | Yes β must be resolved |
| Write latency | Round trip to the leader (maybe far away) | Local | Round trip to W replicas |
| Leader failure | Failover needed (seconds) | Other leaders continue | No leader to lose |
| Strong consistency possible? | Yes (read from leader, sync replication) | Not across leaders | Only approximately (quorums) |
| Typical systems | PostgreSQL, MySQL, MongoDB, Kafka partitions | Multi-region MySQL/Postgres setups, CouchDB, Active-Active Redis | Cassandra, ScyllaDB, Riak, DynamoDB (internally) |
| BoxOffice use | inventory, order, payment | β (avoid for seats!) | Fan activity, view counters |
Synchronous or asynchronous?
Independently of who leads, the leader must decide when to tell the client "OK":
sequenceDiagram
participant C as order
participant L as leader
participant F1 as follower 1 (sync)
participant F2 as follower 2 (async)
C->>L: INSERT order 981
L->>F1: replicate
L->>F2: replicate
F1-->>L: written to disk
L-->>C: OK (after 1 sync follower)
Note over F2: still applying...<br/>a read here is stale
F2-->>L: written (later) | Mode | Client gets OK when⦠| If the leader dies right after OK | Write latency | Availability of writes |
|---|---|---|---|---|
| Asynchronous | the leader wrote locally | Recent writes may be lost | Lowest | High: followers can be down |
| Synchronous (all) | every follower wrote | Nothing lost | Slowest follower decides | Low: any follower down blocks writes |
| Semi-synchronous | the leader + k followers wrote | Nothing lost if a sync follower is promoted | One extra round trip | Good: tolerates other followers down |
| Quorum (consensus) | a majority wrote | Nothing lost | Median of the fastest majority | Tolerates a minority down |
PostgreSQL exposes this choice in synchronous_commit and synchronous_standby_names (for example ANY 1 (replica_a, replica_b) β semi-synchronous with one of two). In practice, "fully synchronous" is rare: one slow follower would stall every write. Semi-synchronous or quorum replication is the usual compromise.
Replication lag
With asynchronous followers, the delay between a write on the leader and its visibility on a follower is the replication lag. On a healthy system it is usually milliseconds; under heavy write load, during a long transaction or after a network hiccup, it can grow to seconds or minutes. The time between a write and a following read decides whether that read is stale:
With an (assumed) exponential lag, the same screen that is almost never stale on a quiet day is stale for about 69% of fans who open it 300 ms after paying during the on-sale, when the mean lag grows to 800 ms. The bug of the opening story is not rare under load β it is the expected behaviour.
Try it. Load the db-replicas scenario, run the load profile and watch the replica lag metric. Then raise the write rate (or switch to spike) and see the lag β and the fraction of stale reads β grow. Finally route the reads to the primary and compare the primary's CPU and p99.
Consistency models: a contract
A consistency model is a contract between a data store and its programmers: if you follow these rules, the results of reads will be predictable in this way3. Stronger models are easier to program against and cost more coordination (latency, availability); weaker models are cheaper and push the complexity into the application.
flowchart TD
LIN["Linearizable<br/>(strong consistency)"] --> SEQ["Sequential"]
SEQ --> CAU["Causal"]
CAU --> RYW["Read-your-writes"]
CAU --> MR["Monotonic reads"]
CAU --> CP["Consistent prefix"]
RYW --> EV["Eventual"]
MR --> EV
CP --> EV An arrow from A to B means "A guarantees everything B guarantees, and more". Read-your-writes and monotonic reads are session guarantees: they hold for one client's session, not for everyone; consistent prefix promises that no reader sees writes out of the order in which they happened.
Linearizability (strong consistency)
Linearizability makes a replicated system behave as if there were a single copy of the data, and every operation took effect atomically at one instant between its start and its end4. Once any read has returned a new value, every later read (by anyone, on any replica) must return that value or a newer one.
sequenceDiagram
participant A as Fan A
participant N1 as replica 1
participant N2 as replica 2
participant B as Fan B
A->>N1: hold seat A-12
N1-->>A: OK (hold committed)
Note over A,B: the write has completed
B->>N2: is seat A-12 free?
N2-->>B: yes, FREE (stale)
Note over N2,B: violates linearizability:<br/>B started after A's write finished
B->>N2: hold seat A-12
Note over N2: double booking if N2 accepts Linearizability is what you need when correctness depends on one up-to-date answer:
- uniqueness and inventory β a seat, a username, the last unit of stock;
- leader election and locks β only one node may believe it is the leader;
- cross-channel timing β a fan pays, then the
notificationservice (reading elsewhere) must see the paid order.
It is obtained by routing all reads and writes for a key through one leader that is itself elected by consensus (with care about stale leaders), or by consensus on every operation. Its price: every operation pays at least one round trip to the leader or to a majority, and nodes that cannot reach the leader/majority must refuse β the C-or-A choice of CAP.
Serializable is not linearizable
Serializability is an isolation guarantee for transactions over many objects (the result equals some serial order of the transactions). Linearizability is a recency guarantee for single objects (reads see the latest write in real time). A database can offer one, both ("strict serializability", as in Spanner) or neither. Isolation levels are covered in Databases β transactions.
Sequential consistency
All nodes see all operations in the same order, and each node's operations appear in its program order β but that order need not respect real time. A read may return a value that is "old" in wall-clock terms, as long as everyone agrees on the sequence. It is mainly a hardware-memory and theory concept; in system design you rarely choose it explicitly.
Causal consistency
Operations that are causally related β one could have influenced the other β are seen by everyone in that order; concurrent operations may be seen in different orders by different nodes.
The classic example is a conversation: if Ana posts "Is the 10:00 sale delayed?" and the organiser replies "No, it starts on time", nobody may see the reply without the question. In BoxOffice: a SeatReleased event caused by a HoldExpired event must not be applied before it.
Causal consistency is interesting because it is the strongest model that remains available during a network partition5: nodes can keep accepting reads and writes on both sides and still respect causality. It is tracked with logical clocks β see Time & Clocks.
Session guarantees
Most "my data disappeared" bugs are violations of one of these four guarantees, defined for a single client session (Terry et al., Bayou project)6:
| Guarantee | Promise | Violation in BoxOffice |
|---|---|---|
| Read-your-writes | After you write, your reads see that write | The opening story: you paid, My tickets is empty |
| Monotonic reads | You never see the data go back in time | Remaining seats shows 120, then 340, then 95 as you hit different replicas |
| Monotonic writes | Your writes are applied in the order you made them | "Change e-mail" then "send ticket" β the ticket goes to the old address |
| Writes-follow-reads (causal) | A write you make after reading X is ordered after X | You reply to a support message that other replicas have not received yet |
sequenceDiagram
participant F as Fan's app
participant R1 as replica 1 (lag 10 ms)
participant R2 as replica 2 (lag 2 s)
F->>R1: GET /events/42/availability
R1-->>F: 95 seats left
F->>R2: refresh (load balancer picks R2)
R2-->>F: 340 seats left
Note over F: the count went back in time:<br/>monotonic reads violated How to provide them on top of asynchronous replicas:
- Read from the leader after a write: for a short window (e.g. 1β5 s) after the fan writes, route her reads to the primary β or always read data the user owns (My tickets) from the primary.
- Version tokens: the write returns the leader's log position (in PostgreSQL, the WAL LSN β Log Sequence Number); the client sends it with the next read, and a replica only answers once it has applied at least that position (otherwise it waits or forwards to the leader). Many databases expose this as causal sessions or read-after-write tokens (MongoDB causal consistency sessions, Cosmos DB session consistency).
- Sticky replica: pin each session to one replica (hash of the session id) β gives monotonic reads, not read-your-writes.
In the BoxOffice code: read-your-writes by routing (lab 6)
Hands-on 6 β Replicas adds a streaming replica to the inventory database and uses the first technique above. One DataSource wraps two pools; a transaction marked @Transactional(readOnly = true) β the seat map, availability per section β gets a replica connection, every other one gets the primary. HoldService.find is deliberately not read-only: the fan just created that hold, so it is read from the primary. The lab measured why: a fan who holds a seat and immediately reloads the seat map from the replica did not see her own hold in about 6% of the attempts with the replica on the same machine, and in 98% with 1 s of artificial lag.
/**
* Two connection pools β primary and replica β behind one DataSource.
* A transaction marked @Transactional(readOnly = true) gets a replica connection;
* every other transaction gets a primary connection. The choice happens when the
* transaction runs its first statement (that is what "lazy" means here).
*/
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("inventory.datasource.primary")
HikariDataSource primaryDataSource() {
return new HikariDataSource();
}
@Bean
@ConfigurationProperties("inventory.datasource.replica")
HikariDataSource replicaDataSource() {
return new HikariDataSource();
}
@Bean
@Primary
DataSource dataSource(HikariDataSource primaryDataSource, HikariDataSource replicaDataSource) {
var routing = new LazyConnectionDataSourceProxy(primaryDataSource);
routing.setReadOnlyDataSource(replicaDataSource); // Spring Framework 6.2+
return routing;
}
Eventual consistency
Eventual consistency only promises that if writes stop, all replicas will eventually converge to the same value. It says nothing about when, and nothing about what you read meanwhile. It is the guarantee of DNS, of most caches and CDNs, of asynchronous replicas, and of leaderless stores at low quorum settings.
It is perfectly fine for many domains β the event description, the poster image, the "312 people are looking at this event" counter. The hard part is what happens when replicas accepted concurrent writes to the same item and must converge:
| Strategy | How | Trade-off |
|---|---|---|
| Last-write-wins (LWW) | Keep the write with the highest timestamp | Simple; silently drops concurrent writes and depends on clocks that drift (Time & Clocks) |
| Version vectors / siblings | Detect concurrent versions, keep all, let the application merge | No silent loss; the application must write merge logic (Dynamo's shopping cart merged by union) |
| CRDTs | Data types whose merge is commutative, associative and idempotent | Automatic, provably convergent; only for data that fits a CRDT (counters, sets, registers, text) |
| Avoid conflicts | Route all writes for a key to one leader (a "home" region) | No conflicts at all; that key's writes pay the latency to its home |
A CRDT (Conflict-free Replicated Data Type)7 is designed so that replicas can exchange state in any order, any number of times, and still end up equal. The simplest is the grow-only counter: each replica increments only its own slot, and merge takes the element-wise maximum.
// Grow-only counter CRDT: e.g. "fans interested in event 42", counted in 3 regions
class GCounter {
private final long[] counts; // one slot per replica
private final int me; // this replica's index
GCounter(int replicas, int me) { this.counts = new long[replicas]; this.me = me; }
void increment() { counts[me]++; }
long value() { return java.util.Arrays.stream(counts).sum(); }
// merge = element-wise max: commutative, associative, idempotent
void merge(GCounter other) {
for (int i = 0; i < counts.length; i++)
counts[i] = Math.max(counts[i], other.counts[i]);
}
}
If SΓ£o Paulo counted [3, 0, 0] and Lisbon [0, 2, 0], both merge to [3, 2, 0] and report 5 β no increment lost, no coordination needed. A single shared number with LWW would report 3 or 2.
The spectrum at a glance
| Model | Guarantee | Coordination cost | Available during a partition? | Typical implementation |
|---|---|---|---|---|
| Linearizable | Single-copy, real-time recency | Highest: leader or majority round trip per operation | No (minority side must refuse) | Raft/Paxos-based stores (etcd, Spanner, CockroachDB), single leader with reads at the leader |
| Sequential | One global order for all | High | No | Mostly theory / hardware memory models |
| Causal | Cause before effect, for everyone | Medium: track dependencies | Yes | Logical/vector clocks, causal sessions |
| Read-your-writes, monotonic reads | Per-session sanity | Low: route or wait per session | Yes, if the session stays on its side | Leader reads after write, LSN tokens, sticky replicas |
| Eventual | Converge if writes stop | Lowest | Yes | Async replication, caches, DNS, leaderless at ONE |
BoxOffice: one consistency model per data domain
| Data | Model | Why | How |
|---|---|---|---|
| Seat status and holds | Linearizable | Two fans must never hold the same seat | Single PostgreSQL primary per event; conditional UPDATE/SELECT β¦ FOR UPDATE |
| Payments | Linearizable + idempotent | A double charge is unacceptable | Primary only; idempotency key forwarded to the provider |
| My tickets, My orders | Read-your-writes | The fan must see what she just bought | Read from the primary for the fan's own data, or LSN token |
| Remaining-seats counter on the event page | Monotonic reads (eventual is tolerable) | It may lag, but must not jump backwards | Sticky replica or cache with a short TTL |
| Event catalogue, images, descriptions | Eventual | Seconds of staleness hurt nobody | Replicas, cache, CDN |
| "Fans interested" counters, analytics | Eventual (CRDT) | Approximate is fine, availability matters | Per-region counters merged |
The design principle: strong where money or uniqueness is at stake, weak everywhere else β because strong consistency is paid on every request.
In the interview
When the interviewer says "and the user should see their update immediately", translate it into a model: "that's read-your-writes, not global strong consistency β I'll read the user's own data from the leader, or pass the write's log position as a token." Then name the one place that does need linearizability (inventory, balances, uniqueness) and explain how you get it (single leader + consensus-based failover, conditional writes). Interviewers reward precise vocabulary and a per-domain choice; they penalise "everything is strongly consistent" (slow, expensive) and "everything is eventually consistent" (double bookings).
Best practices
- Write the consistency model of each data domain in the design doc β one row per domain (seats, payments, My tickets, catalogue), with the mechanism that provides it; reviewers check code against it.
- Default to the primary and opt in to replicas per transaction β a read goes to a replica only when someone marked it as tolerant of staleness. In BoxOffice:
@Transactional(readOnly = true)routes to the replica in lab 6 (box above). - Measure stale reads with a write-then-read test β write, read back immediately from the replica, count the misses; under load the number is never zero. In BoxOffice:
load/read-your-writes.jsof lab 6 (6% at no delay, 98% at 1 s). - Use a
versioncolumn for read-modify-write edits β a conditionalUPDATE β¦ WHERE version = :vturns a lost update into a visible conflict. In BoxOffice:inventory.seats.version(lab 6). - Show freshness where staleness is allowed β "updated 3 s ago" on a counter served from a replica or a cache sets the fan's expectation and saves a support ticket.
Pitfalls
- Read/write split by default: sending all reads to replicas breaks read-your-writes on the first screen after a write.
- Caches are replicas too: putting Redis or a CDN in front of a linearizable store makes those reads eventually consistent (Caching).
- Load balancers break session guarantees: two consecutive reads may hit two different replicas with different lag.
- Async replication + automatic failover = lost writes: the promoted follower may not have the last acknowledged writes. Use semi-synchronous replication for data you cannot lose.
- Confusing "eventual" with "soon": eventual consistency has no time bound; under load the lag can reach minutes. Monitor replication lag and alert on it.
How it connects
flowchart LR
W["write on the leader"] --> REPL{"replication<br/>sync or async?"}
REPL -->|"sync / quorum"| LIN["linearizable reads"]
LIN --> COST["pays latency and availability<br/>β CAP & PACELC"]
REPL -->|"async: lag"| STALE["stale reads"]
STALE --> SG["session guarantees<br/>read-your-writes, monotonic reads"]
W2["writes on several leaders"] --> CONF["concurrent writes"]
CONF --> RES["LWW Β· version vectors Β· CRDTs<br/>β Time & Clocks"]
LIN --> CONS["leader chosen by consensus<br/>β Consensus & Quorums"] Palette components that are replicas (and therefore have a consistency model): sql (primary + read replicas, scenario db-replicas) Β· nosql (leaderless, scenario sharding) Β· cache (scenario read-heavy-cache) Β· cdn (scenario cdn-static) Β· stream (replicated partitions). Builds on: failure models. Continues in: CAP & PACELC, Databases β replication, Caching β invalidation.
Key takeaways
- Replication serves availability, read throughput and latency; all the difficulty is in propagating writes.
- Single-leader avoids write conflicts; multi-leader and leaderless accept writes everywhere and must resolve conflicts (LWW, version vectors, CRDTs, or avoid them with a home leader).
- Synchronous vs asynchronous replication trades write latency and availability for durability on failover; semi-synchronous or quorum is the usual compromise.
- Consistency models range from linearizable (single-copy illusion) through causal and session guarantees (read-your-writes, monotonic reads) to eventual.
- Choose per data domain: linearizable for seats and money, read-your-writes for a user's own data, eventual for the catalogue and counters.
-
KLEPPMANN, M.; RICCOMINI, C. Designing Data-Intensive Applications, 2nd ed. O'Reilly. Chapter "Replication".Β β©
-
DECANDIA, G. et al. Dynamo: Amazon's Highly Available Key-value Store. SOSP, 2007.Β β©
-
Consistency Models β Jepsen (Kyle Kingsbury), a clickable map of the models and their relationships.Β β©
-
HERLIHY, M.; WING, J. Linearizability: A Correctness Condition for Concurrent Objects. ACM TOPLAS, 1990.Β β©
-
MAHAJAN, P.; ALVISI, L.; DAHLIN, M. Consistency, Availability, and Convergence. UT Austin TR-11-22, 2011.Β β©
-
TERRY, D. et al. Session Guarantees for Weakly Consistent Replicated Data. PDIS, 1994.Β β©
-
SHAPIRO, M. et al. Conflict-free Replicated Data Types. SSS, 2011.Β β©