What Is MVCC? Multi-Version Concurrency Control in MySQL
MVCC keeps old row versions so readers see a consistent snapshot without blocking writers. How InnoDB's undo log, read views and purge work, versus PostgreSQL.
- Course: SQL study plan
- Module: Transactions and concurrency
- Kind: Lesson
- Reading time: 15 min
- Runtime: MySQL 8 syntax on SQLite 3.49
What is MVCC in databases?
MVCC, multi-version concurrency control, is the technique of keeping several versions of a row so that each transaction reads a consistent snapshot of committed data while other transactions change it. Readers never wait for writers and writers never wait for readers. MySQL's InnoDB builds old versions from its undo log; PostgreSQL stores them as separate tuples in the table.
Lesson
If every write took locks that blocked readers, a busy table would spend its life waiting: a report scanning the orders table would stall every checkout, and every checkout would stall the report. Modern databases avoid that with multi-version concurrency control (MVCC): a change creates a new version of the row while the old one is kept for transactions that should still see it. Readers read a snapshot; writers write new versions; neither waits for the other. This lesson explains how InnoDB stores versions, how a read decides which version it may see, how isolation levels map onto snapshots, and why long transactions hurt.
The idea in one picture
REPEATABLE READ; price 500, T2 sets it to 550- T1 starts and is given transaction id 100. The price row holds one version, 500, written by transaction 90, which committed long ago.
- T1's first read makes its read view: transaction 90 had committed, 100 is itself, and anything numbered from 101 on did not exist yet. The newest version passes, so T1 reads 500.
- T2 starts with id 101. Nothing has changed for T1: no lock was taken by its read, and its view is already fixed.
- T2's UPDATE writes a new version, 550 by transaction 101, into the table, and keeps 500 in the undo log behind the roll pointer. Readers are not blocked; they can still reach the old version.
- T2 commits. 550 is now the latest committed price — but T1's read view was made before 101 committed, and a view never changes.
- T1 reads again. The newest version is by 101, not in its view, so it follows the roll pointer to 500 by transaction 90, which its view does include, and reads 500 again. Neither transaction ever waited for the other.
- T1 commits and its view is dropped. A transaction reading from now on would see 550; once no read view needs 500, InnoDB's purge discards it from the undo log.
T1 never waits for T2's lock and never sees T2's change: it keeps reading version 1, because version 2 did not exist — committed — when T1's snapshot was taken. T2 never waits for T1 either. Only two writers to the same row conflict, and they are ordered by locks.
How InnoDB stores versions
Each row in the clustered index carries hidden system columns:
DB_TRX_ID(6 bytes) — the id of the transaction that last inserted or updated it;DB_ROLL_PTR(7 bytes) — a pointer to an undo log record holding what the row looked like before that change;DB_ROW_ID— the hidden row id, used only when the table has no suitable key.
An UPDATE changes the row in place and writes the previous values to the undo log, linked by the roll pointer. Repeated updates form a version chain: newest in the table, older ones reachable record by record through the undo log. A DELETE does not remove the row at once; it sets a delete mark, and the row is physically removed later.
The same undo records serve two purposes: ROLLBACK uses them to restore rows (atomicity), and consistent reads use them to rebuild old versions (isolation).
Read views: which version may I see?
A consistent read (any plain SELECT under READ COMMITTED or REPEATABLE READ) uses a read view, created from the state of the transaction system at that moment:
- the list of transaction ids that are active (started, not committed);
- the lowest active id, and the next id to be assigned.
To read a row, InnoDB compares the row's DB_TRX_ID with the view:
- created by this transaction itself → visible;
- created by a transaction that had committed before the view was made → visible;
- created by a transaction still active at that time, or started after → not visible: follow
DB_ROLL_PTRto the previous version and test again.
If the walk reaches a version that did not exist yet (the row was inserted after the snapshot), the row is simply absent from this reader's result. A delete-marked version that is visible means the row is deleted for this reader.
Isolation levels are snapshot timing
MVCC is how InnoDB implements the two levels you meet most:
| Level | When the read view is created |
|---|---|
READ COMMITTED | at the start of every statement — each sees the latest committed data |
REPEATABLE READ | at the first consistent read of the transaction, then reused until commit |
That is the whole difference between them for plain reads. START TRANSACTION WITH CONSISTENT SNAPSHOT creates the view immediately rather than at the first read — mysqldump --single-transaction uses it to take a consistent backup of a live database without locking it.
Remember the boundary from the isolation lesson: MVCC covers plain reads. UPDATE, DELETE and SELECT … FOR UPDATE read the latest committed version and lock it, because changing an old version would lose someone's committed write.
Purge: cleaning up old versions
Old versions cannot be kept forever. InnoDB's background purge threads discard undo records — and physically remove delete-marked rows — once no read view could still need them: when the version has been superseded by a newer one committed before the oldest active read view was created.
That rule is why one idle transaction can hurt a whole server:
-- session A, at 09:00
START TRANSACTION;
SELECT COUNT(*) FROM orders; -- read view created
-- …the developer goes to lunch, the session stays open
Until session A ends, purge cannot remove any version newer than its view. Every update made since 09:00 leaves undo records behind; the history list length (shown in SHOW ENGINE INNODB STATUS) climbs; queries that need an old snapshot walk ever-longer chains; and the undo tablespaces grow. Monitoring old entries in information_schema.INNODB_TRX and killing forgotten sessions is a routine DBA task.
PostgreSQL does it differently
PostgreSQL also uses MVCC, with a different layout:
- an
UPDATEwrites a new tuple in the table and marks the old one dead, using hiddenxmin/xmaxtransaction ids on each tuple; - old tuples stay in the table pages until VACUUM (usually autovacuum) reclaims them — the counterpart of InnoDB's purge;
- a table with heavy updates and lagging vacuum suffers bloat, the counterpart of a long undo history;
REPEATABLE READthere is snapshot isolation: a concurrent update to a row you also update raises a serialization error rather than waiting and applying on the newer version as InnoDB does.
Oracle keeps old versions in undo segments, closer to InnoDB; a query that needs a version already overwritten fails with the famous "snapshot too old" error.
What MVCC does not solve
- Write–write conflicts still need locks: two transactions updating the same row are serialised.
- Lost updates in read-modify-write code: both read version 1 and both write — use an atomic update,
FOR UPDATEor a version column. - Write skew: two snapshots, two different rows written, an invariant broken.
MVCC makes reads cheap and non-blocking; correctness for writes still comes from locks, constraints and careful transactions.
Practise
The exercises model InnoDB's bookkeeping in tables. The first computes what each snapshot sees — the newest version of each row created by a transaction committed before the snapshot, with deleted rows left out. The second plays purge: given the oldest active snapshot, it lists the old versions no reader can still need.
Common questions
How does MVCC work in InnoDB?
Every InnoDB row carries the id of the transaction that last changed it and a pointer to its undo record, which holds the previous version. A consistent read uses a read view listing which transactions were committed when it was created, and walks back along the undo chain until it finds a version that view may see.
What is a read view in MySQL?
A read view is the snapshot a consistent read uses: the transaction ids that were still active when it was created and the next id to be assigned. A row version is visible if its creating transaction committed before the view was made. Under REPEATABLE READ a transaction keeps one read view; under READ COMMITTED each statement gets a new one.
Why are long-running transactions bad for MySQL?
An open transaction's read view may still need old row versions, so InnoDB's purge thread cannot discard any undo record newer than the oldest active view. Undo logs grow, the history list length rises, reads slow down walking long version chains, and disk use climbs. Commit promptly, and never leave a session idle inside a transaction.
How is MVCC in PostgreSQL different from MySQL?
PostgreSQL writes a new tuple for every updated row in the table itself, marking versions with xmin and xmax transaction ids, and the old tuples stay until VACUUM removes them. InnoDB updates the row in place and keeps the old version in the undo log, which a purge thread cleans. Both give readers snapshots without locks.
Exercises
What each snapshot sees
Model InnoDB's version chains. row_versions holds every version of every row (version increases per row, trx_id created it, deleted = 1 marks a delete). commits lists committed transactions in commit order (commit_seq); a transaction absent from it has not committed. A snapshot taken at as_of_seq sees a version only if its transaction committed with commit_seq <= as_of_seq, and for each row it reads the newest such version — and the row is absent if that version is a delete.
row_versions:
| row_id | version | trx_id | val | deleted |
|---|---|---|---|---|
| 1 | 1 | 100 | Pune | 0 |
| 1 | 2 | 103 | Mumbai | 0 |
| 2 | 1 | 100 | red | 0 |
| 2 | 2 | 105 | blue | 0 |
| 3 | 1 | 102 | draft | 0 |
| 3 | 2 | 104 | draft | 1 |
| 4 | 1 | 106 | new | 0 |
commits: (100, 1), (102, 2), (103, 3), (104, 4), (106, 5) — 105 is still running. snapshots (reader, as_of_seq): ('r1', 2), ('r2', 4), ('r3', 5).
Output columns: reader, row_id, val, ordered by reader, then row_id.
reader | row_id | val
r1 | 1 | Pune
r1 | 2 | red
r1 | 3 | draft
r2 | 1 | Mumbai
r2 | 2 | red
r3 | 1 | Mumbai
r3 | 2 | red
r3 | 4 | newWhat purge may remove
InnoDB's purge may discard an old version once no active read view can need it: when a newer version of the same row was committed at or before the oldest active view's as_of_seq. If no view is active, the oldest view counts as the newest commit (MAX(commit_seq)). List the purgeable versions.
row_versions (row_id, version, trx_id, val, deleted):
| row_id | version | trx_id | val | deleted |
|---|---|---|---|---|
| 1 | 1 | 100 | Pune | 0 |
| 1 | 2 | 103 | Mumbai | 0 |
| 1 | 3 | 107 | Delhi | 0 |
| 2 | 1 | 100 | red | 0 |
| 2 | 2 | 105 | blue | 0 |
| 3 | 1 | 102 | draft | 0 |
| 3 | 2 | 104 | draft | 1 |
commits: (100, 1), (102, 2), (103, 3), (104, 4), (107, 6) — 105 is still running. active_views (reader, as_of_seq): ('r7', 5), ('r2', 3), ('r8', 6) — so the oldest view is at 3.
Output columns: row_id, version, ordered by row_id, then version.
row_id | version
1 | 1
Row 1's version 1 was superseded by version 2, committed at 3 — every view sees version 2 or later. Row 3's version 1 must stay: its delete committed at 4, after the view r2 was taken.
In this module: Transactions and concurrency
← Locks and deadlocks · Savepoints — partial rollback inside a transaction →