Part 4 · 10 chapters · ~20 min

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.

41

ACID, honestly

Four letters, three of which are straightforward and one of which is where all the interesting engineering lives.

The claimHow InnoDB delivers it
AtomicityAll of the transaction, or none.Undo logs. Rollback replays them backwards. Covered here and in Part 6.
ConsistencyConstraints 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.
IsolationConcurrent 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.
DurabilityCommitted means survives a crash.Redo log + fsync. And it is configurable — you can turn durability off for throughput. Part 6.
the honest version
Two of the four letters are settings rather than guarantees. Isolation defaults to REPEATABLE READ, not serializable, so anomalies are possible by default. Durability defaults to full, but 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.
42

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:

fieldmeaning
m_idslistTransaction ids that were active (started, not committed) when the view was created.
up_limit_idminThe smallest id in m_ids. Anything below this had already committed — always visible.
low_limit_idnextThe next id the server will hand out. Anything at or above this started after you — never visible.
creator_trx_idselfYour own id. Your own changes are always visible to you.

The visibility algorithm

For a row version stamped with trx_id, in order:

code
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.

read view
which version can this transaction see?
swipe the figure sideways, or tap expand for full screen
1/6
transactions
Several transactions, ordered by id. Some have committed; two are still running when our transaction takes its snapshot.
43

The hidden columns

Every InnoDB row carries columns you did not declare. From chapter 20's row format, here they are in full:

columnsizepurpose
DB_TRX_ID6 Bversion stampThe id of the last transaction to insert or update this row. This is the value the read view algorithm tests.
DB_ROLL_PTR7 Bundo pointerPoints into the undo log at the previous version of this row. The head of the version chain.
DB_ROW_ID6 Bfallback keyOnly 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.

44

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.
the key asymmetry, versus Postgres
InnoDB's current row is always the newest version, so reading fresh data is fast and reading old data costs a chain walk. Postgres keeps every version in the heap, so old and new cost the same to read, but the table bloats and needs VACUUM. Same problem, opposite trade — and it is why MySQL's pathology is a growing undo history while Postgres's is table bloat.
version chain
walking back through undo
swipe the figure sideways, or tap expand for full screen
1/7
insert
A row is inserted. One version exists, stamped with the id of the transaction that created it.
45

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.

the incident, start to finish
An application opens a transaction, runs one 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.
run it
-- 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.
46

The four isolation levels

The level determines when a read view is created, which is the entire mechanism. Everything else follows.

LevelRead viewDirty readNon-repeatablePhantom
READ UNCOMMITTEDNone — reads latest, committed or notpossiblepossiblepossible
READ COMMITTEDNew one per statementnopossiblepossible
REPEATABLE READ (default)One per transaction, at first readnonomostly no — see ch 47
SERIALIZABLEPer transaction, plus every plain SELECT becomes LOCK IN SHARE MODEnonono

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.

session A
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
session B



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.

run it
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;
47

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:

code
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:

session A · REPEATABLE READ
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
session B

t3 UPDATE acct SET bal=500
     WHERE id=1;
t4 COMMIT;
the bug this causes
Application code reads a value, computes with it, then writes. The read used the snapshot; the write used the present. The computation was based on data that was already stale at write time, and no error is raised. This is the mechanism behind a whole family of "the balance is wrong and we cannot reproduce it" bugs.

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.

48

Phantoms, write skew, and lost updates

Precise definitions, because these get used loosely.

AnomalyWhat happensStopped by
Dirty readYou 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 readYou read a row twice and get different values, because someone committed in between.REPEATABLE READ
Phantom readYou run the same range query twice and the second returns rows that did not exist before.REPEATABLE READ (mostly, via gap locks)
Lost updateTwo 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 skewTwo 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.

code
-- 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.

49

Consistent reads versus locking reads

StatementRead typeLocks takenSees
SELECTconsistentnoneYour snapshot
SELECT … FOR SHAREcurrentShared (S)Latest committed
SELECT … FOR UPDATEcurrentExclusive (X)Latest committed
UPDATE / DELETEcurrentExclusive (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

code
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.
50

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.

run it
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 = ?;
the two rules that make this correct
Commit before doing the work. Holding the transaction open across job execution is precisely the chapter 45 incident — an idle transaction pinning purge while your worker calls an external API.

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.