Reading Without Waiting
Each transaction reads the database as it was when it first looked, rebuilding older rows from undo
Stop locking plain reads. A transaction's first read takes a read view; every row it reads is the newest version that view may see, rebuilt from undo records when the row changed since. DELETE marks rows instead of removing them, and purge takes marks and old records away once no view is open.
- Readers never wait for writers, and writers never wait for readers
- A row has versions: the newest in the tree, the older ones in its undo records
- A read view is a list of who had committed when the transaction first looked
- A transaction still running when the view was taken stays invisible, even after it commits
- A delete is a version too: the row stays, marked, until nobody can see it
Why this stage exists
With locks, a report that sums every account takes S on the whole table, and waits behind every transfer that holds X on a row. Worse, every transfer waits behind the report. Readers and writers queue for each other, though a reader changes nothing.
InnoDB's answer, and this stage's: a plain read takes no locks. It reads the database as it was at a fixed moment, rebuilding any row that changed since from the undo records that already exist for rollback. Writers still lock each other; readers never wait and are never waited for.
Versions
The row in the tree is the newest version. Each undo record of that row holds the version before one change, and says which transaction made the change. So the newest version and the row's records, newest first, are its whole history:
- in the tree:
name = d, made by transaction 9; - undo record:
name = c, made by transaction 8; - undo record:
name = b, made by transaction 7; - undo record:
name = ann, made by transaction 3.
A reader walks this chain from the newest version until it reaches one it is allowed to see. These records live in memory, beside the undo records of the transaction that made them, found by table and key. InnoDB keeps the same pointer inside every row as two hidden columns; keeping it outside the row means the row format does not change, and after a restart no view can be open, so nothing is lost.
The read view
What a reader may see is fixed by its read view, taken by its first plain read, not by BEGIN: a transaction that begins and waits a minute before reading sees what was committed during that minute. The view holds two things: the id the next transaction would get, and the ids of the transactions open at that moment.
A change made by transaction t is visible to the view if t is the reader itself, or if t had committed when the view was taken: its id is below the view's next id, and it was not in the open list.
The open list is what simpler rules forget. Transaction 4 began before the view and is still running when it is taken; it commits a moment later. Its id is small, but its change was not committed at the view's moment, so the reader must never see it, before or after that commit.
A transaction keeps its view until it ends, so it reads the same thing every time: this is REPEATABLE READ, the default. The next stage adds other levels.
Deletes become marks
If a DELETE took the row out of its leaf, an old reader scanning the leaf would never meet it, and could not walk back to the version it should see. So a DELETE now only marks the row: bit 15 of the length in its slot. The row keeps its bytes and its slot, and its leaf keeps the space. A reader that sees the delete skips it; one that does not reads the version before it.
Marks and kept records must go some time. When a transaction ends and no view is open anywhere, nobody can need any old version: every committed record is dropped, and every row a committed transaction marked is taken out of its leaf as a DELETE took it out before, merges included. With one session that happens at every COMMIT, so a lone session sees exactly what it saw before this stage. With a view open they stay; the purge stage deals with what a long view holds back.
A marked row still holds its key. An INSERT of that key, once the deleting transaction has committed, replaces the marked row; while the delete is uncommitted, the INSERT waits for its row lock as before.
>@1 begin;@1| Begin statement executed successfully>@1 select count(*) from users;@1| COUNT(*)@1| 2@1| (1 rows)@1| [EXEC] rows_examined=2>@2 delete from users where id = 6;@2| Delete statement executed successfully (1 rows)@2| [EXEC] rows_examined=1>btree dump 2[BTREE] Page 2: type=LEAF num_cells=2 free_space=4028[BTREE] Cell 0: key=5 (16 bytes)[BTREE] Cell 1: key=6 (16 bytes) marked>undo status[UNDO] records=1 pages=0 oldest_view=4 purgeable=0
The algorithm in words
Taking a view
At the first plain read, not at BEGIN.
- 1.Remember the id the next transaction would get.
- 2.Remember the ids of every transaction open right now, other than this one.
- 3.Keep the view until the transaction ends. An autocommit SELECT takes one for itself and drops it at its end.
Reading a row through a view
Walk back until the change is one the view may see.
- 1.Take the row as it is in the tree, and the newest undo record of that row, if any. The record says which transaction made the row as it is now.
- 2.The change is visible if that transaction is the reader itself, or its id is below the view's next id and it was not in the view's open list.
- 3.If it is not visible, the version before it is the record's old row: an INSERT has no older version, so the row is not seen; an UPDATE's record holds the row before; a DELETE's holds the row before it was deleted.
- 4.Repeat with the next older record until a change is visible or there is none. A marked row whose delete is visible is not seen.
Deleting and purging
Marks, then removal when nobody can look.
- 1.A DELETE sets bit 15 of the row's slot length and keeps the bytes. Undoing it clears the bit.
- 2.Committed undo records are kept, not dropped.
- 3.When a transaction ends and no view is open in any session, drop every committed record and take every row a committed transaction marked out of its leaf, in the order they were marked, merging as a DELETE would.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, section 87.
| Input | Output |
|---|---|
| a SELECT beside a writer's open transaction | its result, from the view, and no [LOCK] line |
btree dump of a leaf with a marked row | [BTREE] Cell <i>: key=<k> (<n> bytes) marked; free_space unchanged |
undo status while a view is open | oldest_view=<id of the transaction holding the oldest view>, purgeable=<the undo records of committed transactions whose change every open view sees> |
What you should see when done
$droid --db v.dbWelcome to droid!>create table users (id int primary key, name varchar(20));Create table statement executed successfully>insert into users (id, name) values (5, 'ann');Insert statement executed successfully (1 rows)>@1 begin;@1| Begin statement executed successfully>@1 select name from users where id = 5;@1| name@1| ann@1| (1 rows)@1| [EXEC] rows_examined=1>@2 begin;@2| Begin statement executed successfully>@2 update users set name = 'bea' where id = 5;@2| Update statement executed successfully (1 rows)@2| [EXEC] rows_examined=1>@1 select name from users where id = 5;@1| name@1| ann@1| (1 rows)@1| [EXEC] rows_examined=1>@2 commit;@2| Commit statement executed successfully>@1 select name from users where id = 5;@1| name@1| ann@1| (1 rows)@1| [EXEC] rows_examined=1>@3 select name from users where id = 5;@3| name@3| bea@3| (1 rows)@3| [EXEC] rows_examined=1>.exitexiting.. good bye!
Common bugs in this stage
- Taking the view at BEGIN. The first read takes it.
- Comparing ids only. A transaction open when the view was taken is invisible whatever its id.
- Dropping undo records at COMMIT. A view may still need them.
- Removing the row on DELETE. An old reader can no longer find it; mark it.
- Counting rows the view does not see in
rows_examined. Only rows handed up count. - Locking plain reads. UPDATE and DELETE still lock, and act on the newest rows; SELECT does not.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C: a hash table from (table id, key) to the newest undo record of that row, each record pointing at the next older one of the same row.
- C++:
std::unordered_map<RowId, UndoRecord*>, with records in astd::dequeso pointers stay valid as it grows. - Rust: keep records in a
Vecand link them by index (Option<usize>), which avoids self-referencing structures. - Zig: an
AutoHashMap(RowId, u32)of record indexes into anArrayList(UndoRecord).
Checklist
One line per thing the tests check. The tags name the test cases.
- A transaction keeps reading a row as its view saw itreader-sees-old-value
- A plain read never waits for a writer and never sees an uncommitted changereader-never-waits
- An old view walks back through several committed versionswalks-three-versions
- A row deleted after the view is still seen; one inserted after it is notdelete-and-insert-invisible
- BEGIN takes no view; the first read doesview-starts-at-first-read
- A transaction open when the view was taken stays invisible after it commitsstill-active-writer-excluded
- A DELETE marks the cell; the end of the transaction, with no view open, takes it outdelete-marks
- While a view is open, a committed delete stays marked and its undo record staysold-versions-kept-for-view