System designDeep dive9 min
Storage engines: B-trees, LSM trees & WAL
Underneath every database is a storage engine deciding how bytes reach the disk. Two designs dominate: B-trees, which update pages in place, and LSM trees, which only ever append and tidy up later. Both lean on a write-ahead log to survive crashes, and their trade-offs explain why some databases shine at reads and others at relentless writes.
1 · Durability
A write is safe once it is in the log, not once it is in the table
An electricity utility has 3 million smart meters, each reporting every 30 seconds: 3,000,000 ÷ 30 = 100,000 writes a second. Every acknowledged reading must survive a power cut. The slow way would be to find each reading's place in the data files and force that page to disk before answering. Pages are scattered across the disk, so that is one random write, and one wait, per reading.
Instead, almost every database writes a write-ahead log (WAL) first: an append-only file recording each change. The rule is strict: a change may reach the data files only after the log record describing it is safely on disk. A commit waits only for that log flush:
Appending to one file is sequential and fast. The data files catch up later; after a crash, the database replays the log from the last checkpoint and redoes whatever the data files were missing.
Under load, the log flush is shared: one flush can commit many small transactions that arrived together, called group commit. The same log also feeds replicas and point-in-time backups (Database replication).
Setting (PostgreSQL names) | What a crash can cost |
|---|---|
Synchronous commit (the default) | Nothing acknowledged is lost |
Asynchronous commit | The last few transactions, up to about 600 ms by default; the database stays consistent |
fsync off | Unrecoverable corruption; only for data you can rebuild from elsewhere |
2 · B-trees
B-trees rewrite fixed-size pages in place
The billing database next to the meters is a classic relational database, so its tables and indexes are B-trees: trees of fixed-size pages, 8 KB in PostgreSQL and 16 KB in MySQL's InnoDB (Database indexing covers how lookups walk them). To change a value, the engine finds the leaf page holding the key, changes it in memory, and later writes the whole page back to the same place on disk.
When a leaf page has no room for a new key, it splits:
One insert touches three pages: two halves and the parent, which gains a pointer. The WAL makes that multi-page change all-or-nothing after a crash.
Two costs follow from rewriting pages:
- Write amplification. Changing one 100-byte row eventually rewrites its whole 8 KB page: up to 8,192 ÷ 100 ≈ 80 times the bytes, when nothing else on the page changed.
- Torn pages. A crash halfway through writing a page can leave half old and half new data. PostgreSQL therefore logs a full copy of each page the first time it changes after a checkpoint, which adds to the log's size.
In return, reads are predictable. Every key lives in exactly one place, a lookup walks one path from root to leaf, and a range scan reads neighboring leaves in order. Pages are rarely full: InnoDB leaves 1/16 free, and random inserts leave pages between half and fifteen-sixteenths full.
3 · LSM trees
An LSM tree turns every write into an append and sorts later
100,000 meter readings a second, almost never updated and read mostly by meter and time range, suit a different design: the log-structured merge tree (LSM tree), used by RocksDB, LevelDB and Cassandra. It never modifies a file on disk:
Writes go to the log and a sorted in-memory table. A full memtable becomes an immutable sorted file; background compaction merges files into larger, non-overlapping levels.
The pieces:
- Writes append to the WAL and insert into the memtable, a sorted structure in memory. Nothing on disk is searched or rewritten.
- Flushes write a full memtable out as an immutable, sorted file, called an SSTable or SST file, in one sequential pass.
- Deletes and updates are new records too. A delete writes a tombstone saying "this key is gone", and the newest record for a key wins.
Reads pay for this. A lookup checks the memtable, then the files from newest to oldest until it finds the key. Each file has a Bloom filter that says "definitely not here" for most files, so the engine skips them without touching the disk (Bloom filters). Range scans must merge matching keys from every level at once.
4 · Compaction
Compaction is the bill for cheap writes
Left alone, files pile up: reads check more and more of them, and overwritten readings and tombstones waste disk. Compaction runs in the background, merging sorted files into new sorted files and dropping every value that has been overwritten or deleted. Two main strategies trade differently:
Strategy | How it merges | Good at | Costs |
|---|---|---|---|
Leveled | Each level is one sorted run, about 10× the size of the one above | Little wasted space; few files per read | Data is rewritten at every level: total write amplification is often above 10 |
Size-tiered (RocksDB: universal) | Merges similar-sized files into a bigger one | Fewer rewrites, so cheaper writes | More files to check per read, and temporary extra disk during big merges |
The levels grow geometrically, which keeps them few. As an illustration, with a first level of 1 GB and the default size ratio of 10, levels hold 1 GB, 10 GB, 100 GB, 1 TB and 10 TB. A year of readings is 100,000 × 100 bytes × 86,400 × 365 ≈ 315 TB; spread over 100 nodes that is about 3.2 TB each, which fits within five levels.
Compaction also shares the disk with live traffic. When writes outrun it, level 0 fills with overlapping files and pending work piles up, so the engine protects itself with a write stall: it slows each write down, or stops writes entirely until compaction catches up. That makes LSM write latency less predictable than a B-tree's, especially at the high percentiles. Leave disk bandwidth and space headroom for compaction, and watch its backlog as a health metric (Observability: metrics, logs & traces).
5 · The trade-off
Every engine trades read, write and space amplification
Three numbers describe a storage engine's costs:
- Write amplification: bytes written to disk per byte the application wrote.
- Read amplification: disk reads per lookup.
- Space amplification: disk used per byte of live data.
No design minimizes all three. The usual comparison:
Compared | B-tree | LSM tree |
|---|---|---|
Writes | Find and rewrite pages in place; random I/O | Append and flush sequentially; cheaper, especially for heavy write loads |
Point reads | One root-to-leaf path | Memtable plus several files, mostly skipped by Bloom filters |
Range scans | Read neighboring leaves in order | Merge matching keys across levels |
Space | Partly empty pages | Old versions and tombstones until compaction |
Latency | Predictable | Background compaction can stall writes and slow reads |
Used by | PostgreSQL, MySQL InnoDB | RocksDB, LevelDB, Cassandra |
The usual summary, that LSM trees are faster for writes and B-trees for reads, is a tendency, not a law. Caching, compression, the size of values and how well compaction is tuned can each outweigh it, so benchmark your own workload before switching engines.
6 · In practice
Match the engine to the shape of the writes
The utility ends up with both:
Data | Workload | Engine |
|---|---|---|
Meter readings | 100,000 small appends a second, rarely updated, read by meter and time | LSM (a Cassandra or RocksDB-based store) |
Customers, tariffs, bills | Fewer writes, many reads and transactions, ad-hoc reports | B-tree (a relational database) |
For durability, whichever engine you run:
- Keep commits synchronous unless losing the last fraction of a second of writes is explicitly acceptable; never turn fsync off for data you can't rebuild.
- Use disks that honor flushes. A disk cache that acknowledges a flush it hasn't made breaks the WAL's promise.
- Replication is not a substitute for the log: a power cut can hit every replica in the same building.
For each kind of engine:
- For LSM stores, budget for compaction: spare disk bandwidth, spare space for merges, and alerts on pending compaction and stalls.
- For B-trees, watch write amplification from random-key inserts and wide rows, and keep hot indexes in memory.
- Delete in bulk with care in LSM stores: millions of tombstones slow reads until compaction removes them.
Check yourself
5 questions
Takeaways
Remember this
- A write-ahead log makes commits durable with one sequential flush; data files catch up later and crashes are repaired by replay.
- B-trees rewrite fixed-size pages in place: predictable reads, but each small change costs a page write.
- LSM trees append to a memtable and immutable sorted files, making writes cheap and moving the cost into reads and compaction.
- Leveled compaction saves space and reads at the cost of rewriting data many times; size-tiered does the opposite.
- Pick the engine by the workload: heavy appends favor LSM trees, read-heavy transactional data favors B-trees, and both need synchronous, honest flushes.