How an 18-hour idle-in-transaction session caused 17 GB of dead tuple bloat and 6 hours of replica lag — 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.
A REPEATABLE READ transaction always sees the same snapshot. However, if T1 tries to update a row that T2 has already modified and committed, T1 will get a serialisation error:
ERROR: could not serialize access due to concurrent update
This is not the same as a deadlock. It is MVCC enforcing that the transaction's view of the world is no longer consistent with the actual committed state.
READ COMMITTED vs REPEATABLE READ in practice
For a blockchain indexer reading aggregate statistics alongside writes, READ COMMITTED is usually correct — you want each query to see the latest committed state. For a financial report that must be consistent across multiple queries (e.g. balance sheet that must balance), REPEATABLE READ is essential.
SERIALIZABLE and SSI
SERIALIZABLE goes one step further. It not only takes a consistent snapshot, it also tracks read-write dependencies between concurrent transactions to detect non-serialisable execution orders.
SSI detects that T1 read a value that T2 modified, creating a cycle in the dependency graph. One transaction must be aborted. The application must retry.
SSI is powerful but has a CPU overhead (predicate locking) and a retry burden. For most OLTP workloads, READ COMMITTED with careful application-level conflict handling is more practical.
The True Cost of MVCC: Dead Tuples and Heap Bloat
Every UPDATE and DELETE in Postgres leaves dead tuples on disk. This is not an edge case — it is the fundamental price of MVCC.
How bloat accumulates
Consider a table that receives 1,000 updates per second. Each update:
- Marks the old row version as dead (sets
t_xmax) - Inserts a new row version
After one hour: 3.6 million dead tuples. After one day: 86.4 million dead tuples.
Autovacuum reclaims this space, but only if it is tuned aggressively enough to keep up with the update rate. A misconfigured autovacuum on a high-write table will fall behind, and the table will grow without bound even if the number of live rows is constant.
Measuring bloat
4.5% of this table is dead tuples. On a 10GB table, that is 461MB of wasted space that autovacuum has not yet reclaimed.
The long-running transaction problem
Dead tuples cannot be reclaimed by VACUUM while any active transaction's snapshot can still "see" them. Even a committed snapshot (one taken before the deleting transaction) prevents dead tuple reclamation.
This is why long-running transactions cause bloat even without performing any writes:
To find the oldest transaction holding back vacuum:
Any session with a very old backend_xmin is preventing vacuum from cleaning up dead tuples. If these sessions are idle-in-transaction, they need to be terminated.
Transaction ID Wraparound: The Ticking Clock
The 32-bit XID counter will eventually reach 2^32. When it does, newly created rows will appear to be in the "future" relative to older rows — a logical impossibility that breaks all visibility calculations.
Postgres prevents this catastrophe through transaction ID freezing: periodically replacing old t_xmin values with a special value FrozenTransactionId (XID 2). Frozen rows are always visible to all transactions, bypassing the visibility check entirely.
The age of a relation
The xid_age is the number of transactions since the table's oldest unfrozen XID was created. When this exceeds autovacuum_freeze_max_age (default: 200 million), autovacuum is forced to run a freeze operation on the table.
The danger zone
When xid_age reaches ~1.6 billion (configurable via vacuum_freeze_max_age), Postgres enters a safe-mode where it will refuse all new connections except to allow a superuser to run VACUUM FREEZE.
VACUUM FREEZE: the solution
After VACUUM FREEZE, all tuples have t_xmin = FrozenTransactionId. The VM all-frozen bits are set. Future freeze vacuums skip these pages entirely.
Autovacuum freeze configuration
The key parameters:
For your most critical tables, set autovacuum_freeze_max_age well below the global default of 200 million. This triggers more frequent freeze vacuums and keeps you far from the danger zone.
MVCC and Index Overhead
Indexes in Postgres do not store MVCC visibility information. Every index entry points to a heap tuple, and heap tuple visibility must be checked at query time.
This has two consequences:
1. Index scans are slower than they appear. For every row an index scan returns, Postgres must visit the heap to check visibility. The only exception is Index-Only Scans when the Visibility Map confirms the page is all-visible (covered in Module 1).
2. Dead index entries accumulate. When a row is deleted or updated (creating a dead heap tuple), the corresponding index entries are not immediately removed. They remain in the index until VACUUM cleans them up. On a heavily-updated table with many indexes, dead index entries can make indexes significantly larger than the live data.
If tuples_read is much larger than tuples_fetched, the index is reading many dead entries that the heap then rejects — a sign the index needs vacuuming.
Production Example: The MVCC-Induced Bloat Incident
The situation: A blockchain indexer table starts at 8GB. After three months of production with no bloat issues, it grows to 28GB. pg_stat_user_tables shows only 50 million live rows — which should fit in about 10GB.
Step 1: Confirm bloat with pgstattuple
65% of the table is dead tuples. 17GB of dead rows.
Step 2: Find what is preventing vacuum
An analytics session has been idle-in-transaction for 18 hours. Its snapshot (XID 4813245) predates all the dead tuples we want to reclaim. As long as this transaction is open, VACUUM cannot touch them.
Step 3: Terminate the blocking session
Step 4: Run VACUUM and verify
But the table is still 28GB. VACUUM reclaims space within pages (marks it as available for reuse) but does not return pages to the OS. The table will shrink over time as new inserts reuse the free space.
If you need the space returned to the OS immediately:
Step 5: Prevent recurrence with idle-in-transaction timeouts
With this setting, any session that remains idle in transaction for more than 15 minutes is automatically terminated. This is the most important MVCC-related configuration for any production database that has analytics or reporting queries running alongside writes.
Summary
| Concept | Key Mechanic | Production Implication |
|---|---|---|
| XID | 32-bit counter | Monitor age; freeze before wraparound |
| t_xmin / t_xmax | Row version timestamps | Every UPDATE creates dead tuples |
| Snapshot | Taken at statement or transaction start | Isolation level determines when |
| READ COMMITTED | New snapshot per statement | Can see non-repeatable reads |
| REPEATABLE READ | Snapshot at first statement | Consistent reads, serialisation errors |
| Dead tuples | Accumulate on every UPDATE/DELETE | Autovacuum must keep up |
| Long transactions | Hold back VACUUM | Set idle_in_transaction_session_timeout |
| XID wraparound | 2.1B transaction limit | Monitor age(relfrozenxid) |
| Freeze | Sets t_xmin = FrozenXID | Enables VM all-frozen, safe from wraparound |
In Module 3, we follow writes from application code all the way to durable storage — through the Write-Ahead Log. WAL is what makes MVCC crash-safe, what makes replication possible, and what explains your write amplification.
Next: Module 3 — Write-Ahead Logging: Durability, Replication, and the Price of Every Write →
A high-volume OLTP system experiences intermittent, severe performance degradation on SELECT queries, despite low CPU and I/O utilization. pg_stat_statements shows high shared_blks_hit for the affected tables, and pgstattuple indicates a low dead_tuple_percent. What is the most likely root cause of this performance issue, given the MVCC architecture?
A financial application uses REPEATABLE READ for multi-statement reports. A transaction (T1) first reads an aggregate sum for a user's balance, then attempts to insert a new transaction for that user. Concurrently, another transaction (T2) inserts a different transaction for the *same* user and commits. T1 then attempts its insert and receives a could not serialize access due to concurrent update error. Why did T1 receive this error under REPEATABLE READ, and what would be the behavior if SERIALIZABLE was used instead?
A Postgres DBA observes that a critical transactions table has an xid_age of 1.8 billion, rapidly approaching the shutdown threshold. Autovacuum logs show frequent WARNING: some dead tuples could not be removed due to recently active snapshots messages for this table. The DBA has already increased autovacuum_freeze_max_age for the table to 100 million and confirmed autovacuum is running. What is the most effective immediate action to mitigate the impending transaction ID wraparound shutdown, and what is the most critical long-term preventative measure?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Sign in & RegisterDiscussion
0Join the discussion