How an 18-hour idle-in-transaction session caused 17 GB of dead tuple bloat — and the MVCC mechanics behind it.
Module 2 — MVCC: The Architecture That Makes Concurrency Possible (and Expensive)
What this module covers: Multi-Version Concurrency Control is the single most consequential design decision in Postgres. It enables readers and writers to coexist without locks — but every write creates a new row version, every delete leaves a corpse, and every update creates two corpses. Left unmanaged, MVCC accumulates waste until it shuts your database down entirely. This is not hypothetical. Transaction ID wraparound has forced emergency downtime at major companies. You need to understand this system from first principles.
The Problem MVCC Solves
In a locking database (like MySQL with SERIALIZABLE using range locks, or early PostgreSQL), a SELECT that reads a row must acquire a read lock, preventing concurrent writes. A UPDATE must acquire a write lock, preventing concurrent reads and writes.
At scale, this serialises all access to hot rows — a disaster for any high-throughput system.
MVCC solves this with a different model: instead of blocking reads, create a new version of every modified row. Readers see the old version. Writers create the new version. Nobody blocks.
The cost is space: you accumulate old versions. And there is a hard limit on how many transaction IDs you can use before the version visibility system breaks down completely.
Transaction IDs: The Clock That Drives Everything
Every transaction in Postgres is assigned a Transaction ID (XID) — a 32-bit unsigned integer that increments monotonically. Transaction IDs are used to determine which row versions are visible to which transactions.
The snapshot format is xmin:xmax:xip_list:
xmin— all transactions with XID < this are committed and visiblexmax— all transactions with XID >= this are in-progress or haven't started and are invisiblexip_list— transactions betweenxminandxmaxthat are still in-progress
The 32-bit limit
The XID counter is 32 bits — it wraps around at 2^32 = ~4.3 billion transactions. Postgres uses a concept of "transaction ID age": from any current transaction, XID values in the "past" half of the 32-bit circle are considered committed, and values in the "future" half are considered in-progress.
This means Postgres can only "see back" ~2.1 billion transactions at any time. When a row's t_xmin is more than ~2.1 billion transactions old, Postgres can no longer determine its visibility status — it becomes invisible to everything.
This is transaction ID wraparound, and it will shut down your database.
We will cover the mechanics and prevention in depth later in this module.
Row Versioning: xmin, xmax, and the Visibility Chain
Every row on disk carries two XID fields in its HeapTupleHeaderData:
t_xmin— the XID of the transaction that created this row versiont_xmax— the XID of the transaction that deleted or updated this row version (0 if still live)
What happens on INSERT
The inserted row has:
t_xmin = 4831729(the inserting transaction)t_xmax = 0(not yet deleted)
What happens on UPDATE
Postgres does not modify the existing row. Instead:
- The old row gets
t_xmax = 4831730(marking it as deleted by this transaction) - A new row is inserted with
t_xmin = 4831730,t_xmax = 0, and the newstatusvalue - The old row's
t_ctidis updated to point to the new row's physical location
You now have two versions of the same logical row on disk:
The old version is now a dead tuple — no transaction started after 4831730 can see it, but it still occupies space.
What happens on DELETE
The row gets t_xmax = 4831731. No new row is created. The row becomes dead after all transactions that started before 4831731 complete.
Inspecting row versions live
After an update, you see both versions on the page. The old version's t_xmax would be non-zero.
Snapshots: How Visibility Is Determined
When a transaction reads data, it uses a snapshot to determine which row versions are visible. The snapshot captures the state of all active transactions at a specific moment.
The snapshot algorithm
For a row version to be visible to a transaction with snapshot (xmin, xmax, xip_list):
The row was inserted by a visible transaction when:
t_xmin < snapshot.xmin(committed before snapshot was taken) AND t_xmin's transaction committed
OR:
snapshot.xmin <= t_xmin < snapshot.xmaxAND t_xmin is NOT inxip_list(committed between snapshot xmin and xmax)
AND the row was not deleted by a visible transaction:
t_xmax = 0(never deleted)- OR
t_xmaxis inxip_list(still in-progress — the delete hasn't committed) - OR
t_xmax >= snapshot.xmax(started after the snapshot was taken)
This logic is implemented in HeapTupleSatisfiesMVCC() in the Postgres source code. Every row access in every query runs this function for every candidate row version.
The performance implication
MVCC visibility checking runs for every row the executor evaluates — not just the rows returned. On a sequential scan of 100 million rows that returns 1000, visibility is checked 100 million times. Each check reads t_xmin, t_xmax, and potentially queries the commit log.
For committed transactions (the common case), Postgres caches the commit status in the tuple's t_infomask bits after the first check — the "hint bit" optimisation. But for very old transactions or tables that haven't been vacuumed, these hint bits may not be set, forcing a commit log lookup on every visibility check.
Isolation Levels: Where the Snapshot Is Taken
The difference between READ COMMITTED and REPEATABLE READ is not a conceptual one about what data you see. It is a mechanical one about when the snapshot is taken.
READ COMMITTED
A new snapshot is taken at the start of each statement within the transaction.
This is not a bug — it is the defined behaviour of READ COMMITTED. Each statement gets a fresh view of committed data. This is why you can observe non-repeatable reads: the same query returns different results within the same transaction.
REPEATABLE READ
A snapshot is taken once at the start of the first statement in the transaction and reused for all subsequent statements.
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion