System Design Storage
B-Trees and Variants
Checkout at 9:41 PM asks for one order by id. The database must find that row among tens of millions without scanning the table. A turns that into a few page reads: walk separators at the root, descend, land on a , and follow a row locator. That shallow path is why relational engines and many file systems still center indexes on B-tree family structures.
This note goes deeper than a one-line “indexes are B+ trees.” You will size , count levels, watch a , compare B / B+ / B* shapes, and name the write and latch costs that show up under mixed OLTP load.
Order lookup journey
Root page almost always sits in the buffer pool.
Each internal page picks one of hundreds of children.
Binary search the sorted entries; return the locator.
Fetch the row
Heap or clustered leaf yields the columns the query needs.
What actually happens
Disk and SSD transfer data in fixed-size pages (often 4–16 KiB). A packs many keys into each page so one I/O advances you through a large key range. That is the whole design thesis: minimize random page reads per operation by maximizing and keeping the tree height small and balanced after every insert and delete.
Production OLTP almost always uses a : internal nodes are pure routers; all payloads sit in leaves; sibling pointers make ORDER BY … LIMIT and range predicates cheap. A may make those leaves the table itself. Secondary indexes are usually more B+ trees whose leaves store the primary key (or a heap TID) as the locator.
Writes are the tax. Inserting into a full leaf triggers a , which dirties at least two leaves and a parent, and may cascade. Each change also appends to the WAL before pages can be considered durable. A warm hides most of this from the client; a cold cache or a rightmost-leaf hotspot makes it visible as latency spikes.
Anatomy: pages, separators, and leaves
Page size P ≈ 8 KiB (example)
Internal entry ≈ key_len + child_ptr (e.g. 16 B key + 8 B ptr = 24 B)
Fanout F ≈ (P - page_header) / entry_size
≈ (8192 - 96) / 24 ≈ 337 children per internal page
Leaf entry ≈ key + value_or_locator (+ optional version bits)
Leaf capacity L ≈ (P - header) / leaf_entry_size
Height h for N keys (full tree, rough):
N ≤ L × F^(h-1)
h ≈ 1 + log_F(N / L)
With F≈300, L≈200, N=100M:
F^2 = 90,000 → not enough leaves
F^3 = 27M → still short of 100M/200 = 500K leaves needed wait:
leaves needed ≈ N/L = 100e6/200 = 500,000
F^2 = 90,000; F^3 ≈ 27M → height 3 internals+leaf ≈ 4 page reads coldReal trees run below 100% fill (often ~70% after churn), and variable-length keys change F. The interview-ready intuition is still: fanout in the hundreds keeps height at 3–4 for huge tables, so a point lookup is a handful of page visits, most of which hit the buffer pool.
Read the diagram definition
flowchart TD
Q["Lookup key K"] --> R["Read root page"]
R --> I{"Internal page?"}
I -->|Yes| C["Binary search separators\npick child pointer"]
C --> R2["Read child page"]
R2 --> I
I -->|Leaf| L["Binary search leaf entries"]
L --> H{"Key found?"}
H -->|Yes| V["Return value / row locator"]
H -->|No| M["Miss"]Sizing the height (worked example)
| N keys | Leaf capacity L | Fanout F | Typical height | Cold I/Os (point get) |
|---|---|---|---|---|
| 1M | 200 | 300 | 3 | ≤3 |
| 100M | 200 | 300 | 3–4 | ≤4 |
| 1B | 150 | 250 | 4 | ≤4 |
| 1B (16 B keys, tight) | 80 | 120 | 4–5 | ≤5 |
Memory for upper levels (often fully cache-resident):
Internal pages ≈ ceil(leaves / F) + ceil(that / F) + …
For 500K leaves, F=300:
Level above leaves ≈ 500K/300 ≈ 1,667 pages ≈ 13 MiB @ 8 KiB
Next level ≈ 6 pages ≈ 48 KiB
Root = 1 page
So only ~13 MiB of internal pages cover 100M keys.
The buffer-pool fight is mostly about hot leaves and table pages,
not about keeping the root warm.Raise fanout
Shorter keys, larger pages, prefix compression.
Lower fill factor
Fewer splits on hot leaves; more space and height risk.
Cache the path
Upper levels stay hot; size pool for working-set leaves.
Inserts, splits, and height growth
An insert finds the target leaf the same way as a lookup, then places the key in sorted order. If the leaf has free space, only that page (and the WAL) change. If not, the engine performs a : allocate a new page, move about half the entries, and insert a separator key into the parent. If the parent is full, the cascade continues. A root split is the only way the tree grows taller.
Read the diagram definition
flowchart TD
Ins["Insert into full leaf"] --> Alloc["Allocate sibling page"]
Alloc --> Move["Move ~half entries to sibling"]
Move --> Sep["Promote separator to parent"]
Sep --> Full{"Parent full?"}
Full -->|No| Done["Done"]
Full -->|Yes| Cascade["Split parent similarly"]
Cascade --> Root{"Was root?"}
Root -->|Yes| Grow["New root → height +1"]
Root -->|No| FullRightmost-leaf hotspot (SERIAL / BIGSERIAL primary keys):
Every insert lands on the same hot leaf → frequent splits
Contention: exclusive latches on that leaf and its ancestors
Mitigations: UUIDv7 / time-ordered keys with jitter, reverse keys,
hash partitioning, or a non-clustered heap + secondary PK
Deletes:
Mark slot free or compact page; underflow may merge or borrow
Engines differ: some defer merge aggressively to cut latch work
Empty pages return to free space map; height rarely shrinks soonThat split cascade is a major source of B-tree under random inserts. LSM engines defer reorganization into compaction; B-trees pay some reorganization on the write path itself so the on-disk shape stays immediately searchable.
B-tree vs B+ tree vs B*
| Variant | Where records live | Range scan | Notes |
|---|---|---|---|
| B-tree | Can store values in internal nodes | May need in-order walk of internals | Classic textbook form; rare as primary OLTP leaf layout |
| B+ tree | All records in leaves; internals route only | Walk linked leaves | Postgres, InnoDB, most SQL indexes |
| B* tree | Usually B+-style leaves | Same as B+ | ; higher occupancy |
Interview shorthand: say “B+ tree” for relational indexes unless you are specifically discussing internal-node payloads. Mention B* when discussing occupancy optimizations, not as a different product category.
Clustered vs secondary indexes
In InnoDB-style layouts, the primary key leaf pages are the table. A secondary index leaf stores (secondary_key → primary_key). Looking up by email may need two tree walks: secondary leaf, then primary leaf. Covering indexes avoid the second walk when every needed column is already in the secondary leaf (or INCLUDE list).
Heap-organized tables (classic PostgreSQL heap + indexes) store rows in heap pages; every index leaf stores a TID. Updates that move a row may leave index entries pointing at redirecting tuples until cleanup. Either way, “one logical row write” can touch many B+ trees — that is index from the application’s point of view.
Example: users(id PK, email UNIQUE, status, created_at)
Indexes: PK clustered, UNIQUE(email), INDEX(status, created_at)
INSERT one user:
+ WAL records
+ clustered leaf insert (possible split)
+ email unique leaf insert (possible split)
+ status composite leaf insert (possible split)
Rule of thumb: every secondary index must earn its keep on hot read paths.Optimizations engines actually ship
Prefix compression and key truncation
stores a shared prefix once per page so more keys fit, raising effective fanout. Some engines also truncate separator keys in internal nodes to the shortest distinguishing prefix.
Fill factor and page free space
Leaving 10–30% free space on leaf pages absorbs updates and short bursts of inserts without immediate splits. Too much free space wastes cache and disk; too little causes split storms on hot ranges.
Latches, locks, and optimistic walks
Page latches protect physical structure; row locks protect transactions. Optimistic descent (read latches, retry if a split raced you) is common so readers do not hold exclusive locks up the tree. Rightmost-leaf contention still shows up as latch wait time in APM dashboards.
Bulk load and sorted builds
Building an index from sorted input can pack leaves left-to-right at high occupancy without per-row split cascades. Use it for migrations and large CREATE INDEX operations.
When B-trees win (and when they do not)
Strong fit
- Point lookups + short range scans (orders by user, sessions by id)
- Update-in-place rows with secondary indexes
- Strong transactional semantics with predictable read amplification
- Working set that mostly fits in the buffer pool
Weaker fit
- Extremely write-heavy ingest with little update locality
- Huge cold datasets where every leaf miss is a disk seek budget problem
- Workloads that want cheap sequential append of immutable events (often LSM + log)
- Append-only hot primary keys without mitigation (split + latch hotspot)
File systems (HFS+, NTFS B+ variants, many directory indexes), search engines’ block-based inverted lists, and embedded engines (LMDB, WiredTiger’s row-store mode) all reuse the same page-and-fanout idea. The SD interview usually cares about the database index case: height, splits, clustered vs secondary, and contrast with LSM.
Trade-offs to state clearly
Read amplification vs write amplification. A warm B+ tree point get is a few page reads. A write may dirty multiple pages and several indexes immediately. LSM flips that: cheap sequential writes, more complex reads until compaction catches up.
Space vs split rate. Higher fill factor packs cache better but splits sooner. Lower fill factor burns space and can add a level earlier than necessary.
Page size. Larger pages raise fanout and sequential scan bandwidth; they also increase write amplification on small updates (a 16 KiB page dirtied for an 8-byte change) and raise latch granularity.
Concurrency. Fine-grained page latches scale better than tree-wide locks, but hot leaves still serialize. Design keys and partitions so traffic does not pin one leaf.
Choosing and explaining B-trees
A complete explanation connects the mechanism to a requirement. Describe what improves when you introduce it, which new failure cases become possible, and how you would know that the trade-off is worthwhile.
Explain the core idea
A B+ tree is a balanced multiway tree of disk pages: internals route, leaves hold sorted entries and support range scans via sibling links.
High fanout keeps height tiny, so point lookups cost a handful of page reads—often cache hits on upper levels.
Inserts into full pages split and may cascade; that immediate reorganization is the price for always-sorted leaves.
When it belongs
You need mixed point lookups and range scans under transactional OLTP.
Secondary indexes must stay consistent with row updates.
You are contrasting storage engines and need the B-tree side of the B-tree vs LSM trade-off.
How it fails
Rightmost-leaf hotspots from monotonic keys cause latch contention and split storms.
Too many secondary indexes multiply write amplification on every insert.
Buffer pool too small for the hot leaf working set turns every get into disk I/O.
Trade-offs to weigh
Predictable low read amplification versus paying some page writes on the critical path.
Larger pages and compression raise fanout but can amplify small-update write cost.
Fill factor trades space for fewer splits under churn.
Common misunderstandings
Saying “B-tree” when you mean the B+ tree leaf-only layout used in almost every SQL engine.
Assuming height grows like a binary tree; fanout of hundreds keeps height nearly flat.
Ignoring that a secondary index lookup may need a second tree walk to the clustered leaf.
Wrap-up
Size fanout from page and key size, expect height 3–4 at huge N, design around splits and secondary-index write cost, and pick B+ trees when you want immediately balanced, range-friendly indexes more than peak append throughput.
Primary sources
Key takeaway
B+ trees win by packing huge fanout into disk pages so lookups stay shallow and ranges stay sequential at the leaves. Pay for that with split-time writes, latch hotspots on bad key patterns, and per-index maintenance—then contrast honestly with LSM when ingest dominates.