A standby replays WAL and serves read queries from the same pages. When
replay needs to change a page a running query is reading, something has
to give.
Why the conflict exists
Lesson XIV-01 established that replay is the crash recovery process,
applying records strictly in order. It cannot skip a record, cannot
reorder them, and cannot apply half of one.
Meanwhile a query on the standby holds a snapshot, which requires that
the row versions its snapshot can see continue to exist.
The primary knows nothing about that query. It vacuums on its own
schedule, removes row versions that are dead by its own reckoning, and
ships the WAL that says so. Replay must apply it.
Two irreconcilable requirements, and PostgreSQL resolves them by
cancelling the query.
With feedback off
Read-only / Safethe setup— EXECUTION VALIDATED on 18.6. Settings on the standby.
$ SELECT name, setting FROM pg_settings WHERE name IN ('max_standby_streaming_delay','hot_standby_feedback');
name | setting
-----------------------------+---------
hot_standby_feedback | off
max_standby_streaming_delay | 1000
A query on the standby opens a snapshot and holds it. On the primary,
rows that snapshot can see are deleted and vacuumed:
Read-only / Safethe primary removes what the standby still needs— EXECUTION VALIDATED on 18.6, while the standby snapshot was open.
$ DELETE FROM conflict_demo WHERE id % 2 = 0;
VACUUM (VERBOSE) conflict_demo;
DELETE 100000
tuples: 100000 removed, 100000 remain, 0 are dead but not yet removable
Service impact possiblewhat the standby query received— Real error from 18.6.
$ -- the session that had been holding the snapshot
ERROR: canceling statement due to conflict with recovery
DETAIL: User query might have needed to see row versions that must be removed.
max_standby_streaming_delay bounded the wait at one second, and the
query lost. That setting is how long replay will wait before
cancelling, not a query timeout — the clock is on replay, not on the
query.
With feedback on
hot_standby_feedback = on makes the standby report its oldest xmin to
the primary, which then treats it as it would a local transaction’s
horizon.
Read-only / Safethe same test, feedback on— EXECUTION VALIDATED on 18.6. The standby query ran to completion.
$ -- on the standby, after the sleep
after_sleep
-------------
200000
Read-only / Safewhat it cost the primary— EXECUTION VALIDATED on 18.6, run on the primary while the standby query was open.
$ DELETE FROM conflict_demo WHERE id % 3 = 0;
VACUUM (VERBOSE) conflict_demo;
DELETE 66667
tuples: 0 removed, 200000 remain, 66667 are dead but not yet removable
removable cutoff: 229577, which was 1 XIDs old when operation ended
Zero removed. A read query on a different machine held the primary’s
vacuum horizon exactly as a long local transaction would — everything in
lesson VII-05 applies, including the table that went from 1.7 MB to
19 MB and never came back.
Read-only / Safeand once the standby query ended— EXECUTION VALIDATED on 18.6, the very next vacuum.
$ VACUUM (VERBOSE) conflict_demo;
tuples: 66667 removed, 133333 remain, 0 are dead but not yet removable
All 66,667 removed on the next pass. The bloat was deferred, not
avoided — which is the honest way to describe what feedback buys you.
With a large delay instead
The third option is to keep feedback off and let replay wait.
Read-only / Safereplay held off by one conflicting query— EXECUTION VALIDATED on 18.6. max_standby_streaming_delay = 300s, one long read query on the standby, pgbench running on the primary.
The query survived. The standby served data 30 seconds stale, and would
have gone on falling behind for as long as the query ran.
What to take from this
Replay cannot yield, and a snapshot cannot lose its row versions.
Something must give.
max_standby_streaming_delay is how long replay waits, not a
query timeout.
Feedback on: the query survived, and the primary’s vacuum removed
0 of 66,667 dead tuples until it ended. Bloat deferred, not
avoided.
With a slot, the held horizon is on pg_replication_slots.xmin, not
pg_stat_replication.backend_xmin. Measured NULL five times running.
Large delay instead: 124 MB and 30 seconds behind, from one query.
Feedback does not prevent lock conflicts. DDL on the primary still
cancels standby queries.
Failover target and reporting replica want opposite settings. Use two.
Cross-course references
Observability for Production Sysadmins — Part LIX (Database
observability) covers exporting conflict counters per standby, which
is how a reporting replica’s cancellations become visible to the team
running the primary.
Linux for Production Sysadmins — Part LXXXII (Root cause
analysis) covers tracing a cancelled query on one host to a setting
changed on another.
Quiz
Knowledge check · 6 questions
Q1. A team enables hot_standby_feedback to stop query cancellations on their reporting replica. Cancellations drop but do not disappear, and the survivors correlate with schema migrations. Why?
Q2. A primary shows steadily growing bloat. Autovacuum runs and reports tuples not yet removable. pg_stat_replication.backend_xmin is NULL for the one connected standby. What should be checked next?
Q3. What does max_standby_streaming_delay actually measure?
Q4. Which are genuine costs of hot_standby_feedback = on? Select all that apply.
Q5. With hot_standby_feedback on, the dead tuples vacuum could not remove during a standby query are removed by the next vacuum after it ends.
Q6. A single standby serves both as the failover target and as the reporting replica. Explain why this configuration disappoints, and what to do instead.
Passing score: 75%. Answers are checked in this browser.