Lesson 0004 · Storage & MVCC
MVCC decides visibility by comparing 32-bit transaction IDs on a circular clock. Left unmaintained, that clock wraps and old rows vanish — so VACUUM has a third job you've now met twice in passing: freezing.
Closed book. These are the threads this lesson ties off.
Which process sets the "all-visible" bits an index-only scan relies on?
A row's visibility to your snapshot is decided using data stored in the:
We've said three times now that a tuple carries its own visibility
information in the heap. Time to name it. Every tuple's header stores the
transaction ID (XID) that inserted it (call it xmin) and,
once deleted or updated away, the XID that expired it (xmax). MVCC visibility
is just a comparison: a tuple whose inserting XID is "in the future" relative to my
snapshot is not visible to me.
— PostgreSQL Docs: "PostgreSQL's MVCC transaction semantics depend on being able to compare transaction ID (XID) numbers: a row version with an insertion XID greater than the current transaction's XID is 'in the future' and should not be visible to the current transaction."
Here's the catch that has no InnoDB equivalent. XIDs are 32-bit, so the counter has about 4 billion values and then wraps back to zero. To cope, Postgres compares XIDs with modulo-2³² arithmetic: the space is a circle with no end, and from any point, half the circle is "past" and half is "future." — PostgreSQL Docs: "Normal XIDs are compared using modulo-2³² arithmetic. This means that for every normal XID, there are two billion XIDs that are 'older' and two billion that are 'newer'; another way to say it is that the normal XID space is circular with no endpoint."
If a cluster runs past ~4 billion transactions without maintenance, rows inserted long ago cross that 2-billion horizon and "all of a sudden transactions that were in the past appear to be in the future — which means their output becomes invisible." — PostgreSQL Docs — transaction ID wraparound Silent, catastrophic apparent data loss.
The escape is to take the oldest rows off the circular clock entirely. VACUUM
marks sufficiently-old, all-visible rows as frozen: a frozen tuple is
treated as inserted by a special FrozenTransactionId that is always
older than every normal XID, so it stays "in the past" forever regardless of wraparound.
— PostgreSQL Docs: "VACUUM will mark rows as frozen… PostgreSQL reserves a special XID, FrozenTransactionId, which does not follow the normal XID comparison rules and is always considered older than every normal XID… such row versions will be valid until deleted, no matter how long that is."
Because of this, every table must be vacuumed at least once every two billion transactions, full stop. — PostgreSQL Docs: "it is necessary to vacuum every table in every database at least once every two billion transactions." This is the punchline of the whole storage arc: VACUUM has three jobs — (1) reclaim dead tuples [0002], (2) maintain the visibility map [0003], and (3) freeze old rows to prevent wraparound [this lesson]. Job three is why even a read-only, append-only table — one with zero dead tuples — still must be vacuumed. Bloat you can neglect for a while; wraparound you cannot.
Freezing is not optional and doesn't wait for the normal 20%-dead trigger. Once a
table's oldest unfrozen XID reaches autovacuum_freeze_max_age — default
200 million transactions — Postgres launches an
anti-wraparound autovacuum on it, even if autovacuum is disabled.
— PostgreSQL Docs: "autovacuum is invoked on any table that might contain unfrozen rows with XIDs older than the age specified by… autovacuum_freeze_max_age. (This will happen even if autovacuum is disabled.)"
Ignore it long enough and Postgres protects your data by refusing to move the clock. First it warns, at 40 million transactions from the edge; then, under 3 million left, it stops accepting writes:
ERROR: database is not accepting commands that assign new
transaction IDs to avoid wraparound data loss in database "mydb"
"only read-only transactions can be started. Operations that modify database records…
will fail."
— PostgreSQL Docs — wraparound warnings & shutdown
Recovery means a database-wide VACUUM, often single-user. This is one of the
best-known ways to take a Postgres database down — and it is 100% a maintenance failure,
not a bug.
You spent a whole course on InnoDB and never once worried about "wraparound." That's not luck — it falls out of the keystone. Postgres stores every version inline in the heap, tagged with a 32-bit XID, so those tags are a finite resource that must be recycled by freezing. InnoDB keeps old versions in the undo log and reconstructs them, using wider internal transaction identifiers and continuous purge — so there's no equivalent 32-bit horizon for you to babysit. Same problem (tell old versions from new), opposite mechanism, and only one of them hands you a clock to wind.
| Question | InnoDB | PostgreSQL |
|---|---|---|
| Where is version identity kept? | Undo log + wide internal IDs | 32-bit XID in each heap tuple header |
| Is the ID space a concern? | Not operationally | Yes — circular, wraps at ~4 billion |
| What prevents disaster? | Background purge (automatic) | Freezing by VACUUM |
| Forced maintenance trigger | — | autovacuum_freeze_max_age (200M) |
| Worst case if neglected | Undo bloat / purge lag | Refuses writes to avoid data loss |
From memory. Two items reach back on purpose.
Transaction ID wraparound threatens data because XIDs are:
Freezing a tuple makes it:
An append-only table with zero dead tuples still needs VACUUM because:
When wraparound gets dangerously close, Postgres eventually:
Beyond reclaiming dead tuples, VACUUM's other two jobs are: (recall 0003 + this)
A lab to run later. It exposes the XID clock directly:
SELECT txid_current(); -- the current transaction's XID
SELECT xmin, * FROM some_table LIMIT 5; -- each tuple's inserting XID
-- how close is each table to needing an anti-wraparound vacuum?
SELECT relname, age(relfrozenxid) AS xid_age
FROM pg_class WHERE relkind = 'r'
ORDER BY xid_age DESC LIMIT 10; -- compare against 200M (freeze_max_age)
VACUUM (FREEZE, VERBOSE) some_table; -- force-freeze and watch relfrozenxid advance
age(relfrozenxid) is the number that matters in production — when it climbs
toward 200 million on a big table, an anti-wraparound vacuum is coming. Bring a surprising
xid_age to your teacher.
PostgreSQL Docs — 24.1.5 Preventing Transaction ID Wraparound Failures
(the whole story, with the exact warning/shutdown thresholds). For the tuple header and
xmin/xmax mechanics drawn out, Suzuki's
"Internals of PostgreSQL," ch. 5 (Concurrency Control).