BOOK DEEP READ · DATABASE INTERNALS · CH 2
Database Internals · Ch 2 · Alex Petrov · 2019
You look up a three-year-old order by its number, and out of billions of records it comes back in a fraction of a second. The database does that with an index — a table of contents that can pin down any entry instantly. This chapter is about the one way of building that table of contents that almost everybody uses: the B-Tree. It was invented in 1972, and fifty years later it is still the default index in MySQL, PostgreSQL, SQLite and Oracle. Why that one?
The search tree you were taught in class asks a yes/no question at every step: bigger or smaller? left or right? In memory that works beautifully. Move the very same tree onto a disk and it becomes unusably slow. The arithmetic of "fast" suddenly comes out wrong.
Here is the catch: a disk does not sell you one byte. Even if you want a single byte, it hauls back a whole block — thousands of bytes at a minimum. The trip is expensive; how much you carry back barely matters.
And a tree that forks two ways has exactly one signpost at each fork. With billions of records you have to ask "left or right?" thirty-odd times in a row, and every question means another trip to the disk. The slowness lives in the number of trips, not the number of comparisons.
Trick one: stop putting a single signpost at each fork. If the disk insists on hauling a whole block anyway, pack that block full of signposts — one sheet listing hundreds of ranges: "orders 10,000–50,000 on that shelf, 50,000–90,000 on the next one." One glance tells you where to go next, the way you find a word by the letter tabs on the side of a dictionary rather than turning pages. The result is a tree squashed flat: thirty-odd levels become three or four. And the top two or three levels are small enough to live in memory permanently, so a lookup usually costs just one real trip to the disk.
Trick two is prettier: this tree grows from the bottom up. New data goes into the bottom page; when that page fills, it splits in two and hands its middle signpost up to the level above; if that level fills, it hands one up too; and only when the very top page can no longer cope does the whole tree gain a level, all at once. Because growth always happens at the top, every leaf sits exactly the same distance from the root — the tree is level by construction and never needs the endless rebalancing other trees do.
Trick three is for flipping pages: the real data lives only on the bottom row, and those bottom pages are joined hand to hand in a chain. So "list the thousand orders after March 1st" means finding the starting point and walking the chain — never climbing back to the top.
Fifty years of dominance come from doing three things at once: finding one record fast, finding a whole run of them fast, and still allowing inserts and deletes while staying in order. A hash table finds one record fast too, but it has no notion of order, so the other two are out of reach.
The honest cost, in one sentence: it prefers data that arrives in order — counters and timestamps keep it happy; a primary key made of random codes gets pages sliced up until they sit half empty, wasting space and making writes more expensive.
A B-Tree is not a cleverer search tree — it is a search tree rebuilt around the disk's temperament. Since the disk can only haul whole blocks, fill a whole block with signposts and fork hundreds of ways at once, and thirty levels collapse into three or four. What you save is not comparisons; it is trips to the disk.
Want the mechanisms, the notation and the diagrams? → Switch to the deep read
The claim of this chapter: a B-Tree is not "a faster search tree" — it is "a search tree rewritten for block devices." What it optimizes is not comparisons but round trips to disk. Blow one node up to a full page, push the fanout from 2 to several hundred, and the height collapses from dozens of levels to three or four; then let it grow upward from the leaves, and it comes out perfectly balanced with no rotations at all. That is why it has been the default disk index for fifty years.
8 KB, InnoDB to 16 KB.Chapter 2 of Part I, "Storage Engines." It follows Ch1's three dials — a B-Tree is the classic occupant of the "buffered + updated in place + key-ordered" corner — and it sets up Ch3 (how bytes are laid out inside a page), Ch4 (splits, merges and concurrency in practice), Ch6 (variants) and Ch7 (its opposite number, the LSM-Tree). In the real world it maps onto the default index of essentially every disk-based OLTP database.
Anchor it in something concrete: a 2-billion-row orders table, point lookups by order number at a p99 (the 99th slowest request in a hundred) under 10 ms, holding 50k QPS. A full scan is out. Keeping it all in memory is out too — at 200 bytes a row that is 400 GB. So the index has to live on disk, and disk does not bill you per comparison; it bills you per random I/O: roughly 10 ms on a spinning disk (about 100 IOPS per drive), 50–100 µs on an NVMe SSD.
What happens if you just move a textbook balanced binary tree onto that disk? Two billion rows means about log₂(2×10⁹) ≈ 31 levels — up to 31 random I/Os: 0.3 seconds on a spinning disk, still 2–3 milliseconds on SSD, and one query eating thirty IOPS of budget. So the task is: convert an in-memory search structure into one that is billed in page reads.
Petrov's reasons are blunt, and there are four:
log₂ N — and the unit of height is not "levels," it is "trips to disk."Hence the hook of the chapter: you think a search structure competes on how many questions it asks; on disk it competes on how many trips it makes.
Disks do not sell single bytes. Spinning disks read and write in sectors (512 bytes or 4 KB), the filesystem in pages (usually 4 KB), and the database layers its own page on top (4–16 KB). Reading one byte and reading a whole page cost about the same — the bill is the trip. SSDs go further: they cannot overwrite in place, their smallest erase unit is a block larger than a page, and an internal FTL (flash translation layer, mapping logical addresses onto real flash locations) shuffles things around.
One conclusion follows, and it dictates the whole chapter: the objective function changes from "fewest comparisons" to "fewest page reads." So a node should be exactly one page — if the trip is unavoidable, make everything it carries back useful. To size it: PostgreSQL's page is 8 KB, and at roughly "16-byte key + 8-byte pointer + 8 bytes of slot overhead ≈ 32 bytes" per index entry, one page holds about 250 signposts. Three levels then cover 15.6 million entries and four levels cover 3.9 billion — those 2 billion rows fit in four.
B-Trees came from Bayer and McCreight in 1972; Comer's 1979 survey The Ubiquitous B-Tree made them the textbook standard. A B-Tree generalizes the binary search tree: a node no longer holds one key and forks two ways — it holds a sorted run of keys and forks many ways. Three roles: the root (the single entry point), internal nodes (signposts only) and leaf nodes (the data, or pointers to it).
The structural fact worth memorizing: an internal node with n separator keys has n+1 pointers — n signposts carve the key space into n+1 ranges. Keys 50 | 90 carve out "<50", "50–90" and "≥90": three children. A lookup then starts at the root, binary-searches those few hundred separator keys inside the page (pure memory work, essentially free), picks a range, follows the pointer to the next page, and repeats down to a leaf. Total cost is log_k N page reads, where k is the fanout and N the entry count. Do not let the notation intimidate: it says one thing only — bigger fanout, shorter tree, fewer trips.
One more quantity people skip: occupancy. B-Tree pages are deliberately not full; the slack lets future inserts land in place instead of forcing an immediate split, and the classic invariant "every node but the root is at least half full" is exactly what keeps the height from degenerating. The price is space for time: an index is inherently puffier than the data it points at.
A strict, original B-Tree happily stores data inside internal nodes. Nearly every real database uses the B+-Tree variant instead, with two changes:
Petrov says as much outright: since the differences are largely implementation detail, "B-Tree" in industry usage generally means this family. Being able to say "data in the leaves, leaves chained" is worth more in an interview than reciting the definition.
Insert: walk down to the target leaf exactly as a lookup would and slot the new entry into key order. If it fits, the whole insert is one page rewrite. If it does not (overflow), the page splits: it is cut in half and the middle key is promoted to the parent as a new separator. If the parent is now full too, it splits and promotes in turn. Only when the root itself cannot cope does the root split into a new root — and in that instant the whole tree gains a level.
That is the elegant part: it only ever grows taller at the root, yet the force driving that growth comes from the leaves. Because growth is a whole-tree event, every leaf stays exactly as far from the root as every other — perfectly balanced by construction, with no rotations whatsoever. The red-black trick of rebalancing locally simply does not exist here, and that is the second reason it suits disks.
Delete: walk to the leaf and remove the entry. If occupancy drops below the threshold (underflow), the page either borrows entries from a sibling (redistribution) or merges with one — the latter costs the parent a separator key and can cascade upward; when the root is left with a single child, the tree loses a level. One footnote from reality, though: many real engines defer or skip merging altogether (it locks several pages at once and is expensive under concurrency, while the payoff is only some space), preferring to let pages sit sparse and reclaim them with background maintenance. "The textbook says at least half full" and "your production index actually is half full" are two different statements.
Table 1 · The same data, two structures, billed on disk (2 billion rows, 8 KB pages)
| Balanced binary search tree | B-Tree (paged, multi-way) | |
|---|---|---|
| Node size | a few dozen bytes, far below one page | exactly one page (4–16 KB) |
| Fanout | 2 | 200–500 (shorter keys, bigger fanout) |
| Height | log₂ N ≈ 31 levels | log_k N ≈ 4 levels |
| Page reads per lookup | up to 31, nearly all random I/O | ≤ 4; upper levels stay cached → usually 1 real read |
| Staying balanced | rotations → several page rewrites each time | no rotations; splits and merges, height changes only at the root |
| What it costs | simple, and optimal in memory | slack in every page (space for time); a split writes several pages |
Table 2 · B-Tree vs B+-Tree: why industry went one way
| Original B-Tree | B+-Tree (what you actually run) | |
|---|---|---|
| Where data lives | internal nodes may hold it too | leaves only |
| Internal fanout | smaller — entries carry data | larger → shorter tree, internal level easy to cache whole |
| Point lookup | may hit early; depth varies | always reaches a leaf → uniform cost, predictable latency |
| Range scan | in-order traversal, repeatedly back up to the parent | leaf chain → a linear walk |
| What it costs | — | no early exit on point lookups; sibling pointers need maintaining |
Table 3 · Choosing a page size, anchored on real defaults
| Page size | Upside | Cost | Who ships it |
|---|---|---|---|
4 KB | aligned with the OS page; least useless data per I/O | smaller fanout → taller tree; low ceiling on entry size | SQLite default |
8 KB | the usual compromise between height and waste | — | PostgreSQL default (BLCKSZ) |
16 KB | bigger fanout, shorter tree; friendly to sequential scans | changing one row still writes 16 KB → heavier write amplification; more page-level contention | MySQL InnoDB default |
32–64 KB | highest scan throughput | wasteful for point lookups; worst contention | analytics-leaning, large sequential reads |
The split point is a trade-off too, and a counterintuitive one: it should not always be down the middle. If the primary key is an auto-increment or a timestamp, every insert lands on the rightmost page; splitting 50/50 there leaves the left half stuck at 50% forever, with nobody left to fill it — the index puffs up to twice its necessary size. PostgreSQL's answer is to split the rightmost page lopsidedly, leaving the left page fillfactor full (90 by default for B-Trees). Conversely, a random key like a UUID spreads inserts evenly across every page, so splits happen everywhere and pages settle near half full — that is the mechanism behind "don't use a UUID as your clustered primary key."
When should you not pick a B-Tree? When writes far outnumber reads, keys are random, and space matters — updating in place means "change one row, write back a whole page," and random keys leave pages half empty. That is the home turf of log-structured storage (LSM-Trees, Ch7): it batches random writes into sequential ones, at the cost of searching several levels on read and compacting in the background.
The roll call alone shows how universal it is: PostgreSQL's nbtree, InnoDB's clustered index (the primary key is a B+-Tree, and secondary index leaves store primary key values), SQLite, Oracle, SQL Server, MongoDB's WiredTiger, and bbolt underneath etcd — all B-Trees or B+-Trees. Understanding this chapter hands you a general-purpose wrench: why indexes aren't hash tables (hash point lookups are fast too, but hashes have no order, so range scans and ORDER BY are out); why an index scan costs so little in EXPLAIN (three or four levels, upper ones all in the buffer pool); and why DBAs keep insisting your primary key be monotonically increasing.
fillfactor = 90. Direct confirmation that sequential keys want a lopsided split.postgres/…/nbtsplitloc.c ↗1172 GB on InnoDB and only 574 GB on LSM-based MyRocks, with write volume falling from 152 MB/s to 67 MB/s. That is the production-scale price of occupancy and update-in-place.Facebook mysql-5.6 wiki, "MyRocks advantages over InnoDB" ↗O(log N) means fast. The real cost is page reads and cache hits, not comparisons. A four-level tree with three levels resident in the buffer pool usually costs one physical I/O per point lookup — and that "one in practice" is the actual selling point.① A B-Tree is not a faster search tree; it is a search tree rewritten for block devices — it optimizes round trips to disk, not comparisons.
② Binary trees die on disk: low fanout, nodes far smaller than a page (8 KB hauled for 32 bytes), poor locality (a random I/O per level), and rotations that rewrite several pages.
③ The core move: node size = page size, the page packed with separator keys. Fanout goes from 2 to 200–500 and the height for 2 billion rows collapses from 31 levels to 4.
④ Structure to memorize: an internal node with n separator keys has n+1 pointers; internal nodes are signposts, leaves hold data; binary search inside the page picks the range.
⑤ "B-Tree" in practice means B+-Tree: data only in leaves, leaves chained by sibling pointers — hence uniform point-lookup cost and linear range scans.
⑥ Inserts split and promote the middle key, possibly up to the root — the tree only grows taller at the root, so it is balanced by construction and never rotates; deletes borrow or merge, though many engines defer merging to avoid the concurrency cost.
⑦ Trade-offs: page size (height versus write amplification and contention); and the split point should not always be 50/50 — sequential keys want a lopsided split (PostgreSQL's fillfactor = 90), while random keys (UUIDs) leave pages half full and the index bloated.
⑧ In real systems: PostgreSQL nbtree (a Lehman-Yao B-link tree), InnoDB's clustered index, SQLite, WiredTiger, bbolt; the counter-case is Facebook's MyRocks, where switching to LSM took the same data from 1172 GB to 574 GB. Traps: "B" is not binary, and O(log N) is not the same as fast (in practice it is often a single physical I/O).