Internals · DistributedAtlasDB V11 · Replicationreplicationwal shippingstreaming replicationlogical replicationlsn

Replication Internals: WAL Shipping, LSNs, Lag and Failover

A replica is a machine that consumes the primary's change log and re-applies it; every property of replication — what a replica can serve, how far behind it is, what a failover loses, whether two primaries can exist — is a statement about positions in that log.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    One machine's disk can die, and one machine's CPU can serve only so many reads. You need a second copy of the data that is current enough to serve reads and complete enough to take over.

  2. Naive solution

    Copy the data files to another machine every night; point read traffic at the copy; restore from it if the primary dies.

  3. Why it breaks

    The copy is up to a day stale, so reads lie and a failover loses a day of writes; and copying files under a running database yields a torn, inconsistent snapshot unless you stop the world.

  4. Better idea

    The primary already writes a sequential, ordered description of every change before it touches a page — the WAL. Ship that stream continuously and have the replica replay it; the replica is then a crash-recovering copy that never finishes recovering.

  5. Internal mechanism

    Primary → WAL record at LSN n → sender process → network → receiver on the replica writes it, fsyncs, replays it into its own pages. Lag is the difference between the primary's LSN and the replica's replay LSN. The commit acknowledgement can be released after the local fsync (async), after one replica confirms (semi-sync), or after all listed replicas confirm (sync).

  6. Trade-offs

    Async: fast commits, stale replica reads, lost tail on failover. Sync: no lost writes, every commit waits on the slowest standby, and a dead standby blocks all writes. Physical streams are byte-identical and cannot cross versions or filter tables; logical streams decode row changes and can, at the price of decoding cost and no DDL.

  7. Real database

    PostgreSQL streaming replication with pg_stat_replication, slots and pg_rewind; MySQL binlog replication with GTIDs; Patroni and Orchestrator for automated promotion.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

A replica replays the primary's log

The primary appends every change to its write-ahead log and sends the same bytes to each replica as they are written. The replica applies them to its own copy of the pages, so it converges on the primary's state a little behind. Reads can go to the replica; writes cannot.

How far behind is measured as a difference between log positions — the LSN the primary has written minus the LSN the replica has applied — and can be converted into seconds. Whether the primary waits for the replica before telling the client "committed" is the synchronous/asynchronous choice.

Primary → change log → stream → replica → apply

The practical lesson said "the primary streams its changes"; this is what is being streamed. Every engine with a write-ahead log already produces an ordered, durable, self-describing sequence of changes before it modifies any page (Write-Ahead Logging). Replication is that sequence with a network in the middle: a sender on the primary reads records as they are flushed and pushes them; a receiver on the replica writes them to its own log, fsyncs, and reports back how far it got; an applier replays them into the replica's pages exactly as crash recovery would (Crash Recovery). A replica is a database that is permanently in recovery, and that is not a metaphor — it is the same code path.

Because the stream is ordered, every position in it has a name. In PostgreSQL that is the LSN, a 64-bit byte offset into the WAL; in MySQL a binlog file and position, or the GTID of the last executed transaction. Everything that follows — lag, acknowledgement, failover safety, divergence — is a comparison of two such positions.

The replication pipeline and the three LSNs a replica reports
networkasyncsync: after flushclient COMMITprimary WAL: record at LSN n, fsyncsender (walsender)replica receiver: write_lsn → flush_lsnreplica applier: replay_lsn"committed"
UserLLMAgentToolDataDecisionHumanGuardrail

Physical vs logical

Physical (WAL shipping / streaming) sends the log records themselves — "on block 812 of relation 16412, replace bytes 4080–4136 with these". The replica applies them to identical pages and becomes a byte-for-byte copy: same indexes, same bloat, same free-space map, same catalog. That makes it cheap (no decoding, no SQL execution, apply is sequential page writes) and complete (DDL, sequences, everything), but rigid: same major version, same architecture, whole cluster or nothing, and the replica is read-only because any local write would corrupt the byte identity.

Logical decodes the same WAL into row changes — INSERT INTO orders (id, total) VALUES (…), UPDATE … WHERE id = … with old and new tuples — and sends those. The subscriber applies them as ordinary transactions, so it can be a newer version (zero-downtime upgrade), hold extra indexes or tables, receive only some tables, or be a data warehouse, a Kafka topic or a search index (change-data-capture). The price: decoding costs CPU on the primary, apply is single-threaded per subscription unless parallelised, DDL is not replicated, tables need a replica identity (a PK) for UPDATE and DELETE, and sequences and large objects are excluded. Logical decoding also needs the primary to keep WAL until the consumer confirms it — a replication slot — which is the origin of most "disk full" incidents involving replication.

Physical vs logical replication
Physical (WAL streaming)Logical (decoded row changes)
What is sentWAL records: page-level byte changesRow events with old/new values
Replica isByte-identical, read-onlyAn independent database applying writes
Version / schemaSame major version, same schemaDifferent version, subset, extra indexes, other system
DDL, sequencesReplicatedNot replicated (DDL), sequences excluded
Apply costSequential page writes, cheapSQL execution per row, needs a PK for UPDATE/DELETE
Failover targetYes — promote itUsually no: it is not a copy
Typical useHA + read scalingUpgrades, CDC, selective replication, multi-master

Synchronous, asynchronous, semi-synchronous: where the ack sits

The mode names *which position in the pipeline COMMIT waits for* before replying. Asynchronous: the primary fsyncs its own WAL and replies — 1–3 ms — and the record streams afterwards; a replica may be anywhere behind, and if the primary dies the un-streamed tail is gone. Synchronous: the primary sends the record, waits for each listed standby to report it has fsynced (remote_flush) or applied (remote_apply) it, then replies. Every commit now costs the slowest standby's round trip plus its fsync; within a datacenter ~1–2 ms extra, across regions 50–150 ms. And if a synchronous standby stops responding, every commit blocks: synchronous replication converts a replica failure into a primary outage unless a quorum form is used. Semi-synchronous (ANY 1 (a, b) in PostgreSQL; rpl_semi_sync in MySQL) waits for *any one* of several standbys — durable on two machines, fastest of the candidates, tolerant of one standby dying.

The choice is per transaction, not per cluster: SET LOCAL synchronous_commit = off for an audit-log insert that can be replayed, remote_apply for the payment whose confirmation page will be read from a replica. Remote apply deserves attention: it is the only mode under which a replica listed as synchronous can never serve a stale read of an acknowledged write, and it is the most expensive because it waits for replay, not just for the fsync.

One commit, three modes (times illustrative)
t=0.0 ms  client: COMMIT
t=0.1     primary appends commit record at LSN 0/1A2B448 to WAL buffers
t=2.0     primary fsync of WAL ────────────────────────────────── ASYNC: reply "committed" here
t=2.5     walsender pushes 0/1A2B448 to replicas A and B
t=3.5     A: received, written, fsynced → reports flush_lsn 0/1A2B448 ── SEMI-SYNC (ANY 1): reply here
t=4.0     A: replayed → replay_lsn 0/1A2B448
t=9.0     B (slower link): flush_lsn 0/1A2B448 ─────────────────── SYNC (FIRST 2 / remote_flush): reply here
t=10.5    B: replay_lsn 0/1A2B448 ─────────────────────────────── SYNC remote_apply: reply here

if B stops responding:  ASYNC and SEMI-SYNC unaffected;  SYNC: every COMMIT on the primary waits forever

Lag is a difference of LSNs; stale reads are a difference of LSNs

A replica is at three positions at once: what it has received, what it has flushed to its own log, and what it has replayed into pages. Reads see only the third. Lag in bytes is primary_current_lsn − replay_lsn; lag in time is how long ago the primary wrote the record the replica is currently replaying (replay_lag in pg_stat_replication, computed from timestamps the primary embeds). Bytes tell you how much work remains; seconds tell you how stale a read is. Both are needed: a replica 2 MB behind on an idle primary may be seconds stale; one 2 MB behind on a busy primary may be 50 ms stale.

A stale read is a read served from a replay position below the LSN of the write the client was acknowledged for. The fixes in Replication and Read Scaling are all LSN comparisons: after a write, remember pg_current_wal_lsn(); before reading from a replica, check pg_last_wal_replay_lsn() >= that and wait or fall back to the primary. Lag has causes worth distinguishing: network (bytes per second), apply (the replica's single replay process cannot keep up — a large index build or a bulk update on the primary), and conflict (a long query on the replica holds pages that replay needs to change; PostgreSQL either waits max_standby_streaming_delay or cancels the query — the "canceling statement due to conflict with recovery" error, and the reason hot_standby_feedback exists, which in turn holds back VACUUM on the primary).

Lag from both ends
1-- on the primary: every connected replica and its three positions
2SELECT application_name, state, sync_state,
3 pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn) AS send_lag_bytes,
4 pg_wal_lsn_diff(pg_current_wal_lsn(), flush_lsn) AS flush_lag_bytes,
5 pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes,
6 write_lag, flush_lag, replay_lag
7FROM pg_stat_replication;
8
9-- on the replica: how stale am I?
10SELECT pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn(),
11 now() - pg_last_xact_replay_timestamp() AS staleness;
12
13-- read-your-writes: the app remembers the LSN of its write …
14SELECT pg_current_wal_lsn(); -- on the primary, after COMMIT'0/1A2B448'
15-- … and only reads from a replica that has replayed it
16SELECT pg_last_wal_replay_lsn() >= '0/1A2B448'::pg_lsn; -- on the replica

Failover: the promotion sequence and split brain

Promotion is a sequence, and every step has a failure mode. Detect that the primary is gone — with a timeout, which means "unreachable for 10 s", not "dead". Fence the old primary so it cannot keep accepting writes: power it off, revoke its storage, or rely on a lease it must renew through the majority. Choose the replica with the highest flushed LSN — promoting any other loses writes that replica had. Promote: the replica finishes replaying what it has, opens a new timeline (PostgreSQL writes a history file; MySQL starts issuing GTIDs under its own server UUID), and accepts writes. Re-point every other replica and the application (DNS, a proxy, a virtual IP, a connection string in a config store). Rejoin the old primary as a replica when it comes back — which requires discarding the WAL it wrote after the divergence point (pg_rewind finds it by comparing timeline histories; MySQL by GTID set difference) before it can follow the new leader.

What is lost: under async replication, every acknowledged write the old primary had not yet streamed to the promoted replica. Under sync or semi-sync to the promoted node, nothing. Split brain is the failure of step two: the old primary was partitioned rather than dead, kept accepting writes from clients that could still reach it, and now two histories exist that both contain acknowledged commits. There is no merge for byte-level WAL; one side is discarded. Every serious HA manager (Patroni, Stolon, Orchestrator, cloud managed services) runs leader election through a consensus store precisely so that a node can only be primary while it holds a majority-granted lease — the mechanism Distributed Fundamentals: Partial Failure, Quorums, Consensus, Recovery derives.

Promotion and the divergence it creates
timeline 1 (old primary A):   … ─ LSN 100 ─ 101 ─ 102 ─ 103 ─ 104 ─ 105   (103–105 never reached B: async)
                                                     │
                                                     └─ B last flushed 102 → promoted
timeline 2 (new primary B):                           102 ─ 103' ─ 104' ─ 105' ─ 106' …

  lost:       A's 103, 104, 105 — acknowledged to clients, present nowhere else
  diverged:   if A comes back and is not fenced, A's 103–105 vs B's 103'–105' — two acknowledged histories
  rejoin:     pg_rewind A to LSN 102 (discard 103–105), then A streams timeline 2 from B
  replica C:  if C had flushed 104 from A, it is AHEAD of B → must also be rewound, or it cannot follow

Replication slots and WAL retention

PostgreSQL implementation

The primary recycles WAL segments once a checkpoint has made them unnecessary for its own recovery — but a replica that is behind, or disconnected, still needs them. Without protection, a replica that falls behind by more than wal_keep_size finds its next segment gone and must be rebuilt from a base backup. A replication slot (pg_create_physical_replication_slot, or the slot every logical subscription creates) records the oldest LSN its consumer has confirmed (restart_lsn) and forbids recycling anything after it. Slots make replicas robust to disconnection and are mandatory for logical decoding, which must also retain the catalog state needed to decode old records.

The cost is that a slot whose consumer has vanished — a decommissioned replica, a CDC connector that crashed — pins WAL forever. pg_wal grows until the disk is full and the primary stops. max_slot_wal_keep_size (13+) caps it by invalidating the slot instead; pg_replication_slots.wal_status (reserved, extended, unreserved, lost) and safe_wal_size are the columns to alert on. Slots also carry xmin when hot_standby_feedback is on, holding back VACUUM on the primary for as long as the replica's longest query runs — a replica can bloat the primary.

Slots and what they are holding
1SELECT slot_name, slot_type, active, wal_status,
2 pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal,
3 pg_size_pretty(safe_wal_size) AS headroom
4FROM pg_replication_slots;
5
6-- an inactive slot with gigabytes retained is a disk-full incident in progress
7SELECT pg_drop_replication_slot('old_analytics_replica');

Binlog and GTIDs

MySQL / InnoDB implementation

MySQL replicates from the binary log, a server-level log of transactions written after the storage engine has prepared them — InnoDB's redo log and the binlog are kept consistent by an internal two-phase commit, which is why sync_binlog = 1 and innodb_flush_log_at_trx_commit = 1 are set together for a crash-safe primary. In row format (the default) each event carries the before- and after-image of a row, making it closer to PostgreSQL's logical stream than to physical WAL shipping; MySQL replicas are therefore independently writable (dangerous), can differ in schema, and apply events by executing them. A replica's I/O thread pulls events into a relay log; applier threads execute them, in parallel where the primary's write-set tracking proves the transactions did not conflict.

A GTID (server_uuid:transaction_number) names each transaction globally, and every server maintains gtid_executed, the set it has applied. Replication becomes "send me everything not in my set" (MASTER_AUTO_POSITION), replicas can be re-pointed to a new primary without knowing file offsets, and divergence after a failover is visible as a set difference: if the old primary has A:1-105 and the promoted replica A:1-102, B:1-40, the old primary's A:103-105 are the lost or errant transactions. Semi-synchronous replication waits for at least one replica to acknowledge receipt (not apply) of the binlog event before committing; rpl_semi_sync_source_wait_point = AFTER_SYNC acknowledges only after the replica has it, which closes the window in which a crash could expose a commit no replica holds.

GTID sets after a failover
old primary A   gtid_executed = 3e11f0a2-…:1-105
promoted B      gtid_executed = 3e11f0a2-…:1-102 , 7c4d9b18-…:1-40      (B's own writes since promotion)
replica C       gtid_executed = 3e11f0a2-…:1-104                          (ahead of B: had 103–104 from A)

GTID_SUBTRACT(A, B) = 3e11f0a2-…:103-105   ← errant transactions on A: acknowledged, present nowhere in the new lineage
C cannot follow B until 103–104 are removed (rebuild) — the same rule as a PostgreSQL timeline

Key points

  • Replication is the WAL with a network in the middle: sender → receiver (write, flush) → applier (replay). A replica is a database permanently in crash recovery.
  • Physical streams are byte-identical and rigid; logical streams decode row changes and are flexible, costlier, and exclude DDL.
  • The mode names where the ack sits: async after local fsync, semi-sync after any one standby flushes, sync after every listed standby — each step buys durability with latency and a new way to block.
  • Lag is primary LSN minus replay LSN, in bytes and in seconds; a stale read is a read below the LSN you were acknowledged for. Read-your-writes is an LSN comparison.
  • Failover: detect, fence, choose the highest LSN, promote onto a new timeline, re-point, rewind the old primary. Async loses the unshipped tail; missing fencing creates split brain.
  • PostgreSQL slots pin WAL until the consumer confirms (and fill disks when it vanishes); MySQL GTID sets make positions global and divergence a set difference.

Replication stream

Primary → WAL → replication stream → replica → apply
Every change is a WAL record with an LSN. Replicas consume the stream and replay it. The mode decides at which LSN position the client hears 'committed' — and therefore what can be lost and what can be stale.
PrimaryWAL end: 0/1A007F8 (LSN 3)acked to clients: LSN 301231 msReplica Areceived LSN 3 · applied LSN 3 · lag 0 rec / 0 ms6 msReplica Breceived LSN 3 · applied LSN 3 · lag 0 rec / 0 msack point (Asynchronous): after the local WAL fsync
Primary at LSN 3; both replicas caught up. Each write appends a WAL record; the mode decides when the client is told "committed".
PostgreSQL implementation
lag A
0 rec / 0 ms
lag B
0 rec / 0 ms
commit latency
unacked commits
0
at risk on failover
0
synchronous_standby_names = '' -- lag in bytes: SELECT client_addr, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) FROM pg_stat_replication;

Lag is a difference between two LSNs — the primary's WAL end and the replica's replay position — measured in bytes or in the records between them, and converted to seconds by how long ago the primary wrote the record the replica is at. "At risk" counts acknowledged records no replica has yet received: what an async failover would lose.

Educational simulation — fsync 2 ms, one-way latency 1 ms (A) and 6 ms (B), 20 ms between writes; real lag depends on WAL volume, network and the replica's replay throughput.

When to use — and when not

Use it when
  • This mechanism fits when reads outnumber writes and can tolerate bounded staleness, or when a hot standby for failover is required.
  • Synchronous or semi-sync to at least one standby fits when an acknowledged write must survive the loss of the primary.
Avoid it when
  • This mechanism does not scale writes: every replica applies every write. That is partitioning (Partitioning Internals: Key → Partition Function → Node).
  • Synchronous replication across regions fits poorly for latency-sensitive commits; use it per transaction or with a local synchronous standby.

Failure modes

  • Async failover promoting a replica that was behind: acknowledged writes lost, and a faster replica that must be rewound.
  • A synchronous standby dies or hangs: every COMMIT on the primary blocks until it is removed from synchronous_standby_names.
  • An orphaned replication slot retaining WAL until the primary's disk fills.
  • Unfenced old primary after a partition-triggered promotion: split brain, two acknowledged histories, one discarded.
  • Replay conflict: long replica queries cancelled, or hot_standby_feedback bloating the primary.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.

Cross-domain bridges
  • NetworkingLatency and bandwidth of a link Replication lag: a replica is never closer than one round trip behind, and WAL volume must fit the linkCross-region replication adds 50–150 ms to every synchronous commit; that is the network speaking, not the database.
  • Distributed SystemsState machine replication A replica is a deterministic state machine fed the primary's ordered log