Internals · MVCCAtlasDB V9 · MVCCmvccversion chainxminxmaxsnapshot

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.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. Real database

    PostgreSQL stores every version in the heap with xmin/xmax in 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.

Versions instead of waiting

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.

The version chain for account 7 (PostgreSQL heap layout, simplified)
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.

A snapshot taken while transactions 12 and 15 were running
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.

Visibility of one tuple under one snapshot
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 me
5 if xid >= snap.xmin and xid in snap.xip: return False # running when I started
6 return status(xid) == COMMITTED # else consult pg_xact / hint bits
7
8 if not settled(t.xmin): return False # creator not in my world
9 if t.xmax == 0: return True # never deleted
10 if status(t.xmax) == ABORTED: return True
11 return not settled(t.xmax) # deleted in my world -> invisible

Where old versions live

PostgreSQL implementation

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

MySQL / InnoDB implementation

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.

Undo-based versioning: the newest version in place, history in undo
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 -> visible

Transaction id wraparound and freezing

PostgreSQL implementation

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.

The modular id space and the freeze horizon
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 compared

Key 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

Version chains and snapshots
A row is a chain of versions stamped xmin / xmax. Which one a transaction sees is decided by its snapshot and the status table — never by overwriting data.
v1 · balance = 100xmin 10 committedxmax 15 committed◀ visible to TX12ctidv2 · balance = 80xmin 15 committedxmax 17 abortedinvisible to TX12ctidv3 · balance = 50xmin 17 abortedxmax ∅deadold → new along t_ctid · click a version to see its checks
Snapshot of TX12 — reader; snapshot taken while TX15 was still running
xmin
12
xmax
16
xip (in progress at snapshot)
15

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.

Transaction status table (pg_xact)
TX10 committedTX12 in-progressTX15 committedTX16 in-progressTX17 aborted
What TX12 reads
balance = 100 (v1)
Visibility rule for v1 as seen by TX12
✓ xmin committed before snapshot10 < xmax, not in xip, and pg_xact says COMMITTED
✓ xmax NOT committed before snapshotdeleter 15: 15 ∈ xip: was still in progress when the snapshot was taken — the deletion is not visible, so the old version still is
visible: the transaction that deleted it (15) does not count as committed for this snapshot
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
PostgreSQL implementation
Educational simulation — real tuple headers also carry command ids, hint bits and a t_ctid; the rule shown is the core of HeapTupleSatisfiesMVCC.
view as

When to use — and when not

Use it when
  • 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.
Avoid it when
  • 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.