MVCC, transactions, isolation
Part 3 gave you the structure your data lives in. This part adds time. Several versions of the same row exist simultaneously, and which one your transaction sees depends on a small algorithm run against a snapshot taken at a precise moment. Get this part right and isolation levels stop being a table you memorize and start being something you can derive.
ACID, honestly
Four letters, three of which are straightforward and one of which is where all the interesting engineering lives.
| The claim | How InnoDB delivers it | |
|---|---|---|
| Atomicity | All of the transaction, or none. | Undo logs. Rollback replays them backwards. Covered here and in Part 6. |
| Consistency | Constraints hold before and after. | Mostly your job. The engine enforces declared constraints; the rest is application logic. This is the least interesting letter and it is often padding. |
| Isolation | Concurrent transactions do not corrupt each other. | MVCC plus locking. This whole part, plus Part 5. It is a dial, not a guarantee — you choose how much you get. |
| Durability | Committed means survives a crash. | Redo log + fsync. And it is configurable — you can turn durability off for throughput. Part 6. |
innodb_flush_log_at_trx_commit=2 trades it for throughput and
plenty of production systems run that way. "The database is ACID" is a claim
that needs two configuration values before it means anything.The read view
A read view is InnoDB's snapshot: a small structure captured at a moment in time that decides, for any row version, whether your transaction is allowed to see it. Four fields:
| field | meaning | |
|---|---|---|
| m_ids | list | Transaction ids that were active (started, not committed) when the view was created. |
| up_limit_id | min | The smallest id in m_ids. Anything below this had already committed — always visible. |
| low_limit_id | next | The next id the server will hand out. Anything at or above this started after you — never visible. |
| creator_trx_id | self | Your own id. Your own changes are always visible to you. |
The visibility algorithm
For a row version stamped with trx_id, in order:
if trx_id == creator_trx_id → VISIBLE -- you wrote it if trx_id < up_limit_id → VISIBLE -- committed before you started if trx_id >= low_limit_id → INVISIBLE -- started after you if trx_id in m_ids → INVISIBLE -- was in flight when you started else → VISIBLE -- committed in the gap
When a version is invisible, InnoDB follows that row's
DB_ROLL_PTR into the undo log and tests the previous version, and
the one before that, until one passes. That walk is the subject of chapter 44.
The hidden columns
Every InnoDB row carries columns you did not declare. From chapter 20's row format, here they are in full:
| column | size | purpose | |
|---|---|---|---|
| DB_TRX_ID | 6 B | version stamp | The id of the last transaction to insert or update this row. This is the value the read view algorithm tests. |
| DB_ROLL_PTR | 7 B | undo pointer | Points into the undo log at the previous version of this row. The head of the version chain. |
| DB_ROW_ID | 6 B | fallback key | Only present when the table has no usable primary key — the global-counter problem from chapter 25. |
Thirteen bytes per row, on every table, forever. On a billion-row table that is 13GB of pure MVCC bookkeeping — a real cost, and the reason a table of narrow rows is never as small as the column widths suggest.
Undo logs and the version chain
When a transaction updates a row, InnoDB does not keep two copies in
the page. It overwrites the row in place with the new values, and writes the
old values into the undo log. The row's
DB_ROLL_PTR then points at that undo record, which itself points
at any earlier one. The result is a linked list running backwards through time.
Two kinds of undo record, and the difference matters:
- Insert undo — only needed for rollback. Once the inserting transaction commits, nobody can ever need the "before" state, because there was none. Discarded immediately at commit.
- Update undo — needed for rollback and for MVCC reads by older transactions. Cannot be discarded until no read view could possibly need it. This is what accumulates.
Purge, and how one long transaction fills your disk
Undo records cannot be kept forever. Background purge threads delete undo records that no active read view could need, and physically remove rows that were delete-marked.
Purge can only advance to the oldest active read view. One transaction that opened a snapshot and never committed pins that point, and everything after it accumulates. This is the single most common serious InnoDB incident, and it has a clean signature.
SELECT, then blocks
on an HTTP call to a payment provider that hangs. The transaction stays open
for forty minutes. Meanwhile writes continue across the database, and
none of their undo can be purged, because that idle transaction might
still read the old versions. The undo tablespace grows to hundreds of
gigabytes. Every read of a hot row now walks a chain thousands of versions
long, so queries that took 2ms take 2s. Eventually the disk fills and the
server stops.
The fix takes one second — kill the idle transaction. Finding it is the hard part, and the query below is how.
-- 1. How far behind is purge? This is the number that matters.
-- Healthy: a few thousand. Trouble: millions.
SELECT name, count FROM information_schema.innodb_metrics
WHERE name = 'trx_rseg_history_len';
-- (also shown as "History list length" here)
SHOW ENGINE INNODB STATUS\G
-- 2. Who is holding it open? Longest-running transactions first.
SELECT trx_id, trx_state,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_s,
trx_mysql_thread_id AS conn_id,
trx_rows_modified, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
-- Note: trx_query is often NULL — the transaction is idle, which
-- is exactly the problem. Join to find what it last ran:
SELECT t.trx_id,
TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) AS age_s,
p.user, p.host, p.db, p.command, p.time,
COALESCE(t.trx_query, '-- IDLE IN TRANSACTION --') AS q
FROM information_schema.innodb_trx t
JOIN information_schema.processlist p ON p.id = t.trx_mysql_thread_id
ORDER BY age_s DESC;
-- 3. Undo tablespace size on disk.
SELECT tablespace_name, file_name,
ROUND(file_size/1024/1024/1024,2) AS gb
FROM information_schema.files
WHERE file_type = 'UNDO LOG';
-- 4. The fix, once you have identified the culprit:
-- KILL <conn_id>;
-- 5. The prevention:
SHOW VARIABLES LIKE 'innodb_max_undo_log_size';
SHOW VARIABLES LIKE 'innodb_undo_log_truncate'; -- ON since 8.0
-- and, in the app: never hold a transaction across a network call.The four isolation levels
The level determines when a read view is created, which is the entire mechanism. Everything else follows.
| Level | Read view | Dirty read | Non-repeatable | Phantom |
|---|---|---|---|---|
READ UNCOMMITTED | None — reads latest, committed or not | possible | possible | possible |
READ COMMITTED | New one per statement | no | possible | possible |
REPEATABLE READ (default) | One per transaction, at first read | no | no | mostly no — see ch 47 |
SERIALIZABLE | Per transaction, plus every plain SELECT becomes LOCK IN SHARE MODE | no | no | no |
Reproduce it yourself
Open two terminals. The left one is transaction A, the right is B. Run the lines in the order shown by the timestamps.
t1 SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; t2 BEGIN; t3 SELECT bal FROM acct WHERE id=1; -- 100 ← read view created HERE t7 SELECT bal FROM acct WHERE id=1; -- 100 still! repeatable. t8 COMMIT; t9 SELECT bal FROM acct WHERE id=1; -- 500 new transaction, new view
t4 BEGIN; t5 UPDATE acct SET bal=500 WHERE id=1; t6 COMMIT; -- committed and visible -- to everyone EXCEPT A
Now change A's first line to READ COMMITTED and run it again. At
t7 you will see 500, because READ COMMITTED takes a fresh read view for every
statement. That one difference is the whole distinction between the two levels.
CREATE TABLE acct (id INT PRIMARY KEY, bal INT); INSERT INTO acct VALUES (1, 100); -- Check what level you are actually on: SELECT @@transaction_isolation, @@global.transaction_isolation;
REPEATABLE READ in MySQL specifically
This chapter exists because MySQL's REPEATABLE READ differs from both the standard and from Postgres's, in ways that produce real bugs.
1. The snapshot starts at the first read, not at BEGIN
BEGIN does not create a read view. The first consistent
read does. So a transaction that begins, waits ten seconds, then selects,
sees the data as of the select — not as of the begin. If you need the snapshot
pinned at the start, you must say so:
START TRANSACTION WITH CONSISTENT SNAPSHOT; -- view created now
2. Current reads see the present, ignoring your snapshot
This is the big one. A plain SELECT is a consistent read
and obeys the read view. But SELECT ... FOR UPDATE,
SELECT ... FOR SHARE, UPDATE and DELETE
are current reads: they read the latest committed version,
because locking a stale version would be meaningless.
So a single transaction can see two different values for the same row:
t1 BEGIN; t2 SELECT bal FROM acct WHERE id=1; -- 100 (consistent read) t5 SELECT bal FROM acct WHERE id=1; -- 100 snapshot holds t6 SELECT bal FROM acct WHERE id=1 FOR UPDATE; -- 500 ← CURRENT read. different answer, -- same transaction, same row. t7 UPDATE acct SET bal = bal + 10 WHERE id=1; -- 510, not 110
t3 UPDATE acct SET bal=500 WHERE id=1; t4 COMMIT;
The fix: if you intend to modify a row based on its value, take the lock on the read —
SELECT ... FOR UPDATE — so read and write see
the same version. Or perform the update in one statement
(SET bal = bal - 10) so there is no read-then-write gap at all.3. Phantoms are mostly, but not entirely, prevented
Standard REPEATABLE READ allows phantom rows. MySQL's mostly prevents them — consistent reads use the snapshot, and current reads take gap locks (chapter 54) that block inserts into the ranges you read. The gap is that a current read can still see rows inserted and committed after your snapshot, as in the demo above.
Phantoms, write skew, and lost updates
Precise definitions, because these get used loosely.
| Anomaly | What happens | Stopped by |
|---|---|---|
| Dirty read | You read a value another transaction wrote but has not committed. It may roll back, so you read something that never existed. | READ COMMITTED and above |
| Non-repeatable read | You read a row twice and get different values, because someone committed in between. | REPEATABLE READ |
| Phantom read | You run the same range query twice and the second returns rows that did not exist before. | REPEATABLE READ (mostly, via gap locks) |
| Lost update | Two transactions read the same value, both compute a new one, both write. The second overwrites the first; one update vanishes. | No isolation level stops this in the read-modify-write pattern. Needs FOR UPDATE or an atomic statement. |
| Write skew | Two transactions read overlapping data, each checks an invariant that holds, each writes a different row — and together they break the invariant. | SERIALIZABLE only |
Write skew, concretely
The classic: a hospital rota requiring at least one doctor on call. Two doctors are on call. Both try to go off call simultaneously.
-- A -- B SELECT COUNT(*) FROM oncall SELECT COUNT(*) FROM oncall WHERE on_call = TRUE; WHERE on_call = TRUE; -- 2, fine to leave -- 2, fine to leave UPDATE oncall SET on_call=FALSE UPDATE oncall SET on_call=FALSE WHERE id = 1; WHERE id = 2; COMMIT; COMMIT; -- invariant broken: nobody is on call, and neither transaction did -- anything wrong by itself. They wrote DIFFERENT rows, so no lock -- conflict occurred and REPEATABLE READ did not help.
Fixes, in order of preference: lock the rows you read
(SELECT ... FOR UPDATE), use SERIALIZABLE, or express
the invariant as a database constraint so it cannot be violated.
Consistent reads versus locking reads
| Statement | Read type | Locks taken | Sees |
|---|---|---|---|
SELECT | consistent | none | Your snapshot |
SELECT … FOR SHARE | current | Shared (S) | Latest committed |
SELECT … FOR UPDATE | current | Exclusive (X) | Latest committed |
UPDATE / DELETE | current | Exclusive (X) | Latest committed |
The first row is why MySQL handles read-heavy workloads well: plain reads take no locks at all and never block, and are never blocked by writers. Readers and writers do not contend. That property is the entire point of MVCC.
Two modifiers worth knowing
SELECT ... FOR UPDATE NOWAIT; -- fail immediately with ER_LOCK_NOWAIT rather than waiting. -- use when a stale answer is worse than an error. SELECT ... FOR UPDATE SKIP LOCKED; -- silently skip rows someone else has locked. -- this is a queue primitive — next chapter.
SKIP LOCKED as a job queue
A genuinely useful pattern, and one you can reach for directly. Before 8.0,
building a job queue on MySQL meant either a status-flag dance with a race
condition, or every worker serializing on the same lock.
SKIP LOCKED makes it clean.
CREATE TABLE jobs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
status ENUM('queued','running','done','failed') NOT NULL DEFAULT 'queued',
run_after DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
attempts INT NOT NULL DEFAULT 0,
payload JSON,
-- the index that makes the claim query a range scan, not a table scan
INDEX idx_claim (status, run_after, id)
);
-- Each worker, in a loop:
BEGIN;
-- Claim: lock rows nobody else has, skipping any that are taken.
SELECT id, payload
FROM jobs
WHERE status = 'queued'
AND run_after <= NOW(3)
ORDER BY run_after, id
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- Mark them running so they are not re-claimed after we commit.
UPDATE jobs
SET status = 'running', attempts = attempts + 1
WHERE id IN (...);
COMMIT; -- release the locks BEFORE doing the slow work
-- ... do the actual job outside any transaction ...
UPDATE jobs SET status = 'done' WHERE id = ?;Have an index that matches the claim predicate. Without
(status, run_after, id), the claim query scans, and
SKIP LOCKED over a scan means every worker examines every locked
row before finding a free one — quadratic behaviour as workers scale.MVCC explains what you can see. Part 5 explains what you can touch: the lock hierarchy, gap locks, and how two well-written transactions deadlock each other.