Reading: LSM-Trees vs B-Trees - How Storage Engines Store Data
Notes from the storage chapter of Designing Data-Intensive Applications: the two families of storage engine behind almost every database (log-structured LSM-trees and page-oriented B-trees), how each is built, and why their write paths follow directly from how disks and SSDs behave.
Source: Designing Data-Intensive Applications, Martin Kleppmann, Chapter 3 (Storage and Retrieval)
The starting point: an append-only log
The simplest database is two shell functions: set appends key,value to a file, get scans for the last occurrence of the key. Writes are fast (sequential append), reads are O(n).
To speed up reads you add an index: extra metadata derived from the data. Every index slows writes (you update it on each write), so this is the core trade-off of the whole chapter:
Well-chosen indexes speed up reads, but every index slows down writes.
The two engine families answer “how do we keep an index without making writes expensive?” in opposite ways.
1 | ┌──────────────────────────────┐ |
Part 1: SSTables and LSM-trees
Hash index first (Bitcask)
Keep an in-memory hash map key → byte offset in the log file. A read is one hash lookup plus one disk seek; a write is one append plus a map update. Fast, but:
- All keys must fit in RAM.
- No range queries (
user:100touser:200means probing every key). - The log grows forever, so you need compaction (drop old values for the same key).
SSTable: sort the segment by key
An SSTable (Sorted String Table) is a log segment where the key-value pairs are sorted by key, and each key appears once per segment. That one change gives three wins:
- Merging is cheap. Merging sorted files is a mergesort-style streaming pass, so it works on files bigger than memory. When a key appears in several segments, the newest wins.
- A sparse index is enough. You don’t need every key in memory, just one every few KB. To find
handiwork, look up the nearest smaller indexed keys and scan a small block. - Blocks can be compressed. Records between index entries are scanned together anyway, so compress each block.
1 | Sparse in-memory index SSTable on disk (sorted) |
But how do you write sorted data if writes arrive in random order?
You build the sorted structure in memory first, then flush it. This is the LSM write path:
1 | write(k, v) |
The read path
Check the memtable first, then the newest SSTable, then older ones until the key is found. Reads for keys that don’t exist are the worst case because they touch every segment. The fix is a Bloom filter per SSTable: a tiny in-memory structure that says “definitely not here” or “maybe here”, so most segments are skipped without disk I/O.
1 | read(k) ──▶ memtable ──miss──▶ [Bloom #4?] ──no──▶ [Bloom #3?] ──maybe──▶ SSTable #3 ──hit──▶ value |
Deletes and compaction
Segments are immutable, so a delete appends a tombstone marker. It hides older versions of the key until compaction merges them away.
Compaction strategies:
| Strategy | Idea | Used by |
|---|---|---|
| Size-tiered | Merge newer, smaller SSTables into older, larger ones | Cassandra, HBase (option) |
| Leveled | Key range split into small SSTables, organized in levels, each ~10x larger than the last; data moves down a level at a time | LevelDB, RocksDB |
1 | Leveled compaction |
Worked example: one key moving down the levels
Say we write user:42 = "A", later overwrite it with "B", and watch both versions:
1 | ① Write "A" memtable: {user:42=A} |
Two things to notice: the old value A is only physically removed when a compaction rewrites the files containing it, and each step touched only the files overlapping that key range, not the whole database.
Where the name comes from
Log-Structured Merge-tree: writes are logged (appended), and sorted runs are merged in the background. Lucene (Elasticsearch, Solr) uses the same idea for its term dictionary.
Part 2: B-trees
The B-tree (1970) is the default index in nearly every relational database. It shares one idea with SSTables, data sorted by key, but is organized differently.
Fixed-size pages, not variable-size segments
A B-tree breaks the database into fixed-size pages (traditionally 4 KB, now often 8 to 16 KB) and reads or writes one page at a time. That mirrors the hardware: disks are also divided into fixed-size blocks.
Pages refer to each other by on-disk page number, like pointers but on disk, forming a tree:
1 | ┌─────────────────────────────────────┐ |
- Lookup: start at the root, follow the child whose key range contains your key, until you hit a leaf.
- Branching factor: the number of child references per page (often hundreds). A tree four levels deep with 4 KB pages and a branching factor of 500 stores up to ~250 TB, so lookups take only a handful of page reads.
Updates overwrite in place
Updating a value finds the leaf page and overwrites that page on disk. The references to the page stay valid. This is the sharp contrast with LSM-trees, which never modify a file once written.
Inserts and page splits
Inserting a key into a full page splits it into two half-full pages and updates the parent to point to both. This keeps the tree balanced: depth stays O(log n).
1 | Insert 133 into a full leaf [125 130 141 145] |
Making B-trees crash-safe: the WAL
A split touches several pages (the two leaves plus the parent). If the DB crashes midway you get an orphan page or a corrupted tree. So B-trees add a write-ahead log (redo log): an append-only file where every modification is written before it is applied to the tree pages. After a crash, replay the WAL to restore consistency.
Other B-tree details:
- Latches (lightweight locks) protect the tree for concurrent access.
- Some systems (LMDB) use copy-on-write instead of a WAL: a modified page is written to a new location and a new parent chain is created. This also helps snapshot isolation.
- Optimizations: abbreviated keys in interior pages (higher branching factor), leaf pages laid out sequentially, sibling pointers for range scans.
Side-by-side
1 | LSM-TREE B-TREE |
| LSM-tree | B-tree | |
|---|---|---|
| Write throughput | Usually higher: sequential writes, compressed, less fragmentation | Lower: each write goes to the WAL and to the page (and to a split page sometimes) |
| Read performance | Slower: may check several structures | Faster and more predictable: one location per key |
| Write amplification | One logical write becomes several physical writes over time (compaction) | One logical write becomes WAL + page write(s) |
| Space | Compresses better; no page fragmentation | Pages partly empty after splits |
| Latency predictability | Compaction can steal disk bandwidth and cause p99 spikes | More predictable |
| Locking / transactions | Key can exist in multiple segments | Each key lives in exactly one place, so range locks attach to the tree easily |
| Typical fit | Write-heavy, large ingest: time series, event logs, messaging | Read-heavy OLTP, strong transactional semantics |
Gotchas the book calls out
- Compaction can starve reads/writes. Disk bandwidth is finite. If ingest outpaces compaction, segments pile up and reads degrade, and many LSM engines don’t throttle incoming writes by default, so watch it.
- Write amplification is the real cost metric. Both engines write more bytes than the application asked for. On SSDs this also wears the drive.
- Benchmarks lie across workloads. Always test on your data and access pattern.
How hardware shapes the design
Both designs are shaped by one constraint: sequential access is far cheaper than random access on storage media.
| Medium | Why it matters |
|---|---|
| HDD | A random access needs a mechanical seek (~ms). Sequential throughput is 100x+ better. LSM’s appends and merges are tuned for this. |
| SSD | No seek, but flash can’t overwrite a cell in place: it must erase a whole block (hundreds of KB to MBs) before writing. The firmware’s flash translation layer hides this by remapping pages and doing its own garbage collection. Small random in-place writes therefore cause extra internal writes. LSM’s large sequential writes fit this well; B-tree in-place updates are what the SSD has to work around. |
| RAM | Memtable and Bloom filters live here. The DB must rebuild them from the WAL or SSTables on restart. |
- Page size ≈ block size. B-tree pages are sized to match disk blocks so one page read is one I/O.
- Large flushes. An LSM memtable flush writes megabytes at once, the pattern both disks and SSDs prefer.
- Why Bloom filters exist. A disk read is millions of times slower than a RAM check, so spending memory to avoid I/O is a good trade.
- Why WALs are everywhere. Even in-place structures need one cheap sequential write to be durable.
- Why fsync matters. Data in the OS page cache is lost on power failure. The WAL must be flushed for the durability guarantee.
Takeaways
- An LSM-tree turns random writes into sequential ones by buffering in memory and flushing sorted, immutable files, and pays for it with compaction and slower, multi-place reads.
- A B-tree keeps a single sorted tree of fixed-size pages updated in place, so reads are predictable, and pays with random writes, page splits and a WAL.
- Neither wins universally: choose by workload (write-heavy ingest vs. read-heavy transactions), and measure write amplification and tail latency, not just throughput.
- The shape of each engine follows the hardware: sequential I/O is cheap, and flash can’t overwrite in place.
Reading: LSM-Trees vs B-Trees - How Storage Engines Store Data
https://sineeli.github.io/2026/10/23/reading-lsm-trees-vs-b-trees/