InnoDB Internals: Clustered Index, Buffer Pool, Redo, Undo, Locks
In InnoDB the table is a B+ tree ordered by primary key, secondary indexes store primary keys instead of addresses, old row versions live in undo logs rather than in the table, and durability rests on a circular redo log plus a doublewrite buffer — the same general mechanisms as PostgreSQL, with nearly every decision made the other way.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
Same three requirements: concurrent updates without readers blocking, durability at COMMIT, few page reads per lookup — plus a workload (OLTP by primary key) where range scans on the key should be sequential I/O.
↓ - Naive solution
Store rows in a heap and add a B+ tree per index whose leaves point at row addresses, like PostgreSQL.
↓ - Why it breaks
Rows by primary key are scattered across the heap, so a PK range scan is random I/O; every index must be rewritten whenever a row moves; old versions in the heap need a garbage collector that can fall behind.
↓ - Better idea
Make the primary key's B+ tree the table: store the full row in its leaves, so PK order is physical order. Let secondary indexes reference rows by primary key so rows may move on split without touching them. Keep old versions out of the table, in a log that can be truncated in commit order.
↓ - Internal mechanism
Clustered index with rows in 16 KB leaf pages; secondary B+ trees whose leaves hold (key, PK); a buffer pool with young/old LRU sublists and a change buffer; row versions reconstructed from undo logs via a read view; a circular redo log addressed by LSN with fuzzy checkpoints; a doublewrite buffer against torn pages; record, gap and next-key locks for isolation.
↓ - Trade-offs
Every secondary lookup is two descents; the PK size is paid in every secondary index; random primary keys split pages all over the tree; long transactions grow the undo history and slow every reader that must walk it; gap locks surprise developers with deadlocks.
↓ - Real database
InnoDB, MySQL's default engine since 5.5 and MariaDB's; the same layout family as SQL Server clustered indexes and SQLite's rowid tables.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
InnoDB has no heap. Rows are stored in the leaves of a B+ tree ordered by the primary key (or a hidden 6-byte row id if you declare none). Look up a row by primary key and the leaf you reach *is* the row. Look up by any other column and you go through a secondary index that gives you a primary key, then descend the primary tree to fetch the row.
When you UPDATE, the row is changed in place and the previous value is copied into an undo log. Other transactions that still need the old value follow a pointer from the row to that undo record. When you COMMIT, only the redo log must be flushed; the page is written later.
General concept → InnoDB structure
Read this lesson against PostgreSQL Internals: Heap, Tuples, Shared Buffers, WAL, VACUUM: the general concepts are identical, the implementations are close to mirror images. PostgreSQL keeps versions *in* the table and garbage-collects with VACUUM; InnoDB keeps versions *outside* it in undo logs and purges in commit order. PostgreSQL indexes store physical addresses; InnoDB indexes store primary keys. PostgreSQL protects torn pages with full-page images in WAL; InnoDB with a doublewrite buffer. Neither is "better" — each is a coherent set of choices, and the choices explain the operational advice you have heard for years (UUID primary keys, long transactions, gap-lock deadlocks).
| General concept | InnoDB structure | Where to see it |
|---|---|---|
| Record / page (Slotted Pages) | Compact row format in 16 KB pages; in-page linked list + page directory; hidden DB_TRX_ID, DB_ROLL_PTR | information_schema.INNODB_TABLESPACES; innochecksum; innodb_ruby |
| Table storage (How Is Database Data Physically Stored?) | Clustered index: the PK B+ tree with full rows in the leaves; .ibd file per table | SHOW TABLE STATUS; information_schema.INNODB_INDEXES (index 1 = PRIMARY) |
| Secondary B+ tree (B+ Tree Internals: Pages, Splits, Merges) | Leaves hold (key columns, PK columns); lookups do two descents | INNODB_INDEXES, EXPLAIN Extra: Using index for covering |
| Buffer pool (The Buffer Pool) | Young/old LRU sublists, change buffer, adaptive hash index, flush lists | SHOW ENGINE INNODB STATUS (BUFFER POOL AND MEMORY); INNODB_BUFFER_PAGE |
| Write-ahead log (Write-Ahead Logging) | Redo log ring, LSN, fuzzy checkpoints, innodb_flush_log_at_trx_commit | SHOW ENGINE INNODB STATUS (LOG: Log sequence number, Last checkpoint at) |
| Version chain (MVCC Internals: Version Chains and Snapshots) | Undo log records reached through DB_ROLL_PTR; read views; purge threads | INNODB_TRX (trx_rows_modified); History list length in engine status |
| Lock manager (The Lock Manager) | Record, gap, next-key and insert-intention locks in a lock hash | performance_schema.data_locks, data_lock_waits; LATEST DETECTED DEADLOCK |
| Torn-page protection (Crash Recovery) | Doublewrite buffer, then the home page | Innodb_dblwr_pages_written status counter |
The clustered index: the table is a B+ tree
Every InnoDB table is exactly one B+ tree keyed by the primary key, with the complete row stored in each leaf entry. There is no separate heap and no row address: the primary key *is* the address. The consequences follow mechanically. A lookup by PK reaches the row in one descent (three levels for tens of millions of rows with 16 KB pages, so root and internal pages are in memory and the cost is ~1 leaf read). A range scan by PK walks linked leaves in physical order. Rows with adjacent keys share pages, which is locality for free when the key correlates with access (an order and its items with a composite (order_id, line_no) key) and useless when it does not.
Insert order matters because the tree is the table. A monotonic key appends to the rightmost leaf; when that leaf is full, InnoDB uses a split-at-insert-point optimisation that opens a new page for the new record instead of moving half the page, so pages stay full and only the right edge is hot. A random key (UUIDv4, a hash) lands anywhere: the target leaf is usually not in the buffer pool (a read), it splits in half (two writes, and a parent update), and average fill drops toward 50–70 %. Ten million rows with a UUID PK occupy roughly 1.5× the pages of the same rows with a bigint, plus the same factor in every secondary index. If UUIDs are required, use a time-ordered one (UUIDv7, ULID) so inserts are still monotonic.
+-----------------------------------------------------------------------+ | FIL header (38 B) page type INDEX LSN prev/next page links (leaf chain)| | INDEX header (36 B) n_recs heap_top level 0 index id ... | | FSEG header | | infimum record -> next | | rec: hdr 5 B | nulls | offsets | id=1001 | DB_TRX_ID | DB_ROLL_PTR | email name balance ... | | rec: hdr 5 B | nulls | offsets | id=1002 | DB_TRX_ID | DB_ROLL_PTR | ... | | rec: hdr 5 B | nulls | offsets | id=1003 | DB_TRX_ID | DB_ROLL_PTR | ... (delete-marked) | | ... | | supremum record | | free space | | page directory: sparse slots for binary search (grows down) | | FIL trailer (8 B) checksum low 32 bits of LSN | +-----------------------------------------------------------------------+ secondary index leaf entry for email='ada@x.io' is ( 'ada@x.io' , 1001 ) <- the PK, not a page number
Secondary indexes store the primary key
A secondary index is a second B+ tree whose leaf entries are (indexed columns, primary-key columns). Nothing in it says where the row is on disk, because rows move: a split in the clustered tree relocates half a page of rows, and if secondary indexes stored addresses every one of them would need rewriting. Storing the PK makes secondary indexes immune to row movement — at the price of a second descent: find the PK in the secondary tree, then find the row in the clustered tree. With both upper levels cached that is typically one extra leaf read, but for a query returning thousands of rows it is thousands of extra descents; MySQL's Multi-Range Read sorts the PKs first so the clustered leaves are visited in order.
Two things follow. Covering indexes are unusually powerful here: because the PK is already in every secondary leaf, an index on (status, created_at) covers SELECT id, created_at FROM orders WHERE status = ? with no second descent at all (Extra: Using index). And the PK size is paid N times: a 36-byte CHAR(36) UUID key adds 32 bytes over a bigint to every secondary entry in every index, which is what people mean when they say the primary key should be short, immutable and monotonic. Changing a row's PK moves the row and rewrites every secondary entry — an UPDATE that is really a DELETE plus INSERT everywhere.
The buffer pool: young and old sublists, change buffer
The buffer pool (innodb_buffer_pool_size, typically 50–75 % of RAM on a dedicated server, split into instances to spread mutex contention) caches 16 KB pages on an LRU list divided at the midpoint: the newer 5/8 are the young sublist, the older 3/8 the old sublist. A page read from disk is inserted at the midpoint, i.e. at the head of the old sublist. It is promoted to young only when accessed again *after* innodb_old_blocks_time milliseconds — so a full table scan or a mysqldump, which touches each page once, cycles through the old sublist and never evicts the hot set. SHOW ENGINE INNODB STATUS reports the young/old split and the "young-making rate"; a low hit rate with a high young-making rate means the working set does not fit.
The change buffer (part of the pool, up to 25 % by default) absorbs modifications to secondary-index leaf pages that are not currently in memory: instead of reading a random index page to insert one entry, InnoDB records the change and merges it when the page is read for another reason or by a background thread. For tables with several secondary indexes and random insert keys this converts a random read+write per index per row into occasional sequential merges. It only works for non-unique indexes (a unique check needs the page) and it is why a crash leaves pending merges that recovery must complete. The adaptive hash index is the third resident: a hash table over frequently accessed B+ tree leaf entries that short-cuts descents for equality lookups.
Redo log, LSNs, checkpoints, doublewrite
The redo log is a fixed-size ring of files (#innodb_redo/, sized by innodb_redo_log_capacity, 100 MB default, often set to hours of writes). Every page modification appends a physiological record — "on page P, apply this change to record R" — at the next LSN, a byte offset that only increases. COMMIT flushes the log up to the transaction's last LSN (innodb_flush_log_at_trx_commit = 1; 2 writes to the OS cache and fsyncs per second, 0 neither — both trade durability for throughput exactly like PostgreSQL's synchronous_commit = off). Dirty pages sit on a flush list ordered by the LSN of their oldest modification; a fuzzy checkpoint advances the checkpoint LSN to the oldest such LSN once those pages are written. The ring may only be overwritten behind the checkpoint, so if dirty pages are not flushed fast enough the log fills and every writer stalls — "checkpoint age" is the metric.
A 16 KB page is written with multiple system calls and a crash can leave it half old, half new. Redo cannot fix that: a physiological record needs the page it is applied to be intact. So each batch of dirty pages is first written sequentially to the doublewrite buffer (its own files since 8.0.20), fsynced, then written to the pages' home locations. On recovery, any page whose checksum fails is restored from the doublewrite copy before redo is replayed from the checkpoint LSN. PostgreSQL solves the same problem the other way, by putting a full page image into the WAL on first touch after each checkpoint; InnoDB's choice keeps the redo log small and pays a second sequential write instead.
Log sequence number 1,842,331,904 <- LSN of the newest redo record (written to the log buffer) Log flushed up to 1,842,331,904 <- durable: everything a COMMIT has been acknowledged for Pages flushed up to 1,840,117,215 <- oldest modification of any dirty page in the buffer pool Last checkpoint at 1,840,117,215 <- recovery starts here checkpoint age = LSN - Last checkpoint = 2,214,689 bytes (must stay < innodb_redo_log_capacity or writers stall) ring: [ ...free... | ckpt ▸▸▸▸▸▸▸▸▸▸ dirty-page range ▸▸▸ LSN | ...free... ] wraps around
Undo logs, read views and MVCC
InnoDB updates rows in place and preserves the previous version in an undo log record written to a rollback segment in the undo tablespaces. The row's hidden DB_ROLL_PTR points at that record, which in turn points at the one before it, so a row's history is a chain that starts in the table page and runs backwards through undo. Undo records serve two masters: rollback (apply them in reverse to restore the row) and MVCC (reconstruct the version a reader is entitled to). Because they are written to pages in the buffer pool, undo is itself covered by redo; a crash mid-transaction is rolled back after redo replays by applying the undo records of every transaction with no commit record.
A reader's read view (created at the first consistent read under REPEATABLE READ, per statement under READ COMMITTED) records the low and high water marks of transaction ids and the list of ids active in between. For each row the reader checks DB_TRX_ID: if it is visible under the view, use the row as it is; otherwise follow DB_ROLL_PTR and apply undo records until reaching a version whose trx id is visible. The cost is proportional to how many versions have accumulated — which is why a long-running SELECT (or an idle open transaction) that prevents purge from removing old undo records slows *every* reader of a hot row, and why "History list length" in the engine status is watched like n_dead_tup in PostgreSQL. Delete-marked rows are physically removed by purge as well, once no read view can need them.
clustered leaf: id=1001 balance=300 DB_TRX_ID=4713 DB_ROLL_PTR ──┐
▼
undo rseg 7: [trx 4713] id=1001 balance was 250 prev roll ptr ──┐
▼
[trx 4708] id=1001 balance was 200 prev roll ptr ── (null: the insert)
read view opened while 4713 active (sees ≤ 4712): 300 not visible → apply undo → 250 ✔
read view opened after 4713 committed: 300 ✔
purge: once no view can need trx 4708's record, it is deleted; the chain shortens from the tailTransactions and locks: record, gap, next-key
Consistent reads take no locks. Writes — and SELECT … FOR UPDATE / FOR SHARE — take row locks on the index records they touch, held until COMMIT, in a lock hash keyed by page and record heap number. Under REPEATABLE READ (the default), a locking scan also takes gap locks on the intervals *between* index records it examined and next-key locks (record + the gap before it), so no other transaction can insert a row into a range this one has read with intent to write. That is how InnoDB prevents phantoms without the predicate locks a textbook would require, and it works only because scans are over ordered B+ tree records — the gap is a real, nameable thing. An INSERT takes an insert-intention lock that waits on any gap lock covering its position.
The practical consequences: locking WHERE status = 'pending' without an index on status locks *every* row (the scan examined them all); a range WHERE id BETWEEN 10 AND 20 FOR UPDATE blocks inserts of 15 by anyone; and two transactions inserting into the same gap after each taking a shared next-key lock deadlock instantly — the most common InnoDB deadlock in the wild. READ COMMITTED drops gap locks (except for foreign-key and duplicate checks) and releases locks on non-matching rows after the statement, which is why many high-concurrency deployments choose it. The deadlock detector walks the waits-for graph on every lock wait (Deadlock Detection: The Waits-For Graph) and rolls back the transaction with the fewest modified rows; SHOW ENGINE INNODB STATUS prints the last one with both transactions' lock lists.
1-- session 12START TRANSACTION;3SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE; -- next-key locks on (…,10],(10,…],…,(20,…]4 5-- session 2 (blocks: insert intention waits on the gap lock)6INSERT INTO orders (id, status) VALUES (15, 'new');7 8-- anywhere: who holds what9SELECT engine_transaction_id, object_name, index_name, lock_type, lock_mode, lock_status, lock_data10FROM performance_schema.data_locks;Key points
- The table is the primary-key B+ tree: rows live in its leaves, PK order is physical order, and there is no heap.
- Secondary indexes store primary keys, not addresses: immune to row movement, but every secondary lookup is two descents and the PK size is paid in every index.
- Monotonic primary keys append and fill pages; random ones (UUIDv4) split everywhere, halve fill, and inflate every index. Prefer bigint or a time-ordered UUID.
- The buffer pool's young/old sublists protect the working set from scans; the change buffer batches secondary-index maintenance for pages not in memory.
- Redo is a fixed ring addressed by LSN with fuzzy checkpoints; the doublewrite buffer, not full-page images, defeats torn pages.
- Old versions live in undo logs reached via DB_ROLL_PTR and are purged in commit order; row, gap and next-key locks implement REPEATABLE READ without phantoms — and cause most InnoDB deadlocks.
InnoDB clustered index
The secondary index on email is its own B+ tree. Its leaf entries are (email → primary key), not (email → page offset): InnoDB rows have no stable physical address, because the clustered tree moves them on split.
When to use — and when not
- This design fits OLTP by primary key: point lookups and range scans on the key are one descent and physically sequential, and update-heavy rows do not bloat the table.
- When the working set fits the buffer pool and keys are short and monotonic — the layout then performs at its best.
- This design fits poorly when most access is through secondary indexes returning many rows (double descents) or when primary keys are random and wide.
- Workloads with very long transactions alongside hot rows: undo chains grow and every reader pays to walk them.
Failure modes
- UUIDv4 primary key: random page splits, 50 % fill, buffer-pool misses on every insert, every secondary index inflated by 32 bytes per entry.
- History list length climbing because a transaction stays open for hours: purge stalls, reads slow down, undo tablespaces grow.
- Redo log too small for the write burst: checkpoint age hits the capacity and every writer stalls until flushing catches up.
- Gap-lock deadlocks under REPEATABLE READ from concurrent inserts into the same range — or a locking scan on an unindexed column locking the whole table.
- Changing a primary key value: the row moves and every secondary index entry is rewritten.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- DSAB+ tree with data in the leaves → InnoDB clustered index — the table itself
- DSALRU cache with two segments → Buffer pool young / old sublists
- Operating SystemsJournaling file system + torn-write protection → Redo log ring + doublewrite buffer