BOOK DEEP READ · Database Internals · Chapter 4
Database Internals · Ch 4 · Alex Petrov · 2019
From the last two chapters you know that a database finds data through a kind of directory tree, and that each node of that tree is one page on disk. But all of that was the static picture. This chapter asks the moving question: this ledger takes in millions of new records a day and loses millions of old ones, all while people are reading it — how does it grow, shrink and stay in order at the same time?
Picture a row of filing cabinet drawers. Each drawer holds cards in alphabetical order, and each has a label on the front: "A–F", "G–M".
The textbook stops right here: when a drawer fills up, split it into two, then add a line to the master index. Two sentences. Anyone who has actually run such a cabinet knows the trouble is entirely in what those two sentences leave out.
Four things, none of them in the textbook.
One: after splitting, you have to tell the level above. But you are holding one drawer — how do you know which line of the master index carries its label? You could write "I belong to drawer 12" on the back of every card, but then splitting a drawer means rewriting the back of hundreds of cards.
Two: splitting too often. Halve a drawer the moment it fills and each half is only half full — so the cabinet is permanently running at half capacity while you buy twice as many cabinets.
Three: when the numbers only go up, halving is pure waste. New cards get ever-larger numbers, so they always go into the last drawer. Split that full drawer down the middle and nobody will ever put anything in the front half again. It sits half empty forever.
Four: what you remove doesn't come back. Pulling a card just leaves a gap. The drawer doesn't shrink, and the cabinet doesn't go away.
This explains two things you have probably watched happen. An auto-incrementing primary key is far faster and far smaller than a random one — sequential inserts pack every drawer nearly full, while random inserts scatter cards everywhere and trigger splits all over. And loading a hundred million rows into an empty table goes much faster if you load the data first and build the index last — that's "pack the drawers" instead of "insert one card at a time".
The honest cost: the fuller you pack a drawer, the sooner an insert in the middle forces a split — which is why serious databases deliberately leave about a tenth of each page empty, and accept the extra cabinets.
"When a drawer fills, split it" is the easiest sentence in the chapter. The real craft is everywhere else: breadcrumbs to find the level above, borrow from a neighbour to split less, never halve a sequence, and pack whole drawers instead of inserting one at a time.
Want the breadcrumb stack under concurrency, how much space a right-leaning split saves, and the real defaults in PostgreSQL and InnoDB? → Switch to the deep read
You think this chapter is about "the split and merge algorithm". In fact those two lines of pseudocode are the easiest part of it. The engineering is everywhere else: how you find the parent (breadcrumbs, not parent pointers), how you split less often (borrow from a sibling), how you stop sequential keys from wasting half your space (right-leaning splits), and how you get deleted space back (vacuum). This chapter stitches the tree shape of Chapter 2 to the page bytes of Chapter 3 into something that runs in production for ten years without breaking or bloating — it is the entire distance between the textbook B-Tree and the one inside PostgreSQL or InnoDB.
This is Chapter 4 of Part I, "Storage Engines" — and the most engineering-flavoured chapter in that part. It follows Chapters 2 and 3: Chapter 2 argued why the B-Tree is the default answer on disk (high fan-out flattens the tree), and Chapter 3 gave the byte layout of a page (slotted pages, cells, the availability list). It sets up Chapter 5, "Transaction Processing and Recovery", where every split, merge and in-page compaction here needs a WAL behind it before it can claim correctness across a crash. In the real world: PostgreSQL's nbtree, InnoDB's clustered index, SQLite's btree.c — every mechanism in this chapter has an implementation, and a distinct set of trade-offs, in those three codebases.
Carry the textbook B-Tree straight into production and you hit four walls.
Wall one: splits propagate upward, but a node has no idea who its parent is. A full leaf splits in two, so the parent must take a new separator key; the parent may be full too, so it continues upward, at worst all the way to the root — and when the root splits, the tree grows a level. But a page holds only keys and child pointers, with no route back to the parent. The naive fix is to store a parent pointer, and the cost shows up immediately: when a node splits, every one of its children needs its parent pointer rewritten — a node with fan-out 500 means 500 extra page updates, all of which must go through the WAL.
Wall two: halving splits make you buy twice the hardware. Split 50/50 on overflow and both new pages are half full; under random inserts steady-state occupancy hovers around 50%–70%. To put a number on it: a 100-million-row orders table, primary-key index at 16 bytes per entry, 8 KiB pages with fan-out about 500, comes to roughly 2 GB packed — and 4 GB at half occupancy. Those extra 2 GB don't just take disk; they crowd the page cache by exactly as much. An index that would have fit in memory no longer does, hit rate drops, and latency follows.
Wall three: sequential keys turn that waste into a dead loss. With an auto-increment or timestamp primary key, every insert lands on the rightmost leaf. Halving is especially foolish there: the left half can never receive another key, so it stays half empty forever. Occupancy is pinned at 50% when it could have been close to 100%.
Wall four: the holes left by deletes and updates don't disappear. A delete is only a mark plus an entry on the availability list (Chapter 3); a variable-length field grown by an UPDATE no longer fits in place, so it moves and leaves a hole behind. Once holes accumulate, a page's live payload may be down to thirty percent while it still occupies 8 KiB of disk, 8 KiB of cache, and a full-page read every time.
Table 1 · The textbook B-Tree vs what a production implementation must add
| Problem | What the textbook says | What a real implementation adds |
|---|---|---|
| Finding the parent | "Insert the separator into the parent" — the parent is assumed known | Breadcrumb stack: push the path on the way down, pop it when you need it |
| Node is full | Split it in half, immediately | Borrow from a sibling to defer the split; go right-leaning for sequential keys |
| Node is too empty | Merge with a sibling | Merging is expensive — PostgreSQL simply reclaims only fully empty pages |
| Building a whole tree | — (not covered) | Bulk loading: pre-sort, build bottom-up, zero splits |
| Concurrency | — (not covered) | Sibling links + high keys (Blink-tree) so readers need no locks |
Beyond what Chapter 3 covered (magic number, cell count, free-space boundaries), a B-Tree page header must record the page's identity within the tree: leaf or internal. That decides how the cells are read — "key + value" in a leaf, "separator key + child page ID" in an internal node.
Then comes a detail that is easy to skip past and yet forces two schools of thought: an internal node has N separator keys but N+1 child pointers. Package "key + pointer" into one cell and the last pointer has no key to pair with — it owns the range above every separator. Two ways out:
nbtree.Sibling links are the header's other job: adjacent pages on a level point at each other. The benefit is concrete — a range scan (WHERE id BETWEEN 1000 AND 9000) sweeps along the leaf chain instead of climbing back to the parent after every page. The cost is equally concrete: every split, merge and page deletion must also fix up the neighbours, and under concurrency those pages sit behind different latches, so a careless ordering is a deadlock.
Having found the page, you still have to locate the key inside it. As Chapter 3 showed, in a slotted page the cells lie in arbitrary physical order, but the slot directory (the offset array) is sorted by key — so the binary search runs over that small, contiguous offset array: take the middle slot, follow its offset to read the key, compare, halve the range. An 8 KiB page holding 500 keys is done in log₂500 ≈ 9 steps, all within that one page, with no extra I/O at all.
One small design point deserves naming: "not found" is not a sufficient answer. An insert needs to know where to put the key; a range scan needs to know where to start sweeping. So implementations return the insertion point, commonly folding both results into a single value with a negative encoding such as -(insertion point) - 1: a non-negative result is the index of a hit, a negative one converts to the insertion position. One binary search, three kinds of caller.
Here is the chapter's first genuine engineering decision. After a leaf splits, the parent takes a new separator key; the parent may be full, so it continues upward; reach the root and the root splits, and the tree grows a level — the only moment a B-Tree ever gets taller, which is why it stays perfectly balanced for free. Only one question remains: how do you find the parent?
The intuitive answer is a parent pointer in every node. Petrov rules it out on two counts. Write amplification: when a node splits, the parent pointer of every child moved into the new node is invalidated, so a node with fan-out 500 means hundreds of extra page updates and hundreds of WAL records. Concurrent inconsistency: those pointers are scattered across hundreds of pages and cannot be made atomic with the split itself.
The real answer is breadcrumbs. A search already walks down from the root, so on the way down you push "which page I visited, which slot I took" onto a stack; when you need to propagate upward, popping the stack gives you the parent. The elegance is that the information is free — you had to walk that path to reach the leaf anyway.
One detail must still be handled: breadcrumbs go stale. Under concurrency, a parent recorded on your stack may itself have split while you were descending, so your separator key no longer belongs to it. Here high keys and sibling links save the day a second time: read the stacked page back, find the key above its high key, and step one page right along the sibling link — no need to restart from the root, and no need to hold locks the whole way down.
The second engineering decision targets occupancy. Since a split is both expensive (three pages plus WAL) and wasteful of space (two half-full pages), the move is to avoid it where you can: when a node fills, look at its siblings first and shift a few boundary keys to whichever one has room, updating the separator key in the parent. This is rebalancing.
That is the B*-tree idea: only when a node and its sibling are both full do you turn 2 nodes into 3. Do the arithmetic: an ordinary B-Tree guarantees each node is at least half full after a split, so minimum occupancy is 50%; splitting 2 into 3 leaves each node at least 2/3 full, raising the minimum to about 66%. For the same data, the index is a quarter smaller and the page cache holds a quarter more of it.
The cost is not subtle: one operation now touches two or three pages (self, sibling, parent), which under concurrency means holding two or three latches — the acquisition order has to be globally consistent or you deadlock — and write amplification goes from "one page" to "three". It is a trade of write-path complexity and latency spikes for space and cache hit rate, and not every engine takes it. The same logic runs in reverse for merges: attractive on paper, but they too touch several pages and can thrash against splits (merge, refill, split again) — which is precisely why PostgreSQL declines to merge half-empty pages at all.
This is the section closest to daily life. Your primary key is most likely an auto-increment ID, a timestamp or a snowflake ID — all monotonically increasing, so every insert lands on the rightmost leaf. Generic logic loses on every count here, which is why implementations open special lanes.
Lane one: right-leaning splits. When the rightmost leaf fills, don't cut down the middle — keep almost every key where it is and move only the last handful into the new page. The reasoning is airtight: the left half can never receive another key (new keys are only larger), so halving locks half that space away permanently. InnoDB's real occupancy figures bear the bill out — the gap between sequential and random inserts is nearly a factor of two; the official numbers are in the evidence box below.
Lane two: cache the rightmost leaf. If inserts always land on the same page, don't walk from the root every time. PostgreSQL's nbtree caches the leaf the last insert hit, and if it is still the rightmost leaf, still has room, and the new key exceeds the page's first key, it inserts straight into it — a path called the fastpath. It ships with its own brake: every writer then contends for an exclusive lock on that same page, so if the lock isn't available the code falls back to the ordinary path.
Lane three: bulk loading. When the data is already sorted (an import, an index rebuild, the output of an LSM compaction), inserting one row at a time is pure waste. The right move is to build bottom-up: pack the sorted data page by page into a leaf level, take the first key of each leaf to form the level above, and repeat until one root remains. Zero splits, zero searches, pages packed to nearly 100%, and purely sequential writes. MySQL calls this a sorted index build and takes that path by default when creating or rebuilding an index. It is also why the classic advice holds: when loading an empty table, load the data first and build the indexes last.
Compression saves disk and I/O at the cost of decompressing on every read. Petrov flattens the choice to one question: at what granularity?
n at n × page size, and compressing the file destroys that premise. Essentially out.There is also a saving that doesn't touch the layout at all: deduplication. PostgreSQL 13's B-Tree deduplication is the model — when one key value repeats many times (indexing a low-cardinality status column, say), the duplicates are merged into a single "key + list of row pointers" posting list with the key stored once, saving space and incidentally deferring leaf page splits.
Finally, maintenance. Two sources produce dead space continuously: deletes (a mark plus the availability list, Chapter 3) and updates (a variable-length value grown too large to fit in place has to move, leaving a hole). The result is that live payload per page keeps falling while the page count does not — disk, page cache and full-page reads all cost exactly as much as before. The response has two layers: within a page, repack the live cells together (at the cost of rewriting the page); across pages, unlink fully empty pages into a global free-page list for later splits to reuse — note that the latter usually does not shrink the file, it only makes the space reusable.
One real-world divergence is worth knowing. PostgreSQL reclaims only pages that have become completely empty — a page down to its last few keys stays allocated, so a pattern where "most keys in a range are eventually deleted" wastes space indefinitely. InnoDB takes the other road: once occupancy falls below MERGE_THRESHOLD it merges with a neighbouring page, at the cost of thrashing between merge and split when both pages sit near the threshold. One problem, two trade-offs, and both vendors write the cost down honestly in their own documentation (quoted in the evidence box below).
Table 2 · How to find the parent node
| Parent pointers | Breadcrumb stack (mainstream) | |
|---|---|---|
| Mechanism | Each node stores the page ID of its parent | The descending search pushes "page + slot"; pop when needed |
| Extra cost | On a split, hundreds of moved children need their pointer rewritten, each with its own WAL record | Zero — the search had to walk that path anyway |
| Concurrency | Pointers scattered over hundreds of pages cannot be made atomic with the split | The stack may be stale, but high key + sibling link fixes it with one step right |
| Fits | Read-only or near-read-only trees | Essentially every production B-Tree |
Table 3 · What to do with a full node: four split strategies
| Strategy | Mechanism | Occupancy | Cost | When to use it |
|---|---|---|---|---|
| Halving split | Cut 50/50 when full | Steady state 50–70% under random load | Under sequential keys the left half is half empty forever | The default fallback; fine when keys really are random |
| Borrow from a sibling (B*-tree) | Shift to a neighbour first; split 2 into 3 only when both are full | Minimum rises from 50% to ≈ 66% | Touches 2–3 pages, more latches, higher write amplification and deadlock risk | Space or cache is tight and you'll pay write-path complexity for it |
| Right-leaning split | Move only the last few keys into the new page | InnoDB, sequential inserts: ≈ 15/16 (94%) | Misjudge the workload (keys are actually random) and the reserve is wasted | Auto-increment IDs, timestamps, snowflake IDs — most OLTP primary keys |
| Bulk loading | Pre-sorted input, built bottom-up in one pass | Up to nearly 100% (minus fill factor) | Requires sorted input; no concurrent writes during the build | Initial import, REINDEX, LSM compaction output |
Table 4 · Reserve and reclamation: the real defaults
| PostgreSQL (nbtree) | MySQL / InnoDB | |
|---|---|---|
| Reserve at build time | fillfactor defaults to 90 (leaves filled to 90%, and likewise when extending at the right) | innodb_fill_factor for sorted index builds; clustered-index inserts reserve 1/16 by default |
| Occupancy under sequential inserts | A fastpath caches the rightmost leaf to avoid walking from the root | Official figure: ≈ 15/16 full; random inserts only 1/2 – 15/16 |
| Handling near-empty pages | Reclaims only fully empty pages; a page down to a few keys stays allocated | Below MERGE_THRESHOLD (default 50) it attempts a merge with a neighbour |
| What that choice costs | "Most keys in a range eventually deleted" wastes space badly; needs REINDEX | Documented warning: with both pages near 50%, merge-then-split thrashing hurts performance |
| Extra space saving | Deduplication (PG 13+, on by default): duplicates become a posting list, also deferring splits | Page-level compression (ROW_FORMAT=COMPRESSED and friends) |
You can justify a primary key choice. "Why is a UUID v4 primary key slow" is not folklore: random keys spread inserts evenly over every leaf, so each one has to be read into the page cache, dirtied and written back, with splits everywhere. Switch to a time-ordered ID (auto-increment, UUID v7, snowflake) and inserts concentrate on the rightmost page, taking the right-leaning split and the fastpath — roughly halving index size and roughly doubling how much of the index fits in cache. That conclusion has vendor documentation behind it.
You load data the right way. A hundred million rows into an empty table: maintaining five indexes as you go means a hundred million rounds of "walk root to leaf, maybe split, write WAL". Load the data first and run CREATE INDEX last and you get sort plus bottom-up build — sequential I/O, zero splits, pages packed nearly full.
You know where to look when debugging. "The table is 3 GB, the index is 12 GB, and VACUUM didn't shrink it at all" is almost always section 7 above; getting the space back means REINDEX. Conversely, periodic write-latency spikes on InnoDB should send you straight to merge-split thrashing around MERGE_THRESHOLD.
nbtree README states outright that the implementation follows Lehman & Yao's high-concurrency B-tree algorithm, adding a "left sibling" link of its own to support backward scans. For monotonically increasing keys it caches the leaf the last insert hit and takes a fastpath — but because testing showed this increases contention on the exclusive lock of the rightmost leaf, it only conditionally attempts the lock and abandons the fastpath when it isn't available. PostgreSQL source, src/backend/access/nbtree/README ↗fillfactor defaults to 90 — 100 suits a static table, while a heavily updated one wants it lower to reduce page splits. PostgreSQL docs, "CREATE INDEX" ↗ Since PG 13 it also enables B-tree deduplication by default: duplicates become a "key + sorted TID array" posting list, which the docs say significantly reduces index storage and, being lazy, prevents or at least delays leaf page splits. PostgreSQL docs, "B-Tree Implementation · Deduplication" ↗MERGE_THRESHOLD defaults to 50 (the previously hardcoded value; settable from 1 to 50), and a delete or shortening update that drops a page below it triggers an attempted merge with a neighbour. The docs add plainly that "if both pages are close to 50% full, a page split can occur soon after the pages are merged. If this merge-split behavior occurs frequently, it can have an adverse affect on performance." MySQL docs, "Configuring the Merge Threshold for Index Pages" ↗innodb_fill_factor reserving room for growth (a value the docs explicitly describe as a hint rather than a hard limit). MySQL docs, "Sorted Index Builds" ↗50%–70% for years, meaning a third to a half of the disk and memory you bought for the index is holding air. Most of this chapter (rebalancing, right-leaning splits, bulk loading, deduplication) is a fight with that number.REINDEX / VACUUM FULL, and the cost is a rebuild.1. In a sentence: the textbook's "split when full" is the easiest part of the chapter; the real engineering is everywhere else — finding the parent, splitting less, not wasting space on sequential keys, and getting deleted space back.
2. N separator keys against N+1 child pointers: the odd one goes into the header (rightmost pointer, SQLite) or is balanced by a high key (PostgreSQL). And high key + sibling links = Lehman & Yao's Blink-tree: a reader detects a concurrent split by seeing its key exceed the high key, steps one page right, and needs essentially no read locks. Sibling links also spare range scans the climb back to the parent, at the cost of fixing up neighbours on every split and merge.
3. In-page binary search runs over the sorted offset array (about 500 keys in an 8 KiB page, log₂500 ≈ 9 steps) and must return an insertion point, not merely "not found".
4. Breadcrumb stacks replace parent pointers: record the path while descending, pop it when propagating a split. Parent pointers lose because one split rewrites hundreds of children's pointers and cannot be made atomic with the split.
5. Borrowing from a sibling (B*-tree) raises minimum occupancy from 50% to about 2/3, at the cost of touching 2–3 pages and holding more latches. Merging has the same shape in reverse, which is why PostgreSQL declines to merge half-empty pages at all.
6. Sequential keys deserve their own lane: right-leaning splits (InnoDB reaches ≈ 15/16 full on sequential inserts against just 1/2–15/16 on random ones) plus caching the rightmost leaf (PostgreSQL's fastpath, which backs off to the normal path under lock contention).
7. Bulk loading: with sorted input, build bottom-up — zero splits, zero searches, sequential writes, pages packed nearly full. That is why "load the data, then build the index" wins so clearly (MySQL's sorted index build).
8. Compression has exactly one question, granularity: compress the file and pages stop being addressable, compress per cell and there's nothing to squeeze, so per page is the mainstream compromise; PG 13+ deduplication is a further saving that doesn't touch the layout. Maintenance is the long-term bill: deletes and variable-length updates create dead space continuously, handled by in-page compaction plus page reclamation — but the file usually doesn't shrink. PostgreSQL reclaims only fully empty pages (so REINDEX is what actually recovers the space), InnoDB merges below MERGE_THRESHOLD (at the cost of merge-split thrashing).