Storage Engines: LSM-trees vs B-trees
How a database actually stores your data, and why the write path and the read path cannot both be free.
Retrieve before reading
In one sentence: a database that only ever appends to a file has the fastest possible writes. Why is that not the whole story?
Reveal a strong answer
Because reads become terrible: finding a key means scanning the whole file, O(n). The whole art of storage engines is trading write speed for read speed - and the index is the currency.
The simplest database, and the trade-off it exposes
Start with the world's simplest key-value store: a text file where every write appends a line "key,value", and every read greps the file for the last occurrence of the key. Writes are as fast as a file can be - appending is the simplest possible write operation, and many real databases use an append-only log (an append-only sequence of records) as their foundation. But reads are O(n): double the records, double the scan time.
The fix is an index: additional metadata, derived from the primary data, that acts as a signpost to where the data lives. And here is the trade-off that frames the entire chapter: an index speeds up reads, but every index slows down writes, because the index must be updated on every write. That is why databases do not index everything by default - you choose indexes based on your query patterns, and you pay for each one on every write.
Hash indexes: the simplest index
Keep an in-memory hash map from key to byte offset in the append-only file. Write: append, then update the map. Read: look up the offset, seek, read. This is essentially what Bitcask (Riak's default engine) does. It is excellent when there are many writes per key but not too many distinct keys - the book's example is a per-video play counter, where the key is the video URL and the value is incremented constantly.
To keep the file from growing forever, break it into segments and run compaction in the background: throw away duplicate keys, keeping only the most recent value per key, and merge segments. Segments are immutable once written, so merging never disturbs live reads and writes. The hash index has two hard limits: the hash map must fit in memory (all keys in RAM), and range queries are hopeless - fetching every key between kitty00000 and kitty99999 means one lookup per key.
SSTables and LSM-trees: sorted segments
Now make one small change to the segment format: require the key-value pairs to be sorted by key. This is a Sorted String Table, or SSTable. Sorting seems to break sequential writes, but the trick is to sort in memory first:
- Writes go into an in-memory balanced tree (a red-black tree, say) called the memtable.
- When the memtable passes a threshold (a few MB), flush it to disk as an SSTable - the tree already holds the keys sorted, so the flush is sequential.
- Reads check the memtable first, then the newest segment, then older segments.
- Background compaction merges segments, discarding overwritten and deleted values.
Sorted segments buy three things. First, merging is mergesort: read the input files side by side, copy the lowest key, repeat - and when the same key appears in several segments, keep the value from the most recent one. Second, the in-memory index can be sparse: one key per few kilobytes of segment is enough, because you can seek to a nearby key and scan forward. Third, since range reads scan consecutive keys anyway, you can group records into blocks and compress them - disk bandwidth is usually scarcer than CPU, so spending CPU to shrink what hits the disk is a good trade.
This family is the Log-Structured Merge-Tree (LSM-tree), named by Patrick O'Neil and colleagues. LevelDB and RocksDB implement this family as embedded storage-engine libraries; Cassandra and HBase use similar designs influenced by Google's Bigtable paper, which introduced the terms "SSTable" and "memtable". Lucene, the search engine inside Elasticsearch and Solr, uses the same idea for its term dictionary. One practical wrinkle: a read for a key that does not exist must check the memtable and every segment before concluding - so LSM engines keep a Bloom filter in memory, a compact structure that can say "this key is definitely not here" and skip the disk reads.
B-trees: the incumbent
The most widely used index is not log-structured at all. The B-tree, introduced in 1970, keeps keys sorted like an SSTable, but with a completely different philosophy: instead of variable-size segments written sequentially, it breaks the database into fixed-size pages (traditionally 4 kB) and reads or writes one page at a time - matching how the disk itself is arranged. Each page holds keys and references to child pages; the root page's keys are the boundaries between child ranges. A lookup walks from root to leaf, following the range that contains the key, in O(log n) pages. A page that fills up is split in two, and the parent is updated - the tree stays balanced by construction.
The defining difference is the write operation: a B-tree overwrites a page in place, while an LSM-tree only appends. Overwriting in place is dangerous when a single logical operation touches several pages - a page split writes two new pages and the parent, and a crash halfway leaves a corrupted index. So B-tree implementations keep a write-ahead log (WAL, or redo log): every modification is appended to the WAL before the pages are touched, and after a crash the WAL replays the tree back to a consistent state. The cost: every write hits the disk at least twice - once to the log, once to the page.
LSM vs B-tree: the comparison
The rule of thumb: LSM-trees are faster for writes, B-trees are faster for reads - and real benchmarks are workload-sensitive, so the honest answer is "benchmark your own workload". The mechanism behind each side:
- Write speed: an LSM turns every random write into sequential appends and background merges, which is why it sustains much higher write throughput. The cost is write amplification - one logical write causes many physical writes over the segment's lifetime, which matters on SSDs that wear out.
- Read speed and predictability: a B-tree stores each key in exactly one place, so reads are predictable. An LSM's compaction runs in the background and competes for disk bandwidth; average latency stays fine, but high percentiles (p99) can spike while a big merge is running.
- Transactions: because each key lives in exactly one place, B-trees make range locking natural - which is why relational databases that offer strong transaction isolation (the subject of lesson 0006) tend to use them.
There is no universal winner. Newer datastores increasingly choose log-structured engines; the relational incumbents stay on B-trees; and the book's advice is to test empirically with your own workload rather than argue from principle.
Every time you pick a database, you are picking a write/read trade-off. Cassandra and RocksDB chose write throughput; Postgres and MySQL chose read predictability and transactions. Knowing which family your database uses tells you what it optimized for - and what you gave up.
Problem set
P1. The index tax
A table receives 1,000 writes/sec and 5,000 reads/sec. Adding one secondary index costs 0.2 ms of extra write time per write and saves 3 ms per read on the queries that use it. If 40% of reads use the new index, is the index worth it? Compute the net time change per second.
Solution
Write cost: \(1{,}000 \times 0.2 = 200\) ms/sec added.
Read saving: \(5{,}000 \times 40\% \times 3 = 2{,}000 \times 3 = 6{,}000\) ms/sec saved.
Net: \(6{,}000 - 200 = 5{,}800\) ms/sec saved - the index is clearly worth it. The lesson: indexes are a tax on writes paid to subsidize reads; the tax is worth paying when reads dominate and actually use the index.
P2. Sparse index arithmetic
An SSTable segment is 64 MB with 4 kB average record size. The sparse in-memory index holds one entry per 4 kB of segment. (a) How many index entries does the segment need? (b) If a lookup seeks to the nearest index entry and scans forward, what is the worst-case scan size in records?
Solution
(a) \(64\ \text{MB} / 4\ \text{kB} = 16{,}384\) entries. Note that with 4 kB average records this equals the record count - the sparse index is bounded by segment size, not record count, so it stays at 16,384 entries even if records were much smaller (and the record count much larger).
(b) Worst case, the key is just past an index entry, so the scan covers up to 4 kB of bytes - one record at 4 kB each, or more records if they are smaller. The scan window is bounded by the index spacing, which is why a sparse index works: the search region is small enough to read quickly.
P3. Why the WAL exists
A B-tree page split must write two new child pages and update the parent. Explain, step by step, what a crash can corrupt if there is no write-ahead log, and how the WAL prevents it.
Solution
Without a WAL: the database writes the two new child pages, then crashes before updating the parent. The parent still points at the old (now overwritten) page, so part of the key range is unreachable - an orphaned page, or a tree where the same key range is covered twice. The index is corrupted and no amount of scanning finds the lost keys.
With a WAL: the entire split (both children and the parent update) is appended to the log first. After a crash, recovery replays the durable, complete records and ignores or truncates an incomplete tail according to the log format - the tree is restored to a consistent state before serving traffic. (Append-only does not by itself prevent a torn final record; the log format and recovery procedure handle that.)
P4. Write amplification
A key is written once, then updated 9 times. In an LSM engine, each update lands in the memtable, and the key ends up in 3 different SSTables before compaction finally merges them. (a) How many physical copies of the key exist on disk at peak? (b) Why does this matter on an SSD?
Solution
(a) At the stated peak there are 3 on-disk SSTable copies of the key. If an additional newest update is still sitting in the memtable (not yet flushed), that is a 4th copy in RAM - but it is not on disk. Compaction eventually reduces the on-disk copies to 1, but the merging process rewrote the key multiple times along the way.
(b) SSDs have a limited number of program/erase cycles per block, so they wear out with use. Write amplification means each logical write consumes several physical writes of that endurance budget - the book's footnote flags this as a particular SSD concern. This is the price LSM engines pay for turning random writes into sequential ones.
P5. Pick the engine family
For each workload, argue which family (LSM or B-tree) fits better and what you give up: (a) an append-heavy event ingestion system where reads are rare and always by recent timestamp; (b) a banking ledger where every account balance must be consistent and reads are frequent; (c) a key-value cache with a working set that fits in memory and a very high write rate.
Solution
(a) LSM: writes dominate, and sequential appends are exactly what log-structured engines do. You give up predictable read latency - but reads are rare here, so the p99 spikes from compaction barely matter.
(b) B-tree: each key lives in exactly one place, which makes range locking natural - the reason relational databases with strong transaction isolation tend to use B-trees. You give up raw write throughput. (Note: LSM engines can implement transactions too; the book's point is that B-trees make the locking-based approach convenient, not that it is the only way.)
(c) LSM (or even a hash-index engine like Bitcask): the write rate is the bottleneck and the working set fits in memory, so the sparse-index advantage of LSM is less relevant - but the sequential-write advantage still wins. You give up range-query efficiency, which this workload does not need.
The pattern: name the dominant operation, then name what the other family would have given you. That is the trade-off vocabulary the course is building.
P6. Interpret the artifact
Open the RocksDB wiki or the PostgreSQL docs chapter on indexes. In plain language: (a) which storage family does each use, and what evidence in the docs tells you; (b) what workload each engine's documentation claims to be good for; (c) which of the two trade-offs from this lesson (write speed vs read predictability, or write amplification vs in-place updates) shows up in the docs.
Solution
(a) RocksDB is LSM-family: its wiki describes memtables, SSTables, and compaction explicitly - the exact vocabulary of this lesson. Postgres uses B-trees: its index docs describe pages, and the default index type is a B-tree.
(b) RocksDB's docs emphasize write-heavy workloads and high write throughput; Postgres's docs emphasize general-purpose relational workloads with transactions.
(c) RocksDB's wiki discusses write amplification and compaction tuning directly. For Postgres, the index chapter covers index overhead and B-tree structure; crash safety via the write-ahead log is documented separately in the WAL introduction - follow that link if you want to see the WAL trade-off in the docs. Either way, both are the trade-offs from this lesson, visible in real documentation - the point is that the vocabulary transfers from the book to the tools you actually use.