Data structures and storage engines
MVCC and snapshots
How multi-version concurrency control lets readers and writers avoid blocking each other, how Postgres row versions and snapshots work, and why vacuum and long-running transactions matter.
Locks alone could give you isolation. Readers would take shared locks and writers would take exclusive ones. The cost is that a long report would block every writer it touched. Multi-version concurrency control (MVCC) avoids that. It keeps several versions of each row, so a reader sees a consistent snapshot of the data and a writer never has to wait for a reader.
Concept
Before you start: a snapshot is the set of transactions whose commits you can see. It is fixed when a transaction (or statement) starts. Isolation levels decide when a new snapshot is taken.
Row versions in Postgres
Every transaction gets a 32-bit transaction ID (XID). Every row version, called a tuple, carries two hidden columns:
xmin: the XID that created this version.xmax: the XID that deleted or replaced it. It is 0 while the version is current.
An UPDATE does not overwrite the old version. It sets xmax on the old version and inserts a new version with xmin set to the updating transaction. You can see this in the hidden columns. The output below is illustrative.
SELECT xmin, xmax, id, balance FROM accounts WHERE id = 1;
-- xmin | xmax | id | balance
-- 741 | 0 | 1 | 100
UPDATE accounts SET balance = 90 WHERE id = 1; -- run as XID 742
SELECT xmin, xmax, id, balance FROM accounts WHERE id = 1;
-- xmin | xmax | id | balance
-- 742 | 0 | 1 | 90
-- the old version (xmin 741, xmax 742) is still on disk, but new snapshots skip itHow visibility is decided
A snapshot records which transactions had committed when it was taken. A row version is visible to your snapshot when two things hold:
- Its
xmintransaction committed before your snapshot. - Its
xmaxis empty, belongs to an aborted transaction, or belongs to a transaction that committed after your snapshot.
Both checks are integer comparisons. That is how each transaction sees a different, but internally consistent, version of the database without taking a lock.
Read committed takes a new snapshot for each statement. Repeatable read and serializable keep one snapshot for the whole transaction.
Vacuum and long-running transactions
Old versions must eventually be removed. VACUUM, usually run by autovacuum, deletes versions that no running transaction can still see, and it marks their space reusable.
The word that matters is running. The oldest open snapshot sets a horizon, and vacuum cannot remove any version newer than it. Suppose one transaction stays open for six hours on a busy site. Vacuum cannot clean up anything written in those six hours, across every table. Dead rows pile up, indexes grow and queries slow down. A forgotten BEGIN in a psql session has the same effect.
Transaction ID wraparound
XIDs are 32 bits, so they wrap around after about 4 billion transactions. Postgres can only compare XIDs within a window of about 2 billion. To keep old rows visible after wraparound, vacuum freezes them, which marks them as visible to every transaction.
If freezing falls far enough behind, Postgres stops accepting writes to protect the data. Sentry had a multi-hour outage from this in 2015. Watch the age with:
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;Alert long before the age approaches 2 billion.
How MySQL differs
InnoDB updates rows in place and keeps the old values in undo logs. A purge thread removes them when no one needs them. Long transactions cause the same kind of problem here: the undo history grows, and you can watch it as the "history list length".
Common mistakes
- Leaving a transaction open. An idle-in-transaction session holds back vacuum for every table. Set
idle_in_transaction_session_timeout. - Treating MVCC as "no locks". Readers do not block writers, but two writers still lock the same row.
- Relying on
xminandxmax. They are internal bookkeeping. Do not build application logic on them. - Ignoring XID age until writes stop. By then the database is already protecting itself.
Check yourself
- A transaction stays open for six hours on a busy database. What happens to old row versions? Vacuum cannot remove any version newer than the oldest open snapshot. Dead rows accumulate across all tables, causing bloat.
- Why do readers and writers not block each other in MVCC? A reader sees the older committed version that matches its snapshot. A writer creates a new version rather than overwriting the old one, so they touch different versions.
Key takeaways
- MVCC keeps several row versions, so readers do not wait for writers.
- In Postgres,
xminandxmaxdecide visibility, and a snapshot is a set of committed transactions. - The oldest open transaction limits what vacuum can clean. Keep transactions short.
- XID wraparound is a real outage risk. Monitor
age(datfrozenxid).
Further learning
Andy Pavlo's CMU lecture on MVCC covers the design space behind Postgres, MySQL and others. It looks at version storage, garbage collection and index management.
Next, read locking and concurrency control to see how writers still wait for each other.
Related
Transactions, MVCC and isolation levels
What ACID guarantees on one database node, which concurrency bugs each isolation level still allows, how MVCC gives snapshots without blocking, and the code patterns that keep concurrent writes correct. Start here for transactions.
Isolation levels
What each isolation level promises, which anomalies it still allows, and how Postgres and MySQL differ in practice, including write skew, which snapshot isolation does not prevent.
Locking and concurrency control
How row locks, SELECT FOR UPDATE, SKIP LOCKED, deadlocks, version columns and advisory locks make writers wait safely, with a job queue and an optimistic update as worked examples.
Concurrency
Why shared state breaks when two threads touch it at once, and the tools that fix it, from locks and semaphores to atomics, message passing and database constraints.