Part 3 · 3 chapters · ~18 min

MVCC, Vacuum and the Postgres Bargain

MVCC with tuple versions in the heap, snapshots and the visibility rules, why UPDATE is a delete plus an insert, bloat and how to measure and fix it, VACUUM lazy and full with the freeze map, autovacuum tuning, transaction ID wraparound, and the long transactions and replication slots that block vacuum.

7

Snapshots, visibility and the cost of UPDATE

InnoDB updates rows in place and keeps old versions in undo logs. Postgres writes a new tuple version into the heap and leaves the old one where it was, stamped with the deleting txid. Readers pick the version their snapshot can see. That makes rollback instant and reads lock-free, and makes cleanup (vacuum) a permanent part of the engine.

code
-- two sessions, watch versions appear
BEGIN; UPDATE accounts SET balance_kobo = balance_kobo + 100 WHERE id = 1;
SELECT ctid, xmin, xmax, balance_kobo FROM accounts WHERE id = 1;   -- new ctid, xmin = my txid
-- in another session, before COMMIT: the old version (old ctid) is still what it sees
COMMIT;
SNAPSHOTS AND VISIBILITY
is this tuple version visible to my snapshot?
snapshotxmin=100, xmax=105, xip=[102]tuple Axmin 90, xmax 0tuple Bxmin 102, xmax 0tuple Cxmin 95, xmax 101tuple Dxmin 106, xmax 0
swipe the figure sideways, or tap expand for full screen
1/6
the snapshot
A snapshot records xmin (every txid below it has finished), xmax (every txid at or above it had not started), and xip, the list of in-progress txids in between at the moment the snapshot was taken.
xmin, xmax and the in-progress listtaken per statement (READ COMMITTED) or per transaction
8

Bloat, VACUUM and autovacuum

code
-- dead tuples and the last time autovacuum ran
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, autovacuum_count
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10;

-- an estimate of bloat: pgstattuple gives exact numbers (and reads the whole table)
CREATE EXTENSION pgstattuple;
SELECT * FROM pgstattuple('accounts');            -- dead_tuple_percent, free_percent

-- autovacuum triggers when dead tuples > threshold + scale_factor × rows
-- defaults: 50 + 0.2 × rows  → a 100M-row table waits for 20M dead tuples
ALTER TABLE events SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_cost_limit = 2000);
operationwhat it doeslock
VACUUMremoves dead tuples, makes space reusable (does not shrink files except trailing empty pages), updates FSM and VM, freezesSHARE UPDATE EXCLUSIVE (reads and writes continue)
VACUUM FULLrewrites the table compactly, returning space to the OSACCESS EXCLUSIVE (blocks everything)
pg_repackrebuilds online with a trigger-copied shadow tablebrief exclusive lock at the swap
ANALYZErefreshes planner statisticslight

The most common production failure is autovacuum that cannot keep up: defaults tuned for small tables, cost limits throttling it, or something holding back the xmin horizon so vacuum cannot remove anything at all.

9

Wraparound, and what blocks vacuum

code
-- what holds the xmin horizon back (vacuum cannot remove tuples newer than this)
SELECT 'backend' AS kind, pid::text, backend_xmin, now() - xact_start AS age FROM pg_stat_activity WHERE backend_xmin IS NOT NULL
UNION ALL
SELECT 'slot', slot_name, xmin, NULL FROM pg_replication_slots WHERE xmin IS NOT NULL OR catalog_xmin IS NOT NULL
UNION ALL
SELECT 'prepared', gid, transaction, now() - prepared FROM pg_prepared_xacts
ORDER BY 3 NULLS LAST;

Three things block vacuum: a long-running or idle-in-transaction session (set idle_in_transaction_session_timeout), an inactive replication slot (and hot_standby_feedback from a replica running long queries), and forgotten prepared transactions. Alert on age(datfrozenxid) above about 500 million and on the oldest transaction age.

TRANSACTION ID WRAPAROUND
age(datfrozenxid) against the hard limit, and what autovacuum does at each threshold
healthy50Mautovacuum_freeze_max_age200M: forced anti-wraparound vacuumwarnings start~2,000M: WARNING in logsread-only protection~2,100M: refuses new txidswraparound2^31 ≈ 2,147M
swipe the figure sideways, or tap expand for full screen
1/5
32-bit txids
Transaction ids are 32 bits and compared modulo 2^32: each txid sees about two billion as the past and two billion as the future. Without maintenance, old committed rows would suddenly appear to be in the future and vanish.
32-bit ids, compared in a circleold rows would flip to "the future"