Write vs read amplification, memtables, SSTables, and compaction stalls — the direct payoff of F-2, and why your write-heavy table hates a B-tree.
Module P-2 — Storage Engines: B-Trees vs LSM-Trees
What this module covers: Underneath the database you chose is a storage engine you probably didn't, and the two families fail in completely different shapes. This module covers what a B-tree costs to write — page splits, WAL, torn-page protection, a scattered write pattern — and what an LSM-tree does instead: memtable, immutable SSTable flush, compaction levelled or tiered, bloom filters, tombstones. It covers the three-way amplification trade where you pick two, the compaction stall that makes "it was fine for two weeks" a diagnosis rather than an excuse, and a decision rule that names its own cost.
You Didn't Choose Your Storage Engine, You Inherited It
Almost nobody picks a storage engine. You pick Postgres because it's the sane default (Module F-14), or Cassandra because someone needed multi-region writes, or you adopt a stream processor and inherit RocksDB under its state store. The engine arrives as a side effect, and then it decides the shape of your worst production day.
- A B-tree engine degrades on a slope. Insert throughput sags as the index outgrows memory, hit ratio slides, bloat accumulates (Module F-13) — months of warning on a graph nobody was watching.
- An LSM engine degrades as a cliff. Write throughput is flat and excellent for two weeks, then falls off a ledge inside an hour, p99 going from milliseconds to seconds. Disk isn't full, CPU isn't pegged, nothing was deployed: a background process fell behind.
Both behaviours come from one decision at the bottom of the stack: does a write modify data where it already lives, or append it somewhere new and reconcile later?
Update in Place: What a B-Tree Costs to Write
Module F-13 covered the read side — sorted leaves, four or five page reads for a hundred million rows, sideways scans along the leaf links. The costs hide on the write side. To write one row the engine finds the leaf page the key belongs on, modifies it in memory (in Postgres an 8 KB page in shared_buffers, now dirty), appends the change to the WAL and fsyncs at commit, then eventually writes that page back to its own location at checkpoint time. The WAL part is sequential and cheap. The write-back is not: the page lives wherever the key sorts, so the physical pattern is dictated by your key distribution.
Two further costs are large and easy to miss.
Page splits. When a new entry doesn't fit on its leaf page, the engine allocates a new page, moves half the entries into it, and updates the parent — which may itself be full, cascading upward, occasionally to the root. One logical insert becomes several page writes plus the WAL records describing them, and the index is now fragmented, so later range scans walk leaves that are no longer physically adjacent.
Torn-page protection. A power cut mid-write can leave an 8 KB page half-old and half-new, which no delta-style WAL record can repair. Postgres with full_page_writes on therefore copies the entire page into the WAL the first time it's touched after a checkpoint; InnoDB uses its doublewrite buffer. Updating 200 bytes can put 8 KB into the log.
So B-trees have write amplification too — just bounded, predictable, and paid at commit rather than in the background. It's much of why a single Postgres node with fsync-on-commit sits around 1,000–5,000 write transactions per second.
Why this matters in production: the cost is proportional to how scattered your keys are. Sequential keys concentrate inserts onto the last few leaf pages, which stay cached and rarely split. Random keys spread inserts across the whole index, so the set of pages being dirtied is the index — hit ratio collapses, splits multiply, WAL traffic climbs. That is the random-UUID-primary-key disaster, and Module P-3 is about nothing else.
An LSM-Tree Is Module F-2's Fact Turned Into an Engine
Module F-2 established the counterintuitive number: in useful bytes per second, a sequential SSD write (~3 GB/s) beats a random access pattern in DRAM (~80 MB/s). A log-structured merge tree is that sentence built into an engine. It refuses to write anything in place, on the grounds that a large sequential write is so much cheaper than a scattered one that it's worth doing substantially more total I/O to get one.
The write path has three steps and no page updates in it:
- Append to a write-ahead log for durability. Sequential.
- Insert into the memtable — a sorted in-memory structure, usually a skip list. The write is complete from the client's point of view here, which is why LSM write latency is so low: a log append and a memory insert, no page read.
- When the memtable hits its size limit, freeze it and write it out as one SSTable — a sorted, immutable file — in a single sequential pass, then discard the matching WAL segment. A fresh memtable takes over immediately.
Immutability is load-bearing: nothing on disk is ever modified, so there are no splits, no torn pages, no random write pattern, and no locking between readers and writers. The cost is that many files may each hold a version of the same key, recency decided by file age — so reads must reconcile them, and something must reduce the file count.
Analogy: a B-tree is a librarian who reshelves every returned book the moment it arrives — the shelves are always exactly right, and she spends the day walking the building to keep them that way. An LSM-tree is the returns trolley: books go in as they come, and a night shift merges the trolleys into the shelves after closing. The front desk gets far faster, but finding one book now means checking the trolleys before the shelves — and if the night shift falls behind, trolleys stack up in the corridor until you have to stop accepting returns.
Compaction Is the Bill, Paid Later in Background I/O
Compaction reads several sorted SSTables, merges them, keeps the newest version of each key, drops the shadowed ones, and writes new sorted files. All sequential, and all pure overhead from the application's view, competing with foreground reads for one device. Two strategies dominate and trade cleanly:
| Levelled | Tiered (size-tiered) | |
|---|---|---|
| Structure | L0 holds overlapping fresh flushes; each level below holds non-overlapping files ~10× the size of the one above | Similar-sized files merge into a bigger one; overlapping files coexist at every size |
| Write amp | High — each byte rewritten once per level, ~10–30× | Lower, often single digits |
| Read amp | Low — below L0, one file per level at most holds the key | Higher — several files per size class to check |
| Space amp | ~1.1× — the bottom level holds ~90% of the data | Up to ~2× or worse; a merge needs room for inputs and outputs at once |
| Typical home | RocksDB default, LevelDB | Cassandra's default STCS, HBase |
The write-amplification arithmetic predicts the cliff, so do it once. With a level ratio of 10, a byte is rewritten as it merges L1→L2, L2→L3 and onward, each merge rewriting on the order of 10× its input in the destination level. Ingesting 20 MB/s of application data can mean a few hundred MB/s of device writes once the tree is deep.
That is the honest framing: an LSM does not reduce total I/O. It usually increases it, considerably, while converting nearly all of it into the pattern the device is best at and moving it off the critical path. The second half of that sentence is what breaks.
"It Was Fine for Two Weeks" — the Compaction Stall
A service ingesting events into Cassandra or RocksDB runs beautifully; write p99 is a few milliseconds. Then, somewhere between week two and month three, throughput collapses within an hour, p99 hits seconds, and clients time out. Disk isn't full. CPU has headroom. Deploy history is clean.
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion