MVCC Internals: Version Chains and Snapshots
If a row is never overwritten but versioned, a reader can be handed the version that was current when it started and never wait for a writer; the version chain is a linked list with a creating and a superseding transaction id on each node, the snapshot is three numbers and a list, and the visibility rule is a dozen lines that every read in the engine runs.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
A nightly report reads the accounts table for four minutes. Under locking, every transfer during those four minutes waits for it — or the report reads a mixture of before and after states.
↓ - Naive solution
Shared locks for readers, exclusive for writers, held to commit. Correct, and every long read stalls every write on the rows it touched.
↓ - Why it breaks
Readers dominate. A reporting query and a thousand OLTP transactions per second cannot coexist: either the report waits for a gap that never comes, or the writers queue behind the report. The lock manager is doing exactly what it should, and the system is unusable.
↓ - Better idea
A reader does not need the row as it is now; it needs the row as it was when the reader started. If an UPDATE writes a new version beside the old one instead of over it, both can be satisfied at once: the writer proceeds, the reader keeps the old version.
↓ - Internal mechanism
Each version carries the id of the transaction that created it and the id of the one that superseded it. A transaction records, at start, which ids were in progress — its snapshot. A version is visible if its creator committed before the snapshot and its superseder did not. Readers take no locks and never wait.
↓ - Trade-offs
Old versions occupy space until no snapshot can need them, and someone must find and remove them. A long-running snapshot pins every version created after it. Ids are finite and must be recycled. Writers still conflict on the same row and still use locks.
↓ - Real database
PostgreSQL stores every version in the heap with
xmin/xmaxin the tuple header and vacuums the dead ones; InnoDB and Oracle keep the newest version in place and reconstruct old ones from undo records.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
MVCC replaces "wait until the writer finishes" with "read the version from before the writer". Every update leaves the old row in place and adds a new one; every transaction is told, when it starts, which versions belong to its world. Readers never block writers, and writers never block readers. The price is garbage: old versions that nobody can see any more, waiting to be collected.
Two versions of one row
Account 7 was created with balance 100 by transaction 10. Transaction 15 later debited it to 80. Under in-place update the page would now say 80 and nothing else. Under MVCC the page holds *both* tuples: the original, stamped xmin=10, xmax=15, and the new one, xmin=15, xmax=0. The old version's xmax says "superseded by 15"; its t_ctid says where the successor is. Nothing has been erased. The versions form a chain, oldest to newest, exactly a Linked List whose links are (page, slot) addresses.
A transaction that started before 15 committed, and one that started after, both scan this page and both find two tuples. What differs is the answer each gets from the visibility rule, and the rule's only inputs are the two ids on each tuple and the reader's snapshot.
page 91 slot 3 t_xmin=10 t_xmax=15 t_ctid=(91,8) id=7 balance=100 <- old version, superseded by 15 slot 8 t_xmin=15 t_xmax=0 t_ctid=(91,8) id=7 balance=80 <- current version (ctid points to itself) chain: (91,3) ──► (91,8) ──► ∅
What a snapshot is
A snapshot is not a copy of data; it is a compact description of which transactions count as finished. Three parts. xmin: the lowest transaction id still in progress when the snapshot was taken — every id below it has committed or aborted, and only the status table is needed to tell which. xmax: the next id to be assigned — every id at or above it started after the snapshot and is invisible regardless of whether it has since committed. xip: the list of ids in between that were in progress at snapshot time; they may commit later, but not for this snapshot. The whole structure is a few dozen bytes, built by one pass over the running list from A Transaction, Inside the Engine.
Because it is built from the running list rather than from the data, taking a snapshot costs microseconds and does not touch a single page. That is why READ COMMITTED can afford a new one per statement.
snapshot S { xmin: 12, xmax: 17, xip: [12, 15] }
xid <12 12 13 14 15 16 >=17
view committed IP C C IP C future (C per status table, IP = in progress)
or aborted (in xip) (in xip) (invisible regardless)
(ask table)The visibility rule, spelled out
Every tuple a scan touches goes through the same test, and it has two halves. First the creator: is xmin something this snapshot considers committed? Yes if xmin is my own transaction and the tuple was written by an earlier command of mine (cmin below the current command id); yes if xmin < snap.xmin and the status table says committed; yes if snap.xmin ≤ xmin < snap.xmax, xmin is not in snap.xip, and the status table says committed. Any other case — aborted, in progress, in xip, or at or above snap.xmax — and the tuple is invisible: it was created by something outside my world.
Then the deleter, with the same test on xmax. xmax = 0: nobody has deleted or superseded it; visible. xmax aborted: the deletion never happened; visible. xmax in progress, in xip, or ≥ snap.xmax: the deletion is outside my world; visible. xmax committed and inside my world: the tuple was gone before I started; invisible. One more case: xmax is my own transaction — then it is invisible if the deletion was by an earlier command, otherwise visible. In the example above, a snapshot with xip = [15] sees slot 3 (creator 10 committed, deleter 15 in progress → alive) and not slot 8 (creator 15 in progress); a snapshot taken after 15 committed sees slot 8 and not slot 3.
1def visible(t, snap, me):2 def settled(xid): # "committed before my snapshot"?3 if xid == me.xid: return True # own writes (cid check omitted)4 if xid >= snap.xmax: return False # started after me5 if xid >= snap.xmin and xid in snap.xip: return False # running when I started6 return status(xid) == COMMITTED # else consult pg_xact / hint bits7 8 if not settled(t.xmin): return False # creator not in my world9 if t.xmax == 0: return True # never deleted10 if status(t.xmax) == ABORTED: return True11 return not settled(t.xmax) # deleted in my world -> invisibleWhere old versions live
PostgreSQL keeps every version in the table itself. An UPDATE inserts a new tuple — on the same page if there is room, otherwise elsewhere — and stamps the old one. The chain is walked by t_ctid only in special cases (a READ COMMITTED update chasing a concurrently modified row); ordinary scans simply test every tuple on every page and keep the visible ones. The approach is simple and makes rollback free, at the cost that the table grows with every update until VACUUM reclaims dead versions, and that every index must point at every version. UPDATE, DELETE and Dead Tuples is about that cost; Slotted Pages shows the page structure the versions sit in.
Where old versions live
InnoDB (and Oracle before it) keeps only the newest version in the table. An UPDATE overwrites the row in the clustered index in place, after writing the old column values to an undo log record in a rollback segment; the row's DB_ROLL_PTR points at that record, and the record points at the previous one, so the chain runs backwards through undo space. A reader whose read view cannot see the row's DB_TRX_ID follows the roll pointer and reconstructs the older version, repeating until it reaches one whose transaction id its view accepts. The table stays compact and secondary indexes point at the primary key rather than at a version. The costs are the reconstruction work for old snapshots, the undo space a long reader forces the engine to keep (the "history list"), and the possibility — in Oracle, ORA-01555 — that the undo needed for a very old snapshot has already been recycled.
clustered index id=7 DB_TRX_ID=15 DB_ROLL_PTR ──► undo rec (trx 15): balance=100, prev ──► undo rec (trx 10): insert
balance=80
read view { up_limit 12, low_limit 17, ids [12,15] } reading id=7:
DB_TRX_ID=15 in ids -> not visible -> follow roll ptr -> version balance=100 from trx 10 -> visibleTransaction id wraparound and freezing
Transaction ids are 32-bit, and PostgreSQL assigns one to every writing transaction. Four billion ids sounds like plenty; at ten thousand write transactions per second it is five days. Rather than stop, the counter wraps, and comparisons are done modulo 2^32: id *a* is older than id *b* if *a* lies within the two billion ids behind *b*. That works as long as no tuple on disk carries an xmin more than about two billion ids in the past — beyond that horizon the old id would compare as *newer* than the current counter, and the tuple would become invisible to everyone: silent data loss.
Freezing prevents it. VACUUM marks tuples whose xmin is older than vacuum_freeze_min_age as frozen — historically by overwriting xmin with a reserved id, since 9.4 by setting HEAP_XMIN_FROZEN in the infomask — which the visibility rule treats as "committed before every possible snapshot" without any comparison. Each table records the oldest unfrozen id it might contain (relfrozenxid); when that age reaches autovacuum_freeze_max_age (200 million) autovacuum runs an aggressive vacuum on the table whether it is bloated or not. If something prevents freezing for long enough — a table autovacuum cannot finish, a stuck prepared transaction, an abandoned replication slot — the server warns at forty million ids from the limit and, a few million from it, refuses to assign new ids at all: the database goes read-only until a manual VACUUM catches up. The 64-bit ids that would remove the problem are still a long-standing work item, so age(relfrozenxid) is a number every PostgreSQL operator monitors.
current xid = 3,000,000,000
"past" = 2^31 ids behind "future" = 2^31 ids ahead
◄──────────────────────────────────────┤ now ├──────────────────────────────►
xmin 900,000,000 age 2.1 billion ─► would flip to "future" -> invisible (wraparound)
xmin 2,800,000,000 age 200 million ─► autovacuum freezes it: HEAP_XMIN_FROZEN
frozen tuple: visible to every snapshot, xmin no longer comparedKey points
- MVCC keeps old versions instead of blocking readers: a row is a chain of versions, each stamped with its creating (xmin) and superseding (xmax) transaction.
- A snapshot is xmin, xmax and an in-progress list built from the running-transactions list; it costs microseconds and touches no data.
- Visibility: creator committed in my world, and deleter either absent, aborted, or outside my world — evaluated per tuple on every scan, with hint bits caching status lookups.
- PostgreSQL keeps versions in the heap (free rollback, table growth); InnoDB and Oracle keep the newest in place and reconstruct older ones from undo (compact table, reconstruction cost, snapshot-too-old).
- 32-bit ids wrap; freezing marks old tuples as visible to all so the modular comparison never runs on them, and autovacuum enforces it at 200 million ids of age.
Version chains and snapshots
A transaction id x counts as committed for this snapshot only if x < xmax, x ∉ xip and pg_xact says committed. Everything else — in the future, in progress at snapshot time, aborted — is treated as never having happened.
visible(v, snap) = (v.xmin = me OR committedAsOf(v.xmin, snap)) AND (v.xmax = ∅ OR (v.xmax ≠ me AND NOT committedAsOf(v.xmax, snap))) committedAsOf(x, snap) = x < snap.xmax AND x ∉ snap.xip AND pg_xact[x] = COMMITTED
When to use — and when not
- Multi-versioning fits workloads where readers must never wait for writers — nearly every OLTP system with reporting, and every system that needs consistent reads without locking.
- Heap-stored versions fit update patterns where rollback must be cheap and table scans can afford to skip dead tuples.
- MVCC does not remove write–write conflicts: a hot row updated by many transactions still serialises on the row lock and still generates a version per update.
- Undo-based versioning does not fit very long-running readers on write-heavy tables — reconstruction cost and undo retention grow with the reader's age.
Failure modes
- A snapshot held open for hours (idle in transaction, a paused cursor, a stuck replication slot) pinning millions of versions.
- Reasoning about visibility from commit *time* rather than from the snapshot's in-progress list — a transaction that committed after my snapshot is invisible even though it is "committed".
- Wraparound: autovacuum unable to freeze a large table, discovered when the server refuses writes.
- On undo-based engines, "snapshot too old" for a long report because undo was recycled.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- DSASingly linked list → Version chain (t_ctid forward pointers)Each node is a full tuple; the pointer is a (page, slot) address; the traversal is "follow to the successor until one is visible".
- Operating SystemsCopy-on-write pages → Copy-on-write rows (new version per update)The kernel copies a page on the first write so a fork keeps its old view; MVCC copies a row so old snapshots keep theirs.