BOOK DEEP READ · Database Internals · Chapter 7
Database Internals · Ch 7 · Alex Petrov · 2019
Every message you send, every like you tap, every heartbeat your watch records ends up inside some database. The previous chapters described one way of storing it — like editing a dictionary: turn to the right page, rub out the old entry, write the new one. This chapter describes the opposite temperament: keeping a ledger. You only ever write on the next blank line, and you never go back to change a word.
You keep the books for a small shop. The dictionary school: one page per customer, and when a balance changes you flip to that page and rewrite it. Looking things up is easy, but you flip pages a thousand times a day. The ledger school: your pen never leaves the paper — "Alice +100", "Bob −30", "Alice +50", line after line. It is absurdly fast to write; the price is that to learn what Alice's balance is now, you must read from the top, find several Alice lines, and trust only the most recent one.
Disks have one blunt preference: writing straight ahead is fast; hunting around for a spot to write is slow — not by a little, by tens to hundreds of times. And "editing a dictionary" is exactly the second kind: changing one number means visiting the page it lives on, and those pages are scattered all over the disk. For anything write-heavy — chat logs, sensor readings, application logs, like counters — that is the bottleneck.
One more trick: each volume gets a cheat-strip on its cover that can tell you at a glance "this volume definitely does not contain Alice," which saves an enormous amount of pointless flipping.
It does not make writing cheaper; it changes "pay now" into "put it on credit and repay in the background." The same record gets carried around several times during tidying, and several versions of it sit on disk at once. If you write a lot, read less, and rarely revise what you wrote — logs, metrics, messages — the ledger school wins outright. If you look things up precisely over and over, or need to read a whole range in order, the dictionary school is still the better fit.
The whole cleverness of log-structured storage is replacing "go back and edit" with "append, then merge later." Writes got faster, but not one cent of the cost disappeared — it was simply moved onto three other bills: more volumes to search, more copies on disk, and a background clerk who never stops working.
Want the mechanisms, the numbers and the diagrams? → Switch to the deep read
You think log-structured storage is a magic trick for making writes fast. It is really a single trade: replace "random in-place update" with "sequential append plus merge-after-the-fact." None of the write cost disappears — all of it is charged to read amplification and space amplification, and repaid in instalments by background compaction.
This is the closing chapter of Part I, "Storage Engines." Chapters 2–6 covered the B-Tree lineage: sorted, updated in place. Chapter 6's copy-on-write and Bw-Tree were already edging toward "stop updating in place," and this chapter takes that road to its end — never update in place at all, only append. It maps onto the whole write-optimized branch of real systems: LevelDB, RocksDB, Cassandra, HBase. Next comes Part II: distributed systems.
The pain is in the B-Tree write path. To increment an 8-byte counter it must read the whole page into memory (pages are typically 4KB–16KB), change those 8 bytes, and write the whole page back — and for crash safety it must first append to the WAL. An 8-byte logical write becomes tens of kilobytes of physical writes, and because those pages are scattered through the file, the writes are random.
How far apart are random and sequential? Spinning disks manage random IOPS in the 100–200 range while sequential bandwidth comfortably exceeds 100MB/s; SSDs have no head, but flash's "erase only in whole blocks" rule makes small random writes expensive in its own way. The gap is two to three orders of magnitude. So write-heavy workloads — time-series metrics, logs, chat messages, social counters — hit the wall on the write path first: not because the CPU is short, but because the disk is being forced to do the thing it is worst at. This chapter's bet is blunt: turn every write into a sequential append, and find another way to pay for reads and space.
An LSM-tree (log-structured merge-tree) splits storage into two components with opposite temperaments. The in-memory table is mutable — that is the memtable, which stays sorted as writes are inserted. Everything on disk is immutable — a full memtable is frozen and written out in one sequential pass as an SSTable, after which not a byte changes.
Crash safety comes from the WAL: a write is appended to the log before entering the memtable, so a crash is repaired by replay; once the memtable has been flushed successfully, its WAL segment can be discarded. Across the whole write path the disk does exactly two things: append to the WAL, and write out an SSTable sequentially — never an in-place modification.
If files are immutable, "change" and "delete" need new definitions. An update writes a new version while the old one still sits in an older SSTable. A delete writes a tombstone — the book calls it a "dormant certificate": it removes nothing, it only declares that from this timestamp onward the key is void.
That yields a counter-intuitive rule: deleting data in an LSM makes the disk footprint grow first; real reclamation waits for compaction to roll over it. And deciding when a tombstone may itself be dropped needs care: as long as any older SSTable might still hold an earlier value for that key, the tombstone must stay. Drop it early and the old value resurfaces — resurrected data. Cassandra's gc_grace_seconds (ten days by default) is exactly that safety window.
Writes got simple, so reads got complicated. A key may live in the memtable and in several SSTables at once, so a read does two things. Merge-iteration — each SSTable is internally sorted but their key ranges may overlap, so a priority queue (min-heap) drives a k-way merge: repeatedly take the smallest key across the per-table cursors and advance that one, which turns many sources into a single globally ordered stream with one cursor per table. Reconciliation — when several records for the same key come out, the newest by timestamp / epoch wins, and if the newest is a tombstone the key counts as absent. "Newest wins" is the bedrock that makes the whole design consistent with itself.
In the worst case a point lookup touches every SSTable — that is read amplification. The first wall against it is the Bloom filter: one per SSTable, a handful of hash functions map the key into a bit array, and a single zero bit proves the key is definitely not in this file, so the file is skipped entirely; all ones still only means "maybe." The cost is a direct trade of memory against false-positive rate — RocksDB's own table puts 9.9 bits per key at roughly 1% false positives. A hundred million keys at 10 bits each is only ~125MB of memory to eliminate 99% of pointless file reads, which is an extremely good deal. But it only helps point lookups; range scans get nothing from it.
What happens without it? SSTables multiply without bound, read amplification degrades linearly, and overwritten versions and tombstones occupy disk forever. So the background is always doing the same job: read several SSTables → merge → reconcile → drop expired versions and eligible tombstones → write new files → delete the old ones. The elegant part is that immutability makes compaction unusually easy to write: sorted inputs mean sequential reads, a fresh output file means sequential writes, and no data locking is needed at all — most of the concurrency and torn-page trouble that Chapter 5's B-Tree recovery had to handle is dissolved by the structure itself.
Compaction strategy is the selection decision worth memorizing from this chapter. Three dominate:
10× the capacity of the one above. LevelDB's implementation doc is very concrete: a log becomes an SSTable at about 4MB, more than 4 files at L0 triggers compaction, and level L is capped at 10^L MB. The payoff is at most one file per level; the price is that the same row is rewritten again and again as it descends.4 by default in Cassandra) they are merged into a bigger one, and so on up. Write amplification is far lower, but one key may be spread over many tables, and a big merge keeps old and new alive at once, briefly nearly doubling disk usage.The ledger can now be closed. Judging a storage engine comes down to three numbers — read, write and space amplification — and they cannot all be optimal at once; you get to pick two. That is the RUM conjecture the chapter cites (Athanassoulis et al., EDBT 2016): of Read, Update and Memory overheads, driving two down necessarily pushes the third up. B-Trees sit in the read-optimized corner — read amplification near 1, space compact, write amplification high. LSM-trees sit in the write-optimized corner — a fully sequential write path, but reads must merge and the disk holds several versions. Compaction strategy is how you fine-tune that mix inside the LSM corner.
A log structure does not have to be sorted. Bitcask (Riak's storage engine) takes that to the extreme: on disk it is a chain of purely appended, entirely unsorted data files, while memory holds a hash table called the keydir recording which file and offset holds each key's current value. Every read is therefore one in-memory hash lookup plus one positioned disk read. The price is equally blunt: every key must stay resident in memory, and range scans are not supported. WiscKey is the compromise: key-value separation keeps the key and a pointer in the LSM-tree while the value goes into an append-only vLog, so compaction moves keys but not values and write amplification drops sharply, at the cost of turning range scans into many random reads of the vLog. The rule is clear: the larger the values, the more key-value separation pays.
The last section is the most "database internals" of all: there are more logs beneath your log. The filesystem may itself be log-structured, and an SSD's FTL certainly is — flash erases in blocks and writes in pages, so it never updates in place; it writes to a free page, marks the old one invalid, and collects garbage later. The nasty part is that write amplification across layers multiplies rather than adds: an LSM rewriting a record 10 times, times whatever the FTL's garbage collection adds, can land tens of times the logical write volume on the flash cells. Worse, each layer's garbage collection is blind to the others — the database may have just declared a batch of data dead while the FTL is busy relocating it to a fresh block. The book's direction is to flatten the stack: bypass the filesystem and FTL and manage raw flash directly. The lesson generalizes — "sequential writes" is an assumption that may simply not hold in a layer you cannot see.
An anchor scenario first: an IoT platform with 500,000 devices reporting every 10 seconds — a steady 50,000 writes/sec — where queries are almost entirely "the last 24 hours for one device" and data expires after 30 days. A B-Tree collapses on the write path here: a few dozen bytes of point write drags a multi-kilobyte page, randomly. An LSM makes every write sequential, and TWCS lets each batch of expired data be deleted as a whole file. Conversely, an account balance table that is read-heavy and reconciled by primary-key ranges is still far easier on a B-Tree.
Table 1 · LSM-tree vs. B-tree (this chapter against Chapters 2–6)
| LSM-tree (this chapter) | B-tree (Ch 2–6) | |
|---|---|---|
| Disk write shape | Purely sequential appends, never in place | In-place updates, page-level random writes |
| Write amplification | From background compaction; often >10× when leveled | From page rewrites plus the WAL double-write |
| Read amplification | May touch several SSTables; held down by Bloom filters | About 3–4 page reads, stable |
| Space amplification | High — old versions and tombstones wait for compaction | Low, though pages fragment |
| Latency stability | Compaction competes with foreground IO; write stalls happen | Steadier |
| Best for | Write-heavy, rarely revised: time series, logs, messages, counters | Read-heavy, scattered updates, strict range scans |
Table 2 · Choosing a compaction strategy (defaults as stated in each project's own docs)
| Leveled | Size-tiered | Time-window (TWCS) | |
|---|---|---|---|
| Trigger | A level exceeds its cap (~10× per level) | N similarly sized tables accumulate (default 4) | End of a time window |
| Read amp. | Low — at most one file per level | High — a key may sit in many tables | Low — usually one or two windows |
| Write amp. | High — RocksDB says "often larger than 10" | Low | Low |
| Space amp. | Low (Cassandra cites ~10% headroom) | High — disk may briefly double on a big merge | Low — whole buckets expire and go |
| Pick it when | Reads dominate and disk is tight | Writes dominate and disk is plentiful | Time series with TTL, arriving roughly in order |
Table 3 · Sorted vs. unsorted: the second fork in log-structured design
| Sorted LSM | Bitcask (unsorted) | WiscKey (key-value split) | |
|---|---|---|---|
| Point lookup | Possibly several tables + Bloom filters | Hash lookup + one positioned read | LSM for the pointer, then one vLog read |
| Range scan | Supported and efficient | Not supported | Supported, but many random vLog reads |
| Memory need | Index plus Bloom filters | Every key resident in memory | Keys only — cheaper than a full LSM |
| Write amp. | Compaction moves keys and values; highest | Low | Low — compaction moves keys only |
The weight of this chapter is that nearly every write-optimized store you use today is an instance of it. The lineage starts with Google's Bigtable (OSDI 2006), which fixed the memtable + SSTable + compaction skeleton; LevelDB turned it into an implementation anyone can read; Meta forked RocksDB from LevelDB, and it became a storage substrate embedded almost everywhere — MyRocks, TiKV, CockroachDB's Pebble, stream-processing state stores. Cassandra, HBase and ScyllaDB are the other branch of the same family.
For day-to-day decisions it hands you a set of criteria you can say out loud: when choosing, ask "what is the read/write ratio, how big are the values, do we need range scans?"; when debugging, ask "which amplification is hurting right now — read, write or space?" Asked in an interview why LSM writes are fast, the answer is not "because they are sequential" but "because it swapped random writes for sequential ones and deferred the cost into compaction" — the second half is what separates answers.
4.9 bits/key ≈ 10%, 9.9 bits/key ≈ 1%, 15.5 bits/key ≈ 0.1%, the benefit being "reducing I/O, CPU, and block cache churn associated with DB::Get, or Seek." RocksDB Wiki · Bloom Filter ↗gc_grace_seconds," and tombstones must live in the same data directory as their data so that losing a disk cannot leave data "undeleted." Cassandra Docs · Compaction Overview ↗1. There is exactly one trade: swap "random in-place update" for "sequential append plus merge later." The cost does not vanish, it is deferred into compaction.
2. Structure: one mutable in-memory table (memtable, usually a skiplist) + many immutable on-disk tables (SSTables) + a WAL for crash safety. Nothing on disk is modified in place.
3. An update appends a new version; a delete writes a tombstone — so deleting grows the disk first and real reclamation waits for compaction.
4. A read does two things: a k-way merge turns many sources into one ordered stream, then reconciliation keeps the newest by timestamp; a tombstone beats any older value.
5. The Bloom filter is the first wall against read amplification: point lookups only, absence only; roughly 10 bits per key buys about 1% false positives.
6. Compaction is the heart of it: immutability makes it sequential-read, sequential-write and effectively lock-free. Three strategies — leveled (read-optimized, write amp >10), size-tiered (write-optimized, worse reads and space), time-window (time series + TTL, whole buckets expire).
7. The RUM conjecture: read, update and memory overheads — keep at most two low. B-Trees hold the read corner, LSM-trees the write corner.
8. Log structures need not be sorted: Bitcask buys a single positioned read at the price of all keys in memory and no range scans; WiscKey moves only keys during compaction, and pays off more the larger the values.
9. Log stacking: the filesystem and the SSD's FTL are log structures too, their write amplifications multiply, and their garbage collectors are blind to each other — "sequential writes" may not hold underneath you.
10. Rule of thumb: write-heavy and rarely revised → LSM; read-heavy, range scans, tight disk → B-Tree; when something hurts, first name which amplification it is.