Transactions & Consistency· 15 min

MVCC

Multi-Version Concurrency Control — readers don't block writers

TransactionsAdvanced15 min read

Concurrency technique where readers don't block writers and writers don't block readers — each transaction sees a snapshot.

MVCC (Multi-Version Concurrency Control) — diagram
🖱️ Drag nodes · Scroll to zoom · Drag background to pan
readsreadsv1: balance=100created T=5, visible to Tx Av2: balance=200created T=15, visible to Tx BTx A (started T=10)sees v1 = 100Tx B (started T=15)sees v2 = 200

Explanation

MVCC stores multiple versions of each row. Each transaction gets a snapshot — it sees row versions committed at its start time. Writers create new versions. Old versions are garbage-collected (vacuumed). Postgres, Oracle, MySQL/InnoDB all use MVCC.

Example

-- Tx A (snapshot at T=10)
BEGIN; SELECT balance FROM accounts WHERE id = 1; -- sees v1: 100
-- Tx B commits an update at T=15
SELECT balance FROM accounts WHERE id = 1; -- still 100 (snapshot)
COMMIT;
-- Old v1 kept until all pre-T=15 transactions finish, then vacuumed.

Key points

  • →Readers never block writers
  • →Each transaction sees a consistent snapshot
  • →Updates create new row versions
  • →Dead versions must be vacuumed
  • →Snapshot isolation = Repeatable Read in Postgres

Quick interview answers

Why does Postgres table bloat?
Long-running transactions prevent vacuum from removing dead tuples. Set statement_timeout.
Does MVCC prevent write skew?
No — snapshot isolation allows write skew. Serializable prevents this.

DBMS deep dive

MVCC (Multi-Version Concurrency Control): deep dive

MVCC keeps several versions of a row so that readers see a stable snapshot while writers keep going. The interview-level skill is tracing a version chain: knowing which version a given transaction sees, and when the old ones can be thrown away.

Row versions in PostgreSQL: xmin and xmax

Every PostgreSQL tuple carries two hidden system columns. xmin is the ID of the transaction that created the version, and xmax is the ID of the transaction that deleted or replaced it (0 if none). An UPDATE never overwrites a row in place. It sets xmax on the current version and inserts a new version whose xmin is the updater's ID. A DELETE only sets xmax.

Walkthrough: one row, three versions

Inspect the hidden columns yourself
-- Tx 100
INSERT INTO accounts (id, balance) VALUES (1, 100);   -- v1: xmin=100, xmax=0
-- Tx 105
UPDATE accounts SET balance = 80 WHERE id = 1;        -- v1.xmax=105; v2: xmin=105
-- Tx 110
UPDATE accounts SET balance = 50 WHERE id = 1;        -- v2.xmax=110; v3: xmin=110

SELECT xmin, xmax, ctid, balance FROM accounts WHERE id = 1;
-- Only the version visible to your snapshot is returned
VersionbalancexminxmaxVisible to a snapshot that...
v1100100105sees 100 committed but not 105
v280105110sees 105 committed but not 110
v3501100sees 110 committed

A snapshot records the lowest transaction ID still running, the next ID to be assigned, and the list of IDs in progress. A version is visible if its xmin committed before the snapshot, and its xmax is empty, aborted or not yet visible to the snapshot. Suppose a long report started while Tx 105 was still running. It keeps seeing v1 (balance 100) for its whole life, even after 110 commits. That is how REPEATABLE READ gets a stable view without read locks.

Vacuum: cleaning up the chain

Once no running snapshot can see v1 or v2, they are dead tuples. Plain VACUUM (usually run by autovacuum) marks their space reusable and updates the visibility map and free space map. It does not shrink the file. VACUUM FULL rewrites the table compactly, but it takes an exclusive lock. Vacuum also freezes old tuples, marking them visible to everyone. That stops the 32-bit transaction counter from wrapping around and making old rows look like they are in the future.

One idle-in-transaction session holding an old snapshot blocks cleanup of every dead tuple newer than its snapshot, in every table of the database, not just the tables it touched. That is why bloat often traces back to one forgotten session or a stale replication slot.

PostgreSQL vs InnoDB: two ways to keep versions

PostgreSQLMySQL InnoDB / Oracle
Where old versions liveIn the table heap, next to the current versionIn the undo log (rollback segments)
UPDATEWrites a new full tuple; old tuple staysUpdates in place; the old values go to undo
Chain directionOld version points to the newer one (ctid)Current row points back to older versions (roll pointer)
CleanupVACUUM / autovacuumPurge threads discard undo no longer needed
Main pain pointTable and index bloatLong transactions grow the undo history; old reads get slower

PostgreSQL softens its write cost with HOT (heap-only tuple) updates. If no indexed column changes and the page has free space, the new version stays on the same page and indexes are not touched. Leaving free space with a lower fillfactor makes HOT updates more likely on frequently updated tables.

What MVCC does not solve

MVCC removes read-write blocking, but writers still block writers. Two transactions updating the same row still serialize on a row lock: the second waits, then either proceeds (READ COMMITTED) or fails (PostgreSQL REPEATABLE READ). MVCC also does not stop write skew on its own.

Common mistakes

  • Turning off autovacuum on busy tables to 'save I/O', then hitting bloat and an emergency anti-wraparound vacuum.
  • Expecting DELETE to return disk space immediately. It only creates dead tuples.
  • Updating every row of a large table in one transaction, which doubles its size until vacuum runs. Batch the update instead.
  • Assuming SELECT count(*) is O(1). Under MVCC each transaction may see a different count, so the engine has to scan.

Interview questions

›What do xmin and xmax mean in PostgreSQL?

xmin is the transaction ID that created a tuple version. xmax is the ID of the transaction that deleted or superseded it, or locked it with FOR UPDATE. Together with commit status from pg_xact, they decide whether a version is visible to a snapshot.

›Why can't PostgreSQL do an index-only scan on a freshly updated table?

Indexes do not store visibility, so the engine checks the visibility map. If the page is not marked all-visible, which vacuum sets, it must fetch the heap tuple to check xmin and xmax.

›What is transaction ID wraparound?

Transaction IDs are 32-bit and compared in a circular way. Without freezing, about 2 billion transactions later old rows would look like they are in the future and become invisible. Vacuum freezes old tuples to prevent this, and the database forces an aggressive vacuum, or eventually refuses writes, if freezing falls behind.

›How do MVCC readers in InnoDB see an old value?

Each row stores the ID of the last transaction that modified it and a roll pointer into the undo log. If that transaction is not visible to the reader's read view, InnoDB follows the pointer and applies undo records until it reaches a visible version.

›What is a HOT update?

A heap-only tuple update puts the new version on the same page and skips index updates, because no indexed column changed. Index entries keep pointing to the original tuple, which redirects to the newest version. This cuts index bloat and write I/O.

More Database Management Systems

Compare this lesson with the other 8 concepts in the course. Expand any card for its design, trade-offs and diagram.

Category:
Difficulty:
Showing 8 of 8