Internals · Enginesinnodbmysqlclustered indexsecondary indexbuffer pool

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.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

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

  2. Naive solution

    Store rows in a heap and add a B+ tree per index whose leaves point at row addresses, like PostgreSQL.

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

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

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

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

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

The table is the primary-key tree

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

Concept → InnoDB implementation → where to see it
General conceptInnoDB structureWhere 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_PTRinformation_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 tableSHOW 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 descentsINNODB_INDEXES, EXPLAIN Extra: Using index for covering
Buffer pool (The Buffer Pool)Young/old LRU sublists, change buffer, adaptive hash index, flush listsSHOW 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_commitSHOW 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 threadsINNODB_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 hashperformance_schema.data_locks, data_lock_waits; LATEST DETECTED DEADLOCK
Torn-page protection (Crash Recovery)Doublewrite buffer, then the home pageInnodb_dblwr_pages_written status counter

The clustered index: the table is a B+ tree

MySQL / InnoDB implementation

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.

A clustered-index leaf page (compact format), 16 KB
+-----------------------------------------------------------------------+
| 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

MySQL / InnoDB implementation

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

MySQL / InnoDB implementation

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

MySQL / InnoDB implementation

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.

Redo ring and checkpoint (SHOW ENGINE INNODB STATUS, LOG section)
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

MySQL / InnoDB implementation

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.

A row updated twice: the version chain runs through undo
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 tail

Transactions and locks: record, gap, next-key

MySQL / InnoDB implementation

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.

Seeing the locks
1-- session 1
2START 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 what
9SELECT engine_transaction_id, object_name, index_name, lock_type, lock_mode, lock_status, lock_data
10FROM 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

InnoDB: the table is the clustered B+ tree
Rows live in the leaves of the primary-key tree. A secondary index stores primary keys, so every secondary lookup is two descents — and the primary key's shape decides whether inserts append or split.
MySQL / InnoDB implementation
secondary index (email) → PK · 2 leaf pages
root: 2 separatorsada→1bo→2carla→3dmitri→4elena→5farid→6
clustered index (PRIMARY) → whole row · 2 leaf pages · 75% full
root: 2 separators1:row2:row3:row4:row5:row6:row
SELECT * FROM users WHERE email = 'elena@x.io'

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.

pages touched (inserts)
0
page splits
0
rows inserted
0
leaf fill
75%
secondary lookup
4 page reads
Monotonic keys append. Every new row is larger than every existing one, so it lands on the rightmost leaf; when that page fills, InnoDB opens a fresh page for the new record instead of splitting 50/50. Pages stay ~100% full, the working set is one hot page, and the tree grows only at its right edge.
Educational simulation — 4 records per leaf and a two-level tree so splits are visible; real InnoDB leaves hold hundreds of rows per 16 KB page.
1/5

When to use — and when not

Use it when
  • 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.
Avoid it when
  • 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.