A3 · Part 3 · Advanced
Storage engines
Every database is a bet about how data should sit on disk, and most of what surprises you in production (slow writes, slow deletes, bloat, compaction storms) follows from that bet.
08On this page
- The question underneath every database
- The write-ahead log: why both families have one
- B-trees: update in place, read in a few hops
- LSM-trees: never update, always append, merge later
- Column stores: lay data out for the question, not the row
- Putting it together: why the four systems behave as they do
- Choosing
- Recognizing it in an unseen problem
The question underneath every database
A storage engine answers two questions. Where do the bytes go on disk when a write arrives? And how does a key find its bytes again? Two families dominate. B-trees keep data in fixed-size pages that are updated in place: PostgreSQL's pages are usually 8 kB, and its documentation describes every table and index as an array of such pages. Log-structured merge-trees (LSM-trees) never modify data in place: they append, keep recent writes in memory and sorted files on disk, and merge those files in the background. A third family, column stores, changes the layout again for analytics. The databases from earlier chapters pick sides: PostgreSQL and InnoDB are B-tree engines; Cassandra and RocksDB are LSM engines; ClickHouse is a column store with an LSM-like merge.
To compare them you need a vocabulary of costs, the three amplifications:
- Write amplification: bytes the engine writes to storage for each byte the application wrote. It determines SSD wear and the write throughput a disk can sustain.
- Read amplification: how many disk reads or files a lookup touches.
- Space amplification: bytes on disk divided by bytes of live data.
The honest summary of the field, as the paper on RocksDB space amplification puts it, is that it is not possible to reduce space, read and write amplification all at once, so every tuning knob moves something else. This chapter gives each engine's position on that triangle and the arithmetic behind it. How a database exposes its choices to the application is covered in the earlier databases and storage chapters; the isolation rules that MVCC enforces are in the transactions chapter.
The write-ahead log: why both families have one
Memory is fast and volatile; disk is durable and slow to update randomly.
Both families solve this the same way: before changing the structure, append
a description of the change to a sequential log and force the log to disk.
PostgreSQL's manual states the rule directly: changes to data files must be
written only after the WAL records describing them have been flushed to
permanent storage. Three consequences follow, and the manual lists them:
data pages need not be flushed on every commit, because a crash can be
repaired by replaying the log (roll-forward, or REDO); only the log needs to
reach disk to make a commit durable, and since it is written sequentially,
syncing it is much cheaper than flushing many scattered data pages; and one
fsync of the log can commit many concurrent transactions at once.
LSM engines use the log for the memtable, which would otherwise vanish in a
crash. RocksDB writes each update to the memtable and optionally to a
write-ahead log, and when the memtable is flushed to a file, the matching
log can be deleted. Durability then depends on a setting: RocksDB's
WriteOptions choose whether a write is logged and whether the
log is fsynced before the write is acknowledged. Cassandra's
commit log defaults to periodic sync every 10 seconds, so a
single machine that crashes can lose up to that window or more of acknowledged writes, and the replicas, not the disk, are what save the data; in
batch mode a write is not acknowledged until the log is
fsynced. This is the single-node view of the durability trade-off discussed
across machines in the replication chapter.
B-trees have a second hazard: a page write is bigger than what a disk can
write atomically, so a crash can leave half of a page written. InnoDB handles
it with a doublewrite buffer: pages flushed from the buffer pool are written
first to a separate area, and if a crash interrupts a write to the data file,
recovery finds a good copy there. MySQL's documentation notes that although data is written twice, it does not cost twice the I/O, because the first write is one large sequential chunk with a single fsync. PostgreSQL guards against the same hazard differently, by writing full-page images into the WAL after each checkpoint.
B-trees: update in place, read in a few hops
A B-tree is a balanced tree of pages. Interior pages hold keys and pointers to children; leaf pages hold the entries. A lookup walks from the root to a leaf, one page per level. Because each page holds hundreds of entries, the tree is shallow. With an illustrative 400 entries per page, three levels address 4003 = 64,000,000 keys and four levels address 4004 = 25,600,000,000. A table of a billion rows needs four levels, and since the root and the next level are hot in memory, a point lookup costs about one or two disk reads for the index (a heap table such as PostgreSQL's needs one more for the row). PostgreSQL's documentation observes that typically over 99% of a B-tree's pages are leaf pages, which is why the upper levels cache so easily.
A write finds the leaf, changes the page in the buffer pool, appends to the WAL, and lets the dirty page reach disk later. If the leaf is full it splits: a portion of its entries move to a new page, and a new pointer goes into the parent, which may split in turn. PostgreSQL's manual says splits cascade upwards, and when the root cannot fit another pointer, a root split adds a new level. Hence the shape of a B-tree's costs: reads are cheap and predictable; writes are random page updates.
What it costs in practice
- Write amplification from page granularity. Changing a 100-byte row can mean writing an 8,192-byte page: 8,192 ÷ 100 ≈ 82 times the user's bytes, plus the WAL record. Many updates to one page are absorbed by the buffer pool before a flush, so 82 is an upper bound on the page-flush term for random updates, not an average, and WAL records, full-page images and InnoDB's doublewrite come on top.
- MVCC leaves dead versions. PostgreSQL's UPDATE does not overwrite a row; the old version stays visible to older snapshots, so
VACUUMmust later reclaim the dead versions, and a heavily updated table can bloat to the point thatVACUUM FULLbecomes necessary. Its documentation also recommends moderately frequent ordinary vacuums over rare heavy ones. A heap-only tuple (HOT) update avoids new index entries when no indexed column changes and the page has room, which is why lowering a table's fillfactor can speed up update-heavy tables. - Clustered or heap. In InnoDB the table is a B-tree keyed by the primary key, and each secondary-index entry stores the primary key columns, so a lookup through a secondary index is two tree walks and a long primary key makes every secondary index bigger. PostgreSQL stores rows in a separate heap and every index points into it.
- Insert order matters. InnoDB's documentation says sequential inserts leave pages about 15/16 full, while random inserts leave them anywhere from 1/2 to 15/16 full. A random primary key such as a UUIDv4 turns every insert into a random page write and wastes space; time-ordered identifiers, covered in the time and ordering chapter, avoid it.
LSM-trees: never update, always append, merge later
The LSM-tree was proposed by O'Neil and colleagues for indexing a table with a very high insert rate, as a memory component plus disk components merged by a rolling merge. They argued that a standard disk B-tree effectively doubles the I/O cost of a transaction to maintain such an index, and that deferring and batching index changes, cascaded from memory through disk components in a way "reminiscent of merge sort", greatly reduces disk-arm movement. Bigtable (2006) put the idea into widespread production, and LevelDB, RocksDB and Cassandra descend from it. The modern form has four parts.
- Memtable. New writes go into a sorted in-memory structure (RocksDB's default is a skiplist), together with a log record for recovery.
- Flush. When the memtable fills it becomes immutable, a new one takes over, and the old one is written to disk as a sorted string table (SSTable): a sorted, immutable file. Cassandra's documentation is explicit that SSTables are never written to again.
- Read. A lookup checks the memtable, then the SSTables, newest first, and stops at the first hit. A key's versions may be spread over many files.
- Compaction. Background jobs merge SSTables into larger ones, discarding overwritten and deleted versions.
Bloom filters make the many-files read affordable
A Bloom filter is a small bit array that answers "might this file contain key K?" with either "definitely not" or "maybe". It never gives a false negative, so a "no" lets the engine skip the file. RocksDB builds one per SSTable once a filter policy is configured (it is off by default), and recommends about 10 bits per key, which its documentation tabulates as a 1% false-positive rate (9.9 bits per key gives 1%; 4.9 gives 10%; 15.5 gives 0.1%). Bigtable's authors describe the same trick: a small amount of memory for filters drastically reduces disk seeks. Bloom filters help point lookups, especially for keys that do not exist. A range scan must still merge across every sorted run that overlaps the range.
Compaction strategies, and which cost each one buys
| Strategy | How it works | Favours | Costs |
|---|---|---|---|
| Leveled (RocksDB's default; Cassandra's LCS) | Each level beyond L0 is one sorted run, about 10 times larger than the one above; compacting picks a file from level n and merges it with the overlapping files of level n+1 | Reads and space: one candidate file per level; space amplification can stay near 1.11 with dynamic level sizing (see below) | Write amplification, which RocksDB's documentation says is often larger than 10 |
| Size-tiered (Cassandra's STCS, the default there) | Merge groups of similarly sized SSTables once enough exist (min_threshold 4 by default) | Cheap writes | Many overlapping files, so slower reads, and extra disk while merging |
| Time-window (Cassandra TWCS) | Group by write time, never mix windows | TTL'd, mostly immutable time series: whole windows expire at once | Poor fit for updates or out-of-order data |
| Universal / unified (RocksDB universal; Cassandra UCS) | Adaptive tiered-and-leveled merging. RocksDB's documentation says universal optimises write amplification by merging many files at once; Cassandra recommends UCS for most new workloads | Tunable trade-off | More knobs to understand |
| FIFO (RocksDB) | Drop the oldest files at a size limit | Cache-like or log-like data | Not a store of record |
Dry run: a point read and a write through a five-level LSM
Take the level sizes in the figure (L1 100 MB, each level ten times the last, a multiplier of 10 that Facebook reports using in most of its production RocksDB installations). Assume L0 has 4 overlapping files, and Bloom filters at the documented 10 bits per key, a 1% false-positive rate.
| Quantity | Arithmetic | Result |
|---|---|---|
| Capacity through L5 | 100 MB × (1 + 10 + 100 + 1,000 + 10,000) = 100 MB × 11,111 | 1,111,100 MB, about 1.11 TB |
| Share of the last level | 10,000 ÷ 11,111 | 90.0% |
| Worst-case space amplification | 11,111 ÷ 10,000 (everything above the last level is garbage) | 1.111, as the RocksDB space-amplification paper states for a multiplier of 10 with dynamic level sizing (the default since RocksDB 8.4) |
| Sorted runs a lookup may have to consult | 1 memtable + 4 L0 files + 5 levels (one file each) | 10 |
| Wasted file reads for a key that does not exist | 9 files × 0.01 false-positive rate (the memtable needs no filter) | 0.09 expected, against 9 with no Bloom filters. Facebook skips the filter at the last level to save memory, which makes it 8 × 0.01 + 1 = 1.08 |
| Wasted reads for a key that lives in L5 | 8 earlier files × 0.01 + 1 real read | 0.08 wasted + 1 useful |
| User writes | 10,000 rows/s × 100 bytes | 1,000,000 B/s = 1 MB/s |
| B-tree page flushes, random updates, upper bound on that term (excludes WAL, full-page images, doublewrite) | 10,000 × 8,192 B | 81,920,000 B/s, about 82 MB/s |
| LSM writes at an assumed write amplification of 20 | 1 MB/s × 20 | 20 MB/s, about 4 times less than the B-tree page-flush bound |
The write-amplification figure of 20 is an assumption chosen to be in the range RocksDB's documentation describes ("often larger than 10"), and the B-tree figure is an upper bound on one term that the buffer pool lowers. Neither is a benchmark. A real measurement does point the same way: Facebook's review of production MySQL installations found RocksDB used about half the space of compressed InnoDB and wrote between 10% and 15% as many bytes, at the price of 10% to 20% more reads, which it considered well within acceptable limits.
What compaction costs you at 3 a.m.
- Deletes are writes. An LSM engine cannot erase a key from files it never rewrites; it writes a tombstone, and only compaction discards the data and the marker. Cassandra's default tombstone grace period is ten days, which is why deleted data keeps costing reads and why the repair deadline in the replication chapter exists.
- Compaction competes with traffic. It reads and rewrites bytes the application did not ask about. A backlog of unmerged files makes reads slower: RocksDB's documentation notes that too many L0 files hurt read performance in most queries.
- Disk headroom. While files are being merged, the inputs and the output coexist, so a disk close to full can leave the engine unable to compact.
Column stores: lay data out for the question, not the row
Transaction processing reads and writes whole rows, which suits row layout (and B-trees). Analytics asks different questions: "total revenue by country last quarter" touches two or three columns of billions of rows. A column store keeps each column's values together. Parquet files, for instance, divide the data into row groups, each holding one column chunk per column, with columns divided into pages that are the unit of encoding and compression. ClickHouse's MergeTree stores each column in its own file in its wide part format (small parts default to a compact format with one file).
The arithmetic: a table of 100 columns of 8 bytes is 800 bytes a row, so a billion rows is 800 GB. A row store scanning one column still reads about 800 GB of pages; a column store reads that column's 8 GB, a hundredth of it, before compression, which works better still because a column holds values of one type that often repeat. The other half of the trade is that a single-row insert in a wide layout touches every column file, and an in-place update is awkward, so column stores take data in batches. ClickHouse's design shows how: each insert creates a new part, sorted by the primary key, and background merges combine parts, which is the LSM idea applied to columns. Its primary index is sparse, holding one mark per granule of up to 8,192 rows by default, so a billion rows need only about 109 ÷ 8,192 ≈ 122,000 marks and the index stays in memory.
Putting it together: why the four systems behave as they do
| You observe… | Because… |
|---|---|
| Cassandra absorbs a heavy write stream cheaply, but a read of a cold key touches several files | Writes go to the commit log and memtable only; the structure is repaired later by compaction. Reads consult Bloom filters, then SSTables |
| Cassandra deletes slow queries until compaction catches up, and a lost repair can resurrect data | A delete is a tombstone written like any other mutation; it is purged only after the grace period, by a compaction that also covers any older data it shadows |
| RocksDB latency spikes when background compaction falls behind | L0 files accumulate and every read must check more of them; rewriting data downward competes with foreground I/O |
| PostgreSQL's update-heavy tables grow even though the row count is flat | MVCC leaves dead versions until VACUUM; HOT updates and a lower fillfactor reduce the churn |
| An InnoDB table with a long or random primary key is large and slow to insert into | The row lives in the primary key's B-tree, secondary indexes carry the key, and random inserts split pages |
| A scan-and-aggregate dashboard query over a billion rows is far cheaper on a column store than on a row store | Row layout makes it read every column; column layout reads the few it needs |
Choosing
| Workload | Reach for | Because |
|---|---|---|
| Transactional, read-heavy, mixed updates, range queries on a key | B-tree engine (PostgreSQL, InnoDB) | Predictable reads, mature MVCC and secondary indexes |
| Very high write or ingest rate, time series, logs, storage-bound | LSM engine (Cassandra, RocksDB-based stores) | Sequential writes, and lower write amplification than a B-tree for small random updates (leveled compaction itself rewrites each byte 10 times or more), at the price of compaction and read cost |
| Scans and aggregates over few columns of many rows | Column store (ClickHouse, Parquet on object storage) | Reads only the columns asked for, compresses well |
| Both transactions and analytics | A B-tree OLTP database plus an analytical copy fed from its log | Each layout does the job it is built for; replication delivers the changes |
Say it like this → "This is a write-heavy ingest path
with point reads by key, so I'd favour an LSM engine such as Cassandra: a
write is a log append and a memtable insert, and compaction pays the
rewriting cost off the hot path. I'd budget for it: leveled compaction
rewrites each byte maybe ten or twenty times, so I'd size disks for the
compaction headroom and watch the pending-compaction count. Deletes are
tombstones, so I'd use TTL with time-window compaction if the data expires
by time. Reporting goes to a column store fed from the same stream, because
scanning one of a hundred equal-width columns of a billion rows is up to a hundred times less I/O in that layout."
Recognizing it in an unseen problem
- Signals: "millions of writes a second", "time series", "write-heavy", "append-only log", "analytics over billions of rows", "storage cost". Each is a prompt to name an engine and its amplification.
- The naive design says "use a database" without noticing that a B-tree write is a random page update and an LSM write is a sequential append. Name which cost your workload is dominated by: random writes, read latency or disk space.
- Quantify. Write amplification times user write rate is the real disk load: 1 MB/s of user writes at amplification 20 is 20 MB/s. Space amplification times data size is the disk you buy.
- Mention the maintenance debt. LSM means compaction, tombstones and headroom; B-tree means vacuum, page splits and bloat; column stores mean batching. An answer that names the debt sounds like experience.
- The pitfall to avoid: saying "LSM is faster". It is faster for writes and slower or more variable for reads, and the numbers depend on the compaction strategy.
Before you move on
- Define write, read and space amplification, and say why you cannot minimise all three.
- Explain why a write-ahead log makes commits cheaper, and what Cassandra's periodic commit-log sync means for one machine's crash.
- Trace a write and a point read through an LSM engine, including where Bloom filters help and where they cannot.
- Compute the space amplification of leveled compaction with a multiplier of 10, and the expected wasted file reads for a missing key with 9 files at a 1% false-positive rate.
- Explain why summing one column of 100 is about 100 times less I/O in a column store, and what column stores give up.
Sources and further reading
- O'Neil et al.: The Log-Structured Merge-Tree (LSM-Tree) — the original design, and its argument against B-trees for high insert rates
- RocksDB overview — memtable, write-ahead log, SSTables, flush and compaction styles
- RocksDB: leveled compaction — L0 triggers, merging into the next level, write amplification
- RocksDB: Bloom filters — bits per key against false-positive rate
- Dong et al.: Optimizing space amplification in RocksDB (CIDR 2017) — the 1.111 bound, the size multiplier, and RocksDB against InnoDB measurements
- Cassandra: storage engine — commit log sync defaults, memtables, immutable SSTables, Bloom filters
- Cassandra: compaction overview — STCS, LCS, TWCS and UCS, tombstones and the grace period
- PostgreSQL: write-ahead logging — why the log comes first and what it saves
- PostgreSQL: B-tree indexes — page splits that cascade upward, root splits, over 99% of pages being leaves
- PostgreSQL: routine vacuuming — dead row versions, bloat, VACUUM FULL
- MySQL 8.4: InnoDB physical structure of an index — 16 KB pages, page fill for sequential and random inserts
- ClickHouse: MergeTree — parts, background merges, sparse primary index, one file per column
Claims in this chapter were checked against these sources in October 2026. Figures marked "illustrative" or "assumed" (400 entries per page, a write amplification of 20, 4 L0 files) are worked inputs, not measurements.
5 questions drawn from 8, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.
Finished reading?
0 of 36 chapters read in this series