Inside a PostgreSQL server
Most database alerts at 3 a.m. trace back to a few moving parts: processes, memory, the write-ahead log and row versions. This lesson opens the box, so you can read what Mangrove's order database is doing and predict what will go wrong before it does.
By the end you can
- Name the processes on a PostgreSQL server, what each one does, and why every connection costs memory
- Follow one UPDATE from shared buffers to the WAL to the data files, and explain how crash recovery uses the WAL
- Read xmin, xmax and ctid to see row versions, and decide when a dead version can be removed
- Explain what VACUUM, VACUUM FULL, the visibility map and freezing do, and find what blocks cleanup
- Check transaction ID age long before wraparound turns into an outage
The everyday picture
Picture the back office of a busy Mangrove store. A supervisor at the door never serves anyone. When a customer arrives, the supervisor gives that customer a clerk of their own for the whole visit. Ten customers, ten clerks. That is how PostgreSQL handles connections.
The clerks share one big work table in the middle of the room. They pull boxes from the stockroom onto the table, change what is inside, and leave them there. A night crew carries them back later, many boxes at once. But before a clerk tells a customer "done", the clerk writes the change in ink in a notebook at the till. If the lights go out, the night crew reads the notebook and redoes every change that never reached the stockroom. The work table is memory, the stockroom is disk, and the notebook is the write-ahead log.
One more habit: clerks never erase a price label. They stick a new label on top and cross out the old one, because a customer holding an old flyer may still ask about the old price. A cleaner peels off crossed-out labels, but only when no customer in the store could still need them. One customer who stays for six hours stops the cleaner for the whole shop. That is MVCC and vacuum.
One process per connection
When PostgreSQL starts, it runs one parent process, the postmaster. It does no query work itself. When a client connects, the postmaster starts a new operating system process for it, called a backend. The backend parses, plans and runs every query from that connection until it disconnects. So 300 connections from the NestJS APIs means 300 processes on the database VM.
Each backend has its own private memory: memory for sorts and hashes, plus caches of table definitions and query plans. An idle backend costs a few MB. A backend that has touched hundreds of tables, or is running a big sort, costs much more. That is the reason underneath the pooling advice in Running Postgres in production: connections are processes, and processes cost memory and CPU time even while they wait.
Next to the backends run the background processes. On a PostgreSQL 16 primary you will see these:
- checkpointer: every few minutes, writes all changed pages from memory to the data files, spread out so the disk is not flooded.
- background writer: writes some changed pages ahead of time, so backends usually find a clean memory slot when they need one.
- WAL writer: flushes the write-ahead log to disk in the background, every 200 ms by default.
- autovacuum launcher: checks each database about once a minute (
autovacuum_naptime) and starts autovacuum workers (3 at most by default) for tables that need cleaning. - WAL sender: one per connected replica or backup tool, streaming the log to it (Replication and read replicas).
The process model has one consequence every DBA must know. All these processes share one region of memory. If any backend dies in an unclean way, for example killed by the Linux OOM killer, the postmaster cannot trust that shared memory any more. It logs terminating any other active server processes, disconnects every client, and runs crash recovery before it accepts connections again. One runaway report can drop every app order for a few seconds or longer, which is why the PostgreSQL docs recommend strict memory overcommit settings on Linux.
Quick check
The Linux OOM killer kills one backend that was running a huge report on the order database. What happens to the 180 other connections?
Separate processes sound safe, but they all share memory. After any unclean backend death the postmaster cannot trust shared memory, so it ends every backend and replays the WAL before it accepts connections again. That is why memory limits and pooling protect the whole server, not one query.
Memory: shared buffers, the OS cache and work_mem
PostgreSQL stores tables and indexes in 8 kB pages. A backend never works on the data file directly. It first loads the page into shared buffers, its own page cache, then reads or changes it there. A changed page that is not yet on disk is called a dirty page.
PostgreSQL reads files through the operating system, so Linux also keeps recently read blocks in its page cache. A hot page can sit in both places at once. That is why shared buffers usually starts near 25% of RAM and not 80%: the rest of RAM still works as a second cache. A miss in shared buffers that hits the OS cache is still fast; only a miss in both goes to disk. In EXPLAIN (ANALYZE, BUFFERS), "shared hit" means found in shared buffers, and "read" means asked from the OS, which PostgreSQL cannot split into cache and disk.
When shared buffers is full, a backend that needs a slot takes a page nobody used lately. If that page is dirty, it must be written out first. The background writer keeps clean slots ready so backends rarely wait for that; since PostgreSQL 16, pg_stat_io shows how often client backends had to write pages themselves.
The other memory setting that matters is work_mem, private to each backend. It is a limit for each sort or hash step in a plan, not for the query or the connection. A sort that needs more spills to temporary files, and the plan shows Sort Method: external merge Disk: 210MB. Turn on log_temp_files to see which queries spill. Hash steps may use work_mem times hash_mem_multiplier, which is 2 by default since PostgreSQL 15. Running Postgres in production shows how quickly these limits multiply across connections; Finding and fixing slow queries covers tuning them with real numbers.
The write path: WAL first, data pages later
Writing every changed page to its data file at commit would be slow: one order touches pages in orders, order_line and several indexes, scattered across the disk. Instead, PostgreSQL records each change in the WAL, an append-only log, and writes the data pages later. The rule is simple: the log record for a change must reach disk before the changed page does, and before the commit is confirmed.
Here is one UPDATE orders SET status = 'picked' from the Rider app:
- The backend loads the page into shared buffers and changes it there. The page is now dirty.
- It appends a WAL record describing the change to the WAL buffers in shared memory.
- At
COMMIT, the backend writes the WAL up to its commit record into thepg_waldirectory and calls fsync, which waits until the disk confirms. Only then does the client seeCOMMIT. - Seconds or minutes later, the background writer or the checkpointer writes the dirty page to its data file.
One sequential write per commit is far cheaper than many random page writes, and commits arriving together can share one fsync.
A checkpoint writes every page that was dirty when it started. PostgreSQL starts one when checkpoint_timeout passes (5 minutes by default) or when the WAL written since the last one nears max_wal_size (1 GB by default). It spreads its writes over most of the interval (checkpoint_completion_target, 0.9 by default since PostgreSQL 14). Afterwards, older 16 MB WAL segment files can be recycled, unless archiving, a replication slot or wal_keep_size still needs them (Backups you have actually restored and Replication and failover).
After a crash, the data files may hold a mix of old and new pages. On restart, the startup process finds the last checkpoint's redo point in the control file and replays every WAL record after it; the log shows redo starts at 3A/41000028. Every committed transaction comes back, and nothing uncommitted survives. To survive pages torn in half by a power cut, the first change to each page after a checkpoint writes the whole 8 kB page into the WAL (full_page_writes). That is why WAL volume jumps right after each checkpoint.
Quick check
The order database VM loses power two seconds after the app received COMMIT for order 5102. The checkpointer had not yet written that page. After the restart, where is the order?
A commit is confirmed only after its WAL is on disk, so recovery can always rebuild the page. Losing the data page is expected and harmless. The exception is synchronous_commit = off, which confirms before the flush and can lose the last fraction of a second of commits after a crash, without corrupting anything.
MVCC: every UPDATE writes a new row version
PostgreSQL never changes a row in place. An UPDATE writes a new version of the row and marks the old version as ended; a DELETE only marks it. This is MVCC, introduced in Running Postgres in production. Every row version carries hidden system columns you can select:
xmin: the ID of the transaction that created this version.xmax: the ID of the transaction that deleted or replaced it, or 0. (Row locks such asSELECT … FOR UPDATEalso use xmax, so a non-zero xmax does not always mean the row is gone.)ctid: where the version lives, as (page, item). A new version usually gets a new ctid.
Each statement (or each transaction under REPEATABLE READ, see Transactions, ACID, isolation and locks) takes a snapshot: in effect, the list of transactions that had committed at that moment. A version is visible if its xmin committed before the snapshot and its xmax is 0, aborted, or not yet committed for that snapshot. You can see a snapshot with SELECT pg_current_snapshot();. It prints the oldest running transaction, the next unused ID, and the running ones, such as 48211907:48211915:48211907,48211909.
When no snapshot anywhere can see an old version any more, it is a dead tuple. The ID before which every snapshot agrees is the xmin horizon. VACUUM can remove a dead version only if the transaction that ended it is older than the horizon. And the horizon is set by the oldest thing that holds a snapshot:
- a long-running query or transaction, including a session left
idle in transaction(itsbackend_xmininpg_stat_activity); - a replication slot whose consumer is behind or gone (
xmininpg_replication_slots); - a replica with
hot_standby_feedback = onrunning a long query, which reports its snapshot to the primary; - a forgotten prepared transaction (
pg_prepared_xacts).
One old snapshot holds back cleanup in every table of the database: a 5-hour transaction on a quiet table still stops vacuum on the busiest one.
If no indexed column changes and the new version fits on the same page, PostgreSQL makes a HOT update: no new index entries, and the old version can be pruned during normal page reads. Compare n_tup_hot_upd with n_tup_upd in pg_stat_user_tables; a lower fillfactor leaves room on each page for HOT updates.
Quick check
Autovacuum runs on the rider table every few minutes, yet n_dead_tup keeps rising all afternoon. A reporting session has been idle in transaction since 11:00. Why is vacuum not helping?
Autovacuum does run, but it can only remove versions that ended before the horizon, and the idle session pins the horizon at 11:00. A plain idle transaction holds no lock that blocks vacuum; its snapshot is the problem. End the session, then set idle_in_transaction_session_timeout.
VACUUM, the visibility map and freezing
A plain VACUUM on a table does four jobs in one pass:
- Removes dead versions older than the horizon: first their index entries, then the versions on the table pages. The space becomes free for new rows on the same table.
- Records free space in the free space map, so inserts know which pages have room.
- Updates the visibility map: two bits per page. "All-visible" means every version on the page is visible to everyone. Index-only scans use it to skip the table, and the next vacuum skips those pages. "All-frozen" means every version is also frozen (see below).
- Freezes old row versions, so their transaction IDs can be reused safely.
Plain VACUUM takes a SHARE UPDATE EXCLUSIVE lock, so reads and writes continue. It does not shrink the file, except for empty pages at the very end. After a 2 million row cleanup, order_line is the same size on disk, with reusable space inside. That is normal: a table settles at a size where vacuum frees about as much as new rows use.
VACUUM FULL is a different tool. It copies the live rows into a brand-new file and drops the old one, which does return space to the OS. But it holds an ACCESS EXCLUSIVE lock the whole time, blocking every read and write, and it needs enough free disk for the full new copy plus its indexes. On the 800 GB order database that means hours of downtime; Upgrades, maintenance and on-call covers the online alternatives.
Freezing and wraparound. Transaction IDs are 32-bit and compared in a circle: about 2 billion behind count as the past, 2 billion ahead as the future. With orders, rider updates and jobs, the order database uses millions of IDs a day, so the circle comes round within a year or two. Before an old xmin could look like the future, vacuum freezes the row version: it marks it as visible to everyone, forever. Each table records its oldest unfrozen ID in pg_class.relfrozenxid; age(relfrozenxid) says how many transactions ago that was.
The settings form a ladder, checked for each table:
vacuum_freeze_min_age(50 million): versions older than this get frozen when vacuum visits their page.vacuum_freeze_table_age(150 million): above this age, vacuum becomes aggressive and reads every page that is not already all-frozen.autovacuum_freeze_max_age(200 million): autovacuum starts an anti-wraparound vacuum even if autovacuum is turned off. It shows asautovacuum: VACUUM public.order_line (to prevent wraparound)and, unlike normal autovacuum, it does not give way when a migration or another session asks for a conflicting lock.vacuum_failsafe_age(1.6 billion, PostgreSQL 14 and later): vacuum drops its speed limits and skips index cleanup to finish freezing as fast as it can.
If freezing cannot finish (usually because an old snapshot, slot or prepared transaction holds the horizon), PostgreSQL logs warnings when about 40 million IDs remain, and refuses to assign new transaction IDs when about 3 million remain (the exact margins are version dependent). At that point every write fails until someone vacuums, which on order_line could take hours. Alert on age(datfrozenxid) far earlier, around 500 million to 1 billion.
Build it
We will look inside a PostgreSQL 16 server the way a DBA does on the first day: its processes, a row version being born, the WAL position, and the queries that find dead rows, old snapshots and transaction ID age. Run steps 1 to 3 on a test server or a restored copy; steps 4 to 6 are read-only and safe on production.
-
See the processes
On the database VM, list every process owned by the
postgresuser. The Ubuntu packages setcluster_nameto16/main, so it appears in each process title. Client backends show user, database, client address and what they are doing.Terminalps -u postgres -o pid,ppid,cmdOutputPID PPID CMD 1021 1 /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main -c config_file=/etc/postgresql/16/main/postgresql.conf 1034 1021 postgres: 16/main: checkpointer 1035 1021 postgres: 16/main: background writer 1037 1021 postgres: 16/main: walwriter 1038 1021 postgres: 16/main: autovacuum launcher 1039 1021 postgres: 16/main: logical replication launcher 2210 1021 postgres: 16/main: walsender replicator 10.20.0.12(51732) streaming 3A/7C0E1F28 4811 1021 postgres: 16/main: app_orders orders 10.20.1.5(40112) idle 4812 1021 postgres: 16/main: app_orders orders 10.20.1.5(40114) UPDATEEvery process has PPID 1021: the postmaster started them all. The same picture from SQL works on a managed service too:
SQLSELECT backend_type, state, count(*) FROM pg_stat_activity GROUP BY backend_type, state ORDER BY count(*) DESC; -
Watch an UPDATE create a new row version
On a test database, make a small table and look at the hidden columns. Your transaction IDs will be different numbers; the pattern is what matters.
SQLCREATE TABLE demo_order (id int PRIMARY KEY, status text NOT NULL); INSERT INTO demo_order VALUES (1, 'placed'), (2, 'placed'), (3, 'placed'); SELECT ctid, xmin, xmax, id, status FROM demo_order; UPDATE demo_order SET status = 'picked' WHERE id = 2; -- its own transaction SELECT ctid, xmin, xmax, id, status FROM demo_order;Outputctid | xmin | xmax | id | status -------+------+------+----+-------- (0,1) | 752 | 0 | 1 | placed (0,2) | 752 | 0 | 2 | placed (0,3) | 752 | 0 | 3 | placed ctid | xmin | xmax | id | status -------+------+------+----+-------- (0,1) | 752 | 0 | 1 | placed (0,3) | 752 | 0 | 3 | placed (0,4) | 753 | 0 | 2 | pickedRow 2 now lives at
(0,4)with a new xmin. The old version at(0,2)is still on the page, invisible to you: a dead tuple. Now open two psql sessions. In session A runBEGIN; UPDATE demo_order SET status = 'delivered' WHERE id = 2;and do not commit. In session B, select row 2 again: B still sees(0,4)with statuspicked, but its xmax is now A's transaction ID. Commit A, and B sees the new version. That is MVCC with no locks between reader and writer. -
Find the WAL position and checkpoint pressure
SQLSELECT pg_current_wal_lsn(), pg_walfile_name(pg_current_wal_lsn()); -- PostgreSQL 16: timed (by checkpoint_timeout) vs requested (WAL volume, manual) SELECT checkpoints_timed, checkpoints_req FROM pg_stat_bgwriter; -- PostgreSQL 17 and later moved these to: -- SELECT num_timed, num_requested FROM pg_stat_checkpointer;Outputpg_current_wal_lsn | pg_walfile_name --------------------+-------------------------- 3A/7C0E1F28 | 000000010000003A0000007C checkpoints_timed | checkpoints_req -------------------+----------------- 2841 | 1907Requested checkpoints are about 40% of the total here, which means WAL volume keeps forcing checkpoints before the timer. That points at a
max_wal_sizethat is too small for the evening write rate.log_checkpointsis on by default since PostgreSQL 15; each checkpoint's log line shows how many buffers it wrote and how long it took. -
Find what is holding the horizon back
Run these three whenever dead rows pile up. The oldest
backend_xmin, slotxminor prepared transaction is your suspect.SQL-- Sessions with the oldest snapshots SELECT pid, usename, application_name, state, now() - xact_start AS xact_age, age(backend_xmin) AS xmin_age, -- transactions behind left(query, 50) AS last_query FROM pg_stat_activity WHERE backend_xmin IS NOT NULL ORDER BY age(backend_xmin) DESC LIMIT 5; -- Replication slots that pin old rows SELECT slot_name, active, age(xmin) AS xmin_age, age(catalog_xmin) AS catalog_xmin_age FROM pg_replication_slots; -- Forgotten two-phase transactions SELECT gid, prepared, owner, age(transaction) AS xid_age FROM pg_prepared_xacts; -
Watch dead tuples and transaction ID age
SQL-- Where dead versions pile up, and whether updates are HOT SELECT relname, n_live_tup, n_dead_tup, n_tup_upd, n_tup_hot_upd, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5; -- Tables closest to a forced freeze (200 million by default) SELECT c.oid::regclass AS table_name, age(c.relfrozenxid) AS xid_age, pg_size_pretty(pg_total_relation_size(c.oid)) AS size FROM pg_class c WHERE c.relkind IN ('r', 'm', 't') -- tables, materialized views, TOAST ORDER BY age(c.relfrozenxid) DESC LIMIT 5; -- The number to alert on, per database SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database ORDER BY 2 DESC; -
Make the server tell you what it is doing
Add these to
postgresql.conf(or the managed service's parameters) and reload. None of them needs a restart.INIlog_autovacuum_min_duration = '10s' # log every autovacuum that takes 10 s or more log_checkpoints = on # default since 15; keep it on log_temp_files = '10MB' # log sorts and hashes that spill past work_mem log_lock_waits = on # log waits longer than deadlock_timeout (1 s)SQL-- End sessions that sit idle inside a transaction, per application role ALTER ROLE app_orders SET idle_in_transaction_session_timeout = '5min'; ALTER ROLE warehouse_export SET idle_in_transaction_session_timeout = '15min'; SELECT pg_reload_conf(); -- applies the postgresql.conf changes aboveThe role settings apply to new connections. Pooled connections pick them up when the pool reconnects.
Bring it to your team
Ask these in your next database review or incident follow-up.
- "How many backends do we run at the evening peak, and how much memory can they use together if every report sorts at once?"
- "Which session holds the oldest snapshot right now, and does anything alert us when a transaction stays open for more than 30 minutes?"
- "Which replication slots exist, is every one of them active, and who owns each?"
- "What is age(datfrozenxid) on the order database, and at what value do we get paged?"
- "How long did crash recovery take the last time we tested it, and does our max_wal_size still fit that target?"
Red flags
- Sessions "idle in transaction" for hours, and no idle_in_transaction_session_timeout on any role
- VACUUM FULL in a scheduled job, or run at the evening peak "to free space"
- Someone cancelling a "to prevent wraparound" autovacuum so a migration can run
- An inactive replication slot nobody can explain
synchronous_commit = offset server-wide on the order database without anyone writing down what can be lost
Practice
Find the rows that changed after the load
A script loaded five orders into orders in one transaction. Later transactions updated some of them, and one update wrote the same status back, so you cannot tell from the data alone. Use the xmin system column: list the id and ctid (as text) of every row whose current version was written by a later transaction than the load. Hint: xmin::text::bigint turns the transaction ID into a number you can compare. The starter guesses from the status column instead.
BEGIN; CREATE TABLE orders (id int PRIMARY KEY, status text NOT NULL, rider_id int); INSERT INTO orders VALUES (5101, 'placed', NULL), (5102, 'placed', NULL), (5103, 'placed', NULL), (5104, 'placed', NULL), (5105, 'placed', NULL); COMMIT; BEGIN; UPDATE orders SET status = 'picked', rider_id = 41 WHERE id = 5102; COMMIT; BEGIN; UPDATE orders SET rider_id = 17 WHERE id = 5104; COMMIT; BEGIN; UPDATE orders SET status = 'placed' WHERE id = 5105; COMMIT;
-- Which rows were updated after the load?
SELECT id, ctid::text AS ctid
FROM orders
WHERE status <> 'placed';
[{"id": 5102, "ctid": "(0,6)"}, {"id": 5104, "ctid": "(0,7)"}, {"id": 5105, "ctid": "(0,8)"}]
Show a solution
SELECT id, ctid::text AS ctid
FROM orders
WHERE xmin::text::bigint > (SELECT min(xmin::text::bigint) FROM orders); -- newer than the loadThe load wrote five versions at (0,1) to (0,5). Each UPDATE wrote a new version at the next free slot, (0,6), (0,7) and (0,8), even the one that changed nothing visible. The old versions at (0,2), (0,4) and (0,5) are now dead tuples waiting for vacuum. On a real server, compare xmin values only for recent changes: transaction IDs wrap around, so a bigger number is not always newer.
Which dead versions can vacuum remove?
The table tuple_version models what the pageinspect extension shows for two tables: each row version's t_ctid, t_xmin and t_xmax (0 means not ended). xact_status says whether each transaction committed or aborted. session is shaped like pg_stat_activity; the xmin horizon is the smallest backend_xmin (NULL means the session holds no snapshot). The rules:
- live: xmin committed, and xmax is 0 or an aborted transaction
- dead_removable: xmin aborted (a rolled-back insert), or xmax committed and below the horizon
- dead_kept: xmax committed but not below the horizon
Return relname, live, dead_removable and dead_kept for each table. The starter treats every ended version as removable and ignores aborted transactions.
CREATE TABLE xact_status (xid bigint PRIMARY KEY, status text NOT NULL);
INSERT INTO xact_status VALUES
(1190, 'committed'), (1191, 'committed'), (1195, 'committed'), (1196, 'committed'),
(1201, 'committed'), (1203, 'aborted'), (1204, 'aborted'), (1207, 'committed'),
(1208, 'committed'), (1209, 'committed'), (1210, 'committed');
CREATE TABLE session (pid int PRIMARY KEY, state text NOT NULL, backend_xmin bigint);
INSERT INTO session VALUES (4101, 'active', 1209), (4102, 'idle in transaction', 1205), (4103, 'idle', NULL);
CREATE TABLE tuple_version (relname text, t_ctid text, t_xmin bigint, t_xmax bigint, PRIMARY KEY (relname, t_ctid));
INSERT INTO tuple_version VALUES
('orders', '(0,1)', 1190, 1201), ('orders', '(0,2)', 1190, 0),
('orders', '(0,3)', 1201, 1207), ('orders', '(0,4)', 1207, 0),
('orders', '(0,5)', 1190, 1203), ('orders', '(0,6)', 1203, 0),
('orders', '(0,7)', 1190, 1209), ('orders', '(0,8)', 1209, 0),
('order_line', '(0,1)', 1191, 1195), ('order_line', '(0,2)', 1195, 0),
('order_line', '(0,3)', 1191, 1196), ('order_line', '(0,4)', 1196, 1208),
('order_line', '(0,5)', 1208, 0), ('order_line', '(0,6)', 1191, 0),
('order_line', '(0,7)', 1204, 0), ('order_line', '(0,8)', 1191, 1210);
-- Live, removable and kept versions per table
SELECT relname,
count(*) FILTER (WHERE t_xmax = 0) AS live,
count(*) FILTER (WHERE t_xmax <> 0) AS dead_removable,
0 AS dead_kept
FROM tuple_version
GROUP BY relname;
[{"relname": "orders", "live": 4, "dead_removable": 2, "dead_kept": 2}, {"relname": "order_line", "live": 3, "dead_removable": 3, "dead_kept": 2}]
Show a solution
WITH horizon AS (
SELECT min(backend_xmin) AS xid FROM session -- NULLs are ignored: 1205
)
SELECT v.relname,
count(*) FILTER (WHERE ins.status = 'committed'
AND (v.t_xmax = 0 OR del.status = 'aborted')) AS live,
count(*) FILTER (WHERE ins.status = 'aborted'
OR (del.status = 'committed' AND v.t_xmax < h.xid)) AS dead_removable,
count(*) FILTER (WHERE ins.status = 'committed'
AND del.status = 'committed' AND v.t_xmax >= h.xid) AS dead_kept
FROM tuple_version v
JOIN xact_status ins ON ins.xid = v.t_xmin -- who created the version
LEFT JOIN xact_status del ON del.xid = v.t_xmax -- who ended it (none when 0)
CROSS JOIN horizon h
GROUP BY v.relname, h.xid;The idle-in-transaction session 4102 sets the horizon at 1205. Versions ended by 1207, 1208, 1209 and 1210 are dead, but that session might still see them, so they stay: this is what VACUUM VERBOSE reports as "dead but not yet removable". The version ended by 1203 is still live because 1203 rolled back, and the one created by 1203 is dead at once. End session 4102 and the horizon jumps to 1209, so three of the four kept versions become removable.
Check yourself
To cut write I/O at the evening peak, you raise max_wal_size from 1 GB to 32 GB and checkpoint_timeout to 30 minutes. Write I/O drops. Which new risk must you plan for?
After a crash, the startup process replays all WAL since the last checkpoint's redo point. Fewer, later checkpoints mean more WAL to keep and to replay. Commits never wait for checkpoints, only for the WAL flush, so nothing committed is lost; replicas receive WAL continuously, not at checkpoints.
The rider table keeps growing. VACUUM (VERBOSE) rider reports many versions "dead but not yet removable", but no session on the primary has an old backend_xmin. Where do you look next?
Something else is holding the horizon: a slot's xmin, a replica's reported snapshot, or a forgotten prepared transaction. VACUUM FULL cannot remove versions the horizon protects either, and it blocks every query. More workers or more memory do not move the horizon.
age(datfrozenxid) on the order database is 1.1 billion. An autovacuum "to prevent wraparound" has been running on order_line for 9 hours, and tonight's migration is stuck behind it. What do you do?
The freeze is the only way back from the limit, and autovacuum would soon restart it if cancelled, after losing hours of work. A restart does not reset IDs, and VACUUM FULL needs an exclusive lock and disk for a full copy. The real fix is to find the old snapshot or slot that let the age climb so high.
Flashcards
Key takeaways
- Every connection is a backend process with its own memory; one unclean backend death makes the postmaster reset every connection.
- Changes go to shared buffers and the WAL; COMMIT waits only for the WAL flush. Checkpoints write pages later, and crash recovery replays WAL from the last checkpoint.
- An UPDATE writes a new row version. Dead versions can be removed only after the oldest snapshot, slot or prepared transaction has moved past them.
- Plain VACUUM removes dead versions, updates the visibility map and freezes old rows without blocking. VACUUM FULL blocks everything and needs a full copy's worth of disk.
- Watch age(datfrozenxid) and the oldest backend_xmin. Never cancel an anti-wraparound vacuum; fix what held it back.
Sources
- Write-Ahead Logging (WAL), PostgreSQL documentation
- WAL configuration and checkpoints, PostgreSQL documentation
- Concurrency control: introduction to MVCC, PostgreSQL documentation
- Routine vacuuming, including wraparound, PostgreSQL documentation