Hirestack
Hirestack
Chapter 1

Storage engines: B-trees vs LSM-trees

📖 24 min read · 8 sections · May 2026

Why this matters

When someone asks you "which database should we use?", the honest answer almost always traces back to the storage engine. Postgres feels fast and predictable but eats CPU during maintenance — that's because of its B-tree + MVCC design. Cassandra ingests writes at insane rates but reads have variable latency — that's because of its LSM-tree design. You can't reason about these systems' behaviour, tune them, or pick between them without understanding what's happening on disk.

Every database stores its data in some structure on disk. The choice of structure determines:

From first principles: what does a storage engine need to do?

At its simplest, a storage engine needs to:

  1. Take a key-value pair and put it on disk durably (survives a crash).
  2. Look up a key efficiently (better than linear scan).
  3. Support range queries (give me all keys between X and Y).
  4. Reclaim space when keys are deleted or updated.
  5. Stay correct under concurrent reads and writes.

The naive approach — "write everything to one file in insertion order" — fails (5) because there's no order to look things up by. The "put everything in one sorted file" approach fails because every insert requires rewriting the file. Real storage engines balance these competing demands.

The fundamental tradeoff: three amplifications

"Amplification" is the ratio of actual work the database does to the logical work you asked it to do. The database multiplies your operation into more underlying work — and it's almost always more than you'd guess.

1. Write amplification — "I write 1 byte; how many bytes hit disk?"

You issue INSERT INTO accounts (balance) VALUES (100) — that's logically writing maybe 50 bytes (the row).

What actually happens:

So you wrote 50 bytes logically; the disk saw ~16-24 KB of actual writes. That's write amplification of ~300× for a worst-case insert. Steady-state is typically 10–20× because most inserts don't cause splits.

Why you care:

For LSM-trees, write amp is even higher because of compaction: the same key gets rewritten multiple times as it's merged through levels. RocksDB leveled compaction = ~10–30× write amp under good workloads; can hit 100× under bad ones.

2. Read amplification — "I read 1 row; how many disk pages were touched?"

You issue SELECT balance FROM accounts WHERE id = 42 — logically reading one row.

What actually happens in a B-tree:

That's 3 page reads = 24 KB read for one row. Read amplification = 3× in pages, ~24× in bytes. But in practice, root and branch pages stay in the page cache (RAM), so actual disk reads drop to ~1×. That's why B-trees feel fast.

For LSMs, read amp is worse because you may check multiple SSTables before finding the key:

Without Bloom filters, an LSM read could touch 10+ files. With Bloom filters, most "is the key here" checks become RAM-only bit-array lookups, bringing read amp down to ~1.5–3×.

Why you care: read amplification drives p99 latency. Every extra page touched is another cache-miss opportunity, another network roundtrip (if cloud storage), another lock contention point.

3. Space amplification — "I have 100 GB of data; how much disk does it use?"

You have 100 GB of logical data. What actually consumes disk:

A Postgres DB might store 100 GB of logical data as 130 GB on disk. Space amplification ≈ 1.3×.

Cassandra with size-tiered compaction can hit 2–3× space amplification because the same data exists in multiple unmerged SSTables until compaction catches up.

Why you care:

The triangle: you can't have all three

This is the key insight — the three amplifications trade off against each other. You can pick two corners but not all three at the same time:

If you want…You pay with…
Low write ampHigher read amp (LSM size-tiered) or higher space amp
Low read ampHigher write amp (aggressive LSM compaction; B-tree page splits)
Low space ampHigher write amp (aggressive compaction to reclaim space)

How real engines sit in the triangle:

Write ampRead ampSpace amp
B-tree (Postgres-class)10–20×~1× (with cache)1.1–1.3×
LSM, leveled (RocksDB)10–30× steady, 100× bad1.5–3× with Bloom filters1.1×
LSM, size-tiered (Cassandra)5–10×3–10×2–3×
The RUM conjecture: Manos Athanassoulis et al. formalised this in a 2016 paper. They argue any data structure can optimise for at most two of {Read efficiency, Update efficiency, Memory efficiency}. The three amplifications are exactly the practitioners' framing of this theoretical result. Worth reading: The RUM Conjecture paper and Mark Callaghan's blog where he obsessively measures real-world amplifications across DBs (he's a long-time RocksDB engineer; his data is unmatched).

A concrete comparison: same data, two engines

Imagine storing the same 1 GB of data in Postgres and Cassandra:

Postgres (B-tree)Cassandra (LSM size-tiered)
Disk used~1.2 GB (space amp 1.2×)~2.5 GB (space amp 2.5×)
Bytes written to disk to insert 1 GB~15 GB (write amp 15×)~10 GB (write amp 10×)
Pages touched per random read1 (cached B-tree leaf)3–5 (multiple SSTables)

Cassandra accepts higher space amp and read amp to get lower write amp. Postgres accepts higher write amp to get tight space and fast reads. Neither is "better" — they're optimised for different workloads.

When you understand this triangle, "which database?" almost always traces back to "what's your workload look like, and which corner do you want to live in?"

Analogy
Imagine you're keeping a physical filing cabinet. B-tree is like an alphabetised filing cabinet: every time you add a paper, you find its alphabetical slot and put it there. Fast lookups; but when a folder is full, you have to split it into two folders and update the index — work spread across every insert. LSM-tree is like a stack of dated piles: every new paper goes on top of today's pile. Super fast to add (no organising), but to find a paper you must search every pile from newest to oldest. Periodically, you merge old piles into one big sorted pile to keep search manageable.

B-trees: how they actually work

The big idea

A B-tree is a balanced search tree where each node holds many keys (not just one like a binary tree). This makes the tree shallow — a 4-level tree can hold ~1.6 billion entries. Shallow trees mean few disk seeks per lookup.

Why "B"? Bayer and McCreight invented it in 1970. The "B" is debated; some say "balanced," some say "Bayer." Doesn't matter. What matters is the structure.

The page concept

A "page" is the unit of disk I/O. When the database reads or writes, it reads or writes whole pages — typically 4 KB, 8 KB (Postgres default), or 16 KB (InnoDB default). Even if you only need 100 bytes, you read the whole 8 KB page off disk.

Why pages? Because that's how operating systems and SSDs talk to disk. Read/write happens in fixed-size blocks at the hardware level. Trying to read 100 bytes off disk would still cause the OS to fetch a full page from the kernel buffer cache.

What's inside a page

A B-tree page is either an internal node or a leaf node:

Internal node (page):
+--------------------------------------------------------+
| header | key1 | ptr→child1 | key2 | ptr→child2 | ...   |
+--------------------------------------------------------+
"All keys < key1 are in child1; key1 ≤ k < key2 in child2; ..."

Leaf node (page):
+--------------------------------------------------------+
| header | (key1, value1) | (key2, value2) | ...         |
+--------------------------------------------------------+

The branching factor (how many keys fit per page) depends on key size. With 8 KB pages and ~50-byte (key+ptr) pairs, you fit ~160 entries per page. So the tree branches 160 ways at each level.

Tree depth math: at branching factor 160, a 1-level tree has 160 entries; 2 levels = 25,600; 3 levels = 4M; 4 levels = 650M; 5 levels = 100B. Most databases will never have a tree deeper than 4 or 5 levels.

Reading: walking the tree

To read key K:

  1. Start at the root page. Find the child pointer for K (binary search the page; cheap).
  2. Follow the pointer to the child page. Read it from disk.
  3. Repeat until you reach a leaf.
  4. Find K in the leaf. Return value.

Worst case: depth disk reads (5 disk reads for 100B keys). In practice, the upper levels (root + immediate children) stay in memory because they're touched on every read. So a typical read might be 1-2 actual disk reads, often 0 if the leaf is also cached.

Writing: in-place modification

To insert (K, V):

  1. Walk to the leaf where K should live (same as reading).
  2. Insert (K, V) into the leaf, keeping the leaf sorted.
  3. If the leaf is full (no room for the new entry) → page split.

Page splits — the operation that gets interesting

flowchart TB
    A["Leaf page (full)
[10, 20, 30, 40, 50, 60, 70]"] A -->|"insert 35 — overflow"| B B["1. Pick median key (40)
2. Allocate new page
3. Log WAL split record"] B --> C["4. Update parent:
old child pointer →
(left, key=40, right)"] C --> D["Left leaf: 10, 20, 30, 35"] C --> E["Right leaf: 40, 50, 60, 70"]

When a page is full, the engine:

  1. Allocates a new empty page.
  2. Moves half the keys from the full page to the new page.
  3. Updates the parent page to point to both pages, with a separator key.
  4. If the parent is now full, it splits too — recursive.
  5. If the root splits, tree height increases by one.

Cost of a split: rewrite of the full leaf, write of the new leaf, write of the parent. That's at least 3 page writes for one logical insert. This is part of write amplification.

Fillfactor: Postgres has a setting (fillfactor=70) that tells the engine to leave 30% free space in each page. This delays splits — inserts have somewhere to go without immediately splitting. Trades space for write performance.

The Write-Ahead Log (WAL) — the most important concept

Here's a problem: what if the database crashes mid-write? Say we're modifying a page on disk. The OS does a partial write. The page is now corrupt — half old data, half new data. Recovery has to be smart enough to handle this.

The solution: Write-Ahead Logging (also called journaling, redo logging). Before modifying any page, the database first writes a record describing the change to a separate file — the WAL.

To execute UPDATE accounts SET balance = 100 WHERE id = 42:

1. Find the page containing row 42 (walk the tree).
2. Hold the page in memory (page cache).
3. Generate a WAL record: "page X, offset Y, old=50, new=100".
4. Append the WAL record to the WAL file on disk.
5. fsync() the WAL file (force to disk).  ← THE DURABILITY POINT
6. Modify the in-memory page (set balance=100).
7. (Eventually, during checkpoint) write the dirty page to disk.

If the database crashes between step 5 and step 7, the modification is safe — recovery replays the WAL and re-applies the change. If the crash happens before step 5, the modification is lost but the database is consistent (the write never committed).

Analogy
WAL is like writing in a diary before doing anything. "Tomorrow I'm going to move the sofa to the other wall." If you write it down first, then start moving the sofa and get interrupted halfway, you can resume because you have a record of intent. If you just started moving without writing, you'd be stuck — was the sofa supposed to be here or there?

Why fsync() is so important — and so expensive

fsync() is the system call that forces the OS to flush its buffer cache to physical storage. Without fsync(), data may sit in the OS's write buffer for seconds before reaching disk. If the machine loses power in that window, you lose data.

Cost of fsync:

Every committed transaction needs at least one fsync (to durably persist its WAL records). This is the practical bottleneck of transaction throughput on traditional disks.

Group commit: instead of one fsync per transaction, batch multiple transactions' WAL records into one fsync. If 100 transactions are committing in the same millisecond, one fsync covers them all — 100× throughput multiplier. Postgres does this automatically; the commit_delay setting tunes the batch window.

Senior insight: When someone says "Postgres can do 50K TPS," ask: with synchronous_commit on or off? Off means commits return before WAL is fsynced — fast but you risk losing the last ~200ms of transactions on crash. On with group commit can also reach 50K+ TPS but requires fast SSD. The fsync cost is the single biggest determinant of OLTP throughput on traditional databases.

MVCC — making readers and writers stop blocking each other

Old databases used locking: if you're updating a row, no one else can read it until you commit. This is correct but slow under contention.

Modern databases use Multi-Version Concurrency Control (MVCC). Every row carries metadata:

When a transaction starts, it gets a snapshot — basically a list of transaction IDs that have committed before it started. Reads filter rows: a row is visible if xmin is in the snapshot AND xmax is either null or not in the snapshot.

Updates don't modify the row in place. They write a new row version, set the old version's xmax to the current transaction ID, and the new version's xmin to the current transaction ID.

Result:

The cost of MVCC: dead row versions and VACUUM

If every update creates a new row version, the old version stays around until no transaction can see it. Over time, you accumulate "dead tuples" — row versions that are invisible to any current transaction. They take space; they slow queries.

Postgres needs VACUUM to reclaim dead tuples. VACUUM is a background process that scans tables, identifies dead tuples, and marks their space as reusable.

Operational issues:

War story — GitLab's 2017 outage
GitLab's primary Postgres was struggling with replication lag and bloat. An engineer ran rm -rf on what they thought was the lagging replica's data directory. It was actually the primary's. Recovery took 18 hours; they lost 6 hours of database transactions. The root cause was VACUUM and replication management complexity at scale — there are five different backup systems, and four of them weren't working. Read the postmortem: about.gitlab.com/blog/postmortem-of-database-outage-of-january-31. It's a classic. Lesson: operational complexity is part of the storage choice.

B-tree numbers worth memorising

Used by: Postgres, MySQL InnoDB, Oracle, SQL Server, SQLite, MongoDB WiredTiger, etcd's BoltDB.

Further reading — B-trees and Postgres internals

LSM-trees: the alternative

The big idea

What if we just... never modify in place? Every write appends. We organise the appended files later in the background. This trades read complexity (multiple files to check) for write simplicity (sequential, batched, never random).

This idea was published in 1996 (O'Neil et al., "The Log-Structured Merge-Tree"). It got popular when Google built Bigtable on it (2006). Today it powers Cassandra, RocksDB, LevelDB, HBase, ScyllaDB, InfluxDB, and most modern KV stores.

The mechanics, step by step

Step 1 — Write to memory. A write goes to an in-memory sorted data structure called the memtable. Typically a skip list or red-black tree. Skip lists are popular because they're simpler to make concurrent.

Step 2 — Append to WAL. Simultaneously, the write is appended to a Write-Ahead Log on disk (same idea as B-tree WAL). This makes the write durable even before it lands in an on-disk structure.

Step 3 — When memtable fills, flush. When the memtable hits a size threshold (e.g., 64 MB), it's flushed to disk as an immutable, sorted file: an SSTable (Sorted String Table). The memtable becomes a new empty one; writes continue.

Step 4 — Background compaction. Over time, you accumulate many SSTables. The DB has a background process — compaction — that merges multiple SSTables into a larger one, discarding overwritten keys and tombstones.

Reading from an LSM

To find key K:

  1. Check the memtable. Found? Return.
  2. Check each SSTable, newest to oldest. The newest version of K wins.
  3. If we reach the end without finding K, return "not found."

Naively, this could mean checking N SSTables per read. If N=20, that's 20 disk seeks. Way too slow. Two optimisations make LSM reads fast:

Bloom filters

A Bloom filter is a small probabilistic data structure that answers "is X in this set?" with two possible answers:

Each SSTable has a Bloom filter for its keys. To read key K, you check each SSTable's Bloom filter first. If it says "definitely not in," you skip that SSTable entirely — no disk read. You only actually read SSTables whose Bloom filters say "maybe in."

With a 1% false positive rate, reading a missing key requires ~1.2 actual SSTable reads instead of N. Bloom filters typically use ~10 bits per key — so 1 billion keys = 1.25 GB of Bloom filter memory. Keep it in RAM.

Level structure

flowchart TB
    W["Write"] --> M["Memtable (RAM, sorted, ~64MB)"]
    M -->|flush when full| L0["L0: small SSTables
overlapping key ranges"] L0 -->|compact when L0 grows| L1["L1: ~10MB SSTables
non-overlapping"] L1 -->|leveled compaction| L2["L2: ~100MB SSTables
non-overlapping"] L2 --> L3["L3: ~1GB SSTables"] L3 --> LN["Lₙ: each ~10× previous
(typical depth 5–7)"]

Instead of one big pile of SSTables, organize them in levels:

L0 — newly flushed SSTables. May overlap in key range.
L1 — sorted, non-overlapping SSTables. Total size ~10 MB.
L2 — sorted, non-overlapping. Total ~100 MB.
L3 — sorted, non-overlapping. Total ~1 GB.
L4, L5, L6 — each 10× larger.

Reads at L1+ can use binary search: each level has a sorted index. Reads at L0 must check each L0 file (because they overlap). L0 is kept small (typically max 4 files).

Compaction strategies — the most important LSM concept

Compaction takes multiple SSTables and merges them. Different strategies trade write amplification, read amplification, and space amplification differently.

StrategyHowPros / cons
Size-tiered (Cassandra default)When N SSTables of similar size accumulate, merge them into one larger SSTable. Larger SSTables form their own tier.Low write amp; high space amp (data exists in multiple tiers); high read amp
Leveled (RocksDB, LevelDB default)Strict level structure. Periodically pick one SSTable in L_i and merge it with overlapping SSTables in L_{i+1}.Low space amp (each key in one place per level); low read amp; higher write amp (~10×)
Universal (RocksDB option)Bounded size-tiered. Avoids unbounded tier growth.Compromise; bursty workloads
FIFOJust delete oldest SSTables after a TTL. No compaction.Time-series with hard expiration

Why compaction is the operational beast of LSM

Compaction is constantly running in the background. It reads SSTables, merges them, writes new ones, deletes old ones. This consumes CPU and I/O.

If compaction can't keep up with writes:

This manifests as periodic write-latency spikes during heavy ingest. Tuning compaction (rate limiting, parallelism, thresholds) is a senior skill.

War story — Discord's Cassandra problems
Discord stored trillions of messages in Cassandra. They ran into:
  1. Compaction storms: when multiple compactions ran simultaneously, CPU pinned and reads slowed.
  2. Tombstone reads: when many messages were deleted (account deletion, GDPR), reads had to skip large numbers of tombstones, causing latency spikes.
  3. Heat: hot partitions (popular channels) overloaded specific nodes.
Their solution: migrate to ScyllaDB (a C++ rewrite of Cassandra with better compaction scheduling). The migration is documented at discord.com/blog/how-discord-stores-trillions-of-messages. Compaction tuning + tombstone TTLs + heat balancing — all part of LSM operations.

Tombstones — how LSMs handle deletes

You can't actually delete a row from an LSM cheaply — the row might exist in 5 different SSTables. So a delete writes a tombstone: a marker saying "this key is deleted."

The tombstone wins over any older value of the key. During compaction, when a tombstone meets the row it overrides, both can be discarded.

Tombstone problems:

LSM numbers

Used by: Cassandra, RocksDB, LevelDB, ScyllaDB, HBase, Bigtable, DynamoDB (internally), CockroachDB (via Pebble), InfluxDB, Kafka log compaction.

Further reading — LSM-trees

How to choose — and what real systems pick

You need...PickWhy
Read-heavy OLTP, complex queries, joinsB-tree (Postgres, MySQL)Predictable reads, mature SQL ecosystem
Write-heavy time-series, append-mostlyLSM (Cassandra, Scylla, Influx)Sequential writes; OK with read amp
Predictable latency (financial, gaming)B-treeLSM compaction can stall
Massive ingest, OK with eventual consistencyLSMHigher write ceiling per node
Frequent updates of the same keysB-treeLSM creates many versions of each key
Wide rows, scan-heavyLSM (Cassandra, HBase)Columnar-ish storage natural in LSM
Complex secondary indexesB-treeLSM secondary indexes are hard and expensive
Senior insight: "LSM is faster at writes" is a misleading shorthand. LSM has lower immediate write cost (just append), but pays it back later in compaction. The total amount of CPU + I/O work for a given workload is comparable between B-trees and LSMs. What LSM gives you is decoupling: write latency stays low even under bursts, because the expensive compaction work is deferred and batched. The tradeoff: latency is less predictable, because compaction events can stall reads.

Interview drills

Q: Why doesn't Postgres use LSM?
Historical and practical reasons. Postgres started in the 1980s; LSM didn't exist yet. The MVCC + B-tree model was the standard. The entire Postgres feature set — foreign keys, complex secondary indexes, GIN/GiST indexes for full-text and JSON, partial indexes, expression indexes — is built on B-tree semantics. Retrofitting LSM would be a multi-year rewrite. New systems like CockroachDB and TiDB sit at "Postgres-compatible SQL on LSM" precisely because they don't have the legacy to preserve.
Cross-Q: Is there work to add LSM-like storage to Postgres?
A: There's been experimentation (ZHEAP, an alternative heap aimed at reducing bloat, never made it to mainline). TimescaleDB takes a different approach — keeping B-tree but using columnar compression and partitioning to optimise time-series. The general consensus: Postgres' value is the layer above storage; you'd build an LSM on top of Postgres-as-an-API rather than replace its storage.
Q: Walk me through what happens when a B-tree page splits.
Suppose we're inserting into a leaf page that's already full. (1) Acquire a write latch (short-lived lock) on the leaf. (2) Allocate a new empty page from the free space map. (3) Pick a split point — usually the median key. Copy half the entries (smaller keys) to one page, the other half to the other. (4) Write a WAL record describing the split atomically — it's logged as a single recovery unit. (5) Update the parent page: replace the old child pointer with two new ones plus a separator key. (6) If the parent is full, recursively split it. (7) If the root splits, allocate a new root and increase tree depth by one.
Cross-Q: What if we crash in the middle?
A: The WAL record describing the split is atomic — either it's fully persisted to disk before the crash, or it isn't. If it's persisted, recovery replays it; either way the tree is consistent. The split is never "half done" from a recovery perspective. This is why we use WAL.
Cross-Q: What's the write amplification of a single insert that causes a split?
A: Original leaf write + new leaf write + parent update + WAL record(s). Maybe 4–5 pages of I/O for one row insert. This is amortised because most inserts don't cause splits.
Q: Explain Bloom filters in an LSM context.
Each SSTable has a Bloom filter — a fixed-size bit array. When the SSTable is written, every key is hashed by k different hash functions and the corresponding bits are set. To check if key K is in the SSTable, we hash K with the same k functions and check those bits. If any bit is unset, K is definitely not in the SSTable (we can skip it without a disk read). If all bits are set, K is "maybe" in the SSTable — we have to do the disk read to confirm.
Cross-Q: What determines the false-positive rate?
A: Bit array size and number of hash functions. With ~10 bits per key and k≈7 hash functions, FP rate is ~1%. More bits per key drives FP down but uses more RAM. With 1B keys at 10 bits each, that's 1.25 GB of Bloom filter — must stay in memory or you've lost the benefit.
Cross-Q: When do Bloom filters not help?
A: For range scans (Bloom filters only answer point lookups). And for keys that are actually present — Bloom filter says "maybe" and you still have to read. They only save you on negative lookups. A workload of "read keys that mostly exist" gets less benefit than "read keys that mostly don't."
Q: What's a "write stall" in RocksDB?
When compaction can't keep up with incoming writes, the number of L0 SSTables grows. Reads slow down (must check many L0 files). When L0 hits a threshold (e.g., 20 files), RocksDB throttles writes — your write API calls start taking longer. If L0 grows further (e.g., 36 files), RocksDB stops writes entirely until compaction catches up. You see this in production as periodic write-latency cliffs.
Cross-Q: How do you tune away write stalls?
A: Multiple knobs. Increase memtable size (delay flushes). Increase the number of background compaction threads. Use rate-limited compaction I/O to keep compaction running even under load. Use universal compaction for spiky workloads. Sometimes the answer is "your hardware is too slow for your workload" — more SSDs.
Q: Why does Cassandra have a tombstone TTL (gc_grace_seconds = 10 days by default)?
Cassandra is multi-replica with eventual consistency. When you delete a row, the tombstone propagates to replicas asynchronously. If a replica is offline and misses the tombstone, and the tombstone is garbage-collected before that replica comes back, the replica will gossip its old (un-deleted) version of the row to other nodes during anti-entropy — and the deleted row resurrects. The 10-day default gives ops time to repair down replicas before tombstones are reaped.
Cross-Q: What happens if you set it lower?
A: Faster space reclamation; risk of resurrection if any replica is down longer than the TTL. Setting it to 0 is dangerous. Common practice: set to 3 days if you have very reliable replicas and strong repair processes; never less than node-down-recovery-time + safety margin.
Q: How would you tune Postgres for write-heavy workload?
(1) wal_buffers larger to batch more WAL before fsync. (2) commit_delay non-zero to encourage group commit. (3) Make sure WAL is on fast SSD; consider separate SSD for data files vs WAL to avoid contention. (4) Tune checkpoint_timeout and max_wal_size to spread checkpoint I/O. (5) Aggressive autovacuum settings on hot tables. (6) Consider synchronous_commit = off if you can tolerate ~200ms data loss on crash. (7) Partition large tables. (8) Use HOT (Heap-Only Tuples) updates — keep fillfactor low (70%) so updates can stay on the same page.
Cross-Q: What's a HOT update?
A: When you update a row in Postgres, the default is to write a new row version and mark the old one dead. If the indexed columns aren't changing and there's free space on the page, Postgres can do an in-page HOT update that avoids the index update — cheaper. Requires fillfactor < 100% to leave room.

Red flags candidates show on this topic