03 · Streaming Replication¶
A standby is a copy of the primary server that continuously replays the primary's WAL (Level 2 · 09). It gives you a hot spare to fail over to, a place to run read-only queries, and the basis for zero-data-loss configurations. Physical streaming replication is built in, copies the whole cluster byte for byte, and is the foundation of almost every high-availability PostgreSQL setup.
Rather than describe it, this lesson builds one: a primary on port 54329 and a standby on 54330, both PostgreSQL 18.6 on one laptop. Running both on one machine is only for learning — a real standby must be on separate hardware, ideally in a separate failure zone.
Prerequisites on the primary¶
Defaults since PostgreSQL 10 already allow replication: wal_level = replica (this lab uses
logical, which includes it), max_wal_senders = 10, and pg_hba.conf needs a replication line for
the connecting role (the lab's trust rules cover it; in production use scram-sha-256 or certificates):
Create a dedicated role and a replication slot:
CREATE ROLE replicator LOGIN REPLICATION PASSWORD 'repl-dev';
SELECT pg_create_physical_replication_slot('standby1');
The slot makes the primary keep any WAL the standby has not yet received, so a standby that falls behind or restarts can always catch up. That guarantee has a cost you will see shortly.
Cloning the primary¶
-D standby1— the new data directory (3.3 GB here: every database in this course's lab cluster).-X stream— also stream the WAL generated during the copy, so the copy is consistent.-R— writestandby.signaland the connection settings, so the copy starts as a standby.-S standby1— use the slot.
-R wrote these into postgresql.auto.conf (trimmed):
primary_conninfo = 'user=replicator passfile=''/Users/.../.pgpass'' host=localhost port=54329 ...'
primary_slot_name = 'standby1'
The copy also inherited the primary's postgresql.conf, including its port. Before starting it on the
same machine I set port = 54330, a smaller shared_buffers, and hot_standby_feedback = on
(explained below).
LOG: redo starts at 1/DF000028
LOG: completed backup recovery with redo LSN 1/DF000028 and end LSN 1/DF000120
LOG: consistent recovery state reached at 1/DF000120
LOG: database system is ready to accept read-only connections
LOG: started streaming WAL from primary at 1/E0000000 on timeline 1
Watching it work¶
On the primary:
SELECT application_name, state, sync_state,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)) AS replay_lag_bytes,
replay_lag
FROM pg_stat_replication;
application_name | state | sync_state | replay_lag_bytes | replay_lag
------------------+-----------+------------+------------------+-----------------
walreceiver | streaming | async | 96 bytes | 00:00:00.000167
On the standby, data written to the primary appears, and writes are refused:
$ psql -p 54330 -d lab -c "SELECT * FROM repl_demo"
id | note
----+--------------------
1 | written on primary
$ psql -p 54330 -d lab -c "INSERT INTO repl_demo VALUES (2, 'on standby')"
ERROR: cannot execute INSERT in a read-only transaction
SELECT pg_is_in_recovery() returns true on a standby — useful in health checks and for routing.
Lag under load¶
During a 10-second write-heavy pgbench run on the primary (about 9,900 transactions per second),
sampled halfway through:
The standby was 83 MB and about 1.5 seconds of replay behind. Replication is asynchronous by
default: a commit returns once it is durable on the primary, and the standby catches up afterwards.
A failover at that moment could lose whatever WAL had not yet reached the standby. Monitor lag in
both bytes (pg_wal_lsn_diff) and time (replay_lag), and alert on it.
Synchronous replication¶
ALTER SYSTEM SET synchronous_standby_names = 'walreceiver'; -- the standby's application_name
SELECT pg_reload_conf();
Now each commit waits until the standby confirms it has flushed the WAL. Same benchmark:
async: latency average = 0.807 ms tps = 9912.837342
sync: latency average = 0.960 ms tps = 8336.187427
About 16% fewer transactions per second — with the "network" being a loopback interface. Across real
networks every commit pays at least one round trip to the standby, so the cost grows with distance.
synchronous_commit chooses how much the commit waits for: remote_write (standby received it),
on (flushed to its disk, the default), or remote_apply (replayed, so a read on the standby will see
it). Use ANY 1 (s1, s2) / FIRST 1 (s1, s2) to tolerate one standby being down.
What happens when the only sync standby is down¶
I stopped the standby and inserted a row:
after 3 s the session is waiting on: ('IPC', 'SyncRep')
INSERT returned after 3.0 s
WARNING: canceling wait for synchronous replication due to user request | The transaction has already committed locally, but might not have been replicated to the standby.
row visible on primary: 1
The commit hung — waiting forever for a standby that was not there — until I cancelled it after 3
seconds. And the cancellation did not undo anything: the transaction had already committed on the
primary, and the row was visible. So synchronous replication with a single standby turns "standby
down" into "primary cannot commit", and a client that times out cannot conclude its transaction failed.
Production setups use at least two synchronous candidates (ANY 1 (...)) and failover tooling that
manages synchronous_standby_names.
I reset synchronous_standby_names before continuing.
The replication slot trap¶
With the standby still stopped, another 10-second pgbench run on the primary:
slot_name | active | wal_status | retained
-----------+--------+------------+----------
standby1 | f | reserved | 746 MB
max_slot_wal_keep_size
------------------------
-1
The inactive slot made the primary keep 746 MB of WAL in 10 seconds, in case the standby came back.
A standby that is gone for good — decommissioned without dropping its slot — makes pg_wal grow until
the primary's disk fills up and it stops. It also holds back VACUUM if the slot carries an xmin
(with hot_standby_feedback). Protections:
max_slot_wal_keep_size(default-1, unlimited): cap retained WAL; a slot that exceeds it is invalidated (wal_status = 'lost') and the standby must be rebuilt — better than an outage.- Alert on inactive slots and on retained WAL size.
- Drop slots as part of decommissioning:
SELECT pg_drop_replication_slot('standby1');
When the standby restarted it caught up from the retained WAL — the slot did its job:
and the row committed while it was down was there:
$ psql -p 54330 -d lab -Atc "select note from repl_demo where id = 3"
committed while standby is down
Queries on the standby and conflicts¶
A standby replaying WAL may need to remove row versions that a long query on the standby is still
reading (because VACUUM on the primary removed them). PostgreSQL then either delays replay (up to
max_standby_streaming_delay, default 30 s) or cancels the standby query with "canceling statement due
to conflict with recovery". hot_standby_feedback = on makes the standby tell the primary which rows its
queries still need, avoiding most cancellations at the cost of some bloat on the primary. Choose per
standby: a reporting replica usually wants feedback on; a pure failover replica may not.
Promotion¶
$ psql -p 54330 -d lab -c "insert into repl_demo values (4, 'written on the promoted standby') returning note"
note
---------------------------------
written on the promoted standby
The standby finished replaying the WAL it had received, became a read-write primary, and started a new
timeline (2), recorded in pg_wal/00000002.history. Timelines are how PostgreSQL tells the history
of the old and new primaries apart from the moment of the fork.
I promoted it while pg_stat_replication on the old primary still reported 526 MB of replay lag, then
compared the pgbench_history table on both servers: identical row counts and the same latest
timestamp. The lag was WAL received but not yet replayed; promotion replays everything already
received before opening for writes. WAL that has not been received is what an async failover loses.
The old primary is still running and accepting writes on timeline 1. Two primaries is split brain:
clients writing to both create diverging data that cannot be merged. Real failover must fence the old
primary (stop it, cut its network, or have the load balancer stop routing to it) before promoting.
Turning the old primary into a standby of the new one requires pg_rewind or a fresh base backup.
Lesson 9 covers the tooling that automates this.
How It Actually Works¶
On the standby, the startup process applies WAL — the same code path as crash recovery, running
forever instead of stopping at the end of the log. A walreceiver process connects to the primary
using the replication protocol and asks to stream from a given LSN; on the primary a walsender
process reads WAL (from buffers or pg_wal) and sends it. The walreceiver writes and flushes the WAL
into the standby's pg_wal and reports its write, flush and replay positions back, which is what
pg_stat_replication and synchronous commit use.
A physical slot is a small record on the primary (in pg_replslot/) storing restart_lsn — the oldest
WAL the consumer still needs — and optionally an xmin. Checkpoints never remove WAL segments newer than
the oldest restart_lsn of any slot, which is both the guarantee and the disk-filling risk.
Because the standby is a byte-for-byte copy, both must run the same PostgreSQL major version on the same platform and architecture; that is the difference from logical replication (next lesson).
Common mistakes¶
- Replicas on the same host, rack or availability zone as the primary.
- Leaving slots for decommissioned standbys; no
max_slot_wal_keep_size; no alert on retained WAL. - A single synchronous standby with no fallback, so its failure stops writes.
- Promoting without fencing the old primary.
- Not monitoring lag, then discovering at failover time that the replica was hours behind.
- Assuming a cancelled synchronous commit was rolled back.
Exercise¶
- Build a primary and a standby on your machine with
pg_basebackup -R -S. Verify withpg_stat_replicationandpg_is_in_recovery(). - Run a long query on the standby while updating and vacuuming the same table on the primary, with
hot_standby_feedbackoff and then on. Record what happens to the standby query each time. - Configure
synchronous_standby_names = 'ANY 1 (s1, s2)'with two standbys (setapplication_namein eachprimary_conninfo). Stop one; show commits still succeed. Stop both; show what happens. - Set
max_slot_wal_keep_size = '200MB', stop the standby, generate WAL until the slot is invalidated, and recordwal_statusalong the way. - Promote the standby, then rejoin the old primary as a standby of the new one using
pg_rewind(it requireswal_log_hints = onor data checksums — which PostgreSQL 18 enables by default).