Skip to the chapter
←System DesignGGroundworkSDSystem Design
System Design/Advanced/A3
23 / 36 written chapters
/

The cover
  1. B1The mental model
  2. B2Client-server basics
  3. B3Vertical vs horizontal
  4. B4Databases in design
  5. B5Caching fundamentals
  6. B6APIs & communication
  7. B7Walkthrough: URL shortener
  1. I1Load balancing in depth
  2. I2CDNs
  3. I3Message queues & async
  4. I4Consistency models
  5. I5Replication
  6. I6Rate limiting & throttling
  7. I7Designing for availability
  8. I8Storage systems
  9. I9Search systems
  10. I10Microservices
  11. I11Security basics
  12. I12Walkthrough: feed/chat
  13. I13Walkthrough: notifications
  1. A1CAP theorem in depth
  2. A2Transactions and isolation
  3. A3Storage engines
  4. A4Sharding at scale
  5. A5Time, ordering and IDs
  6. A6Distributed consensus
  7. A7Batch & stream processing
  8. A8Fault tolerance
  9. A9Observability at scale
  10. A10Release safety
  11. A11Capacity estimation
  12. A12Case studies
  13. A13Walkthrough: key-value store
  14. A14Walkthrough: web crawler
  15. A15Walkthrough: payments
  16. A16Tradeoff thinking

SDA3 · 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.

19 min read3 diagrams8 sections0 exercises
23 of 36

In this chapter

  • 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

and 3 more

about 19 min
08On this page
  1. The question underneath every database
  2. The write-ahead log: why both families have one
  3. B-trees: update in place, read in a few hops
  4. LSM-trees: never update, always append, merge later
  5. Column stores: lay data out for the question, not the row
  6. Putting it together: why the four systems behave as they do
  7. Choosing
  8. 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 VACUUM must later reclaim the dead versions, and a heavily updated table can bloat to the point that VACUUM FULL becomes 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.

  1. Memtable. New writes go into a sorted in-memory structure (RocksDB's default is a skiplist), together with a log record for recovery.
  2. 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.
  3. 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.
  4. Compaction. Background jobs merge SSTables into larger ones, discarding overwritten and deleted versions.
1 log append write memtable sorted, in memory level 0 flushed files levels 1..n each about 10x larger flush when full compaction rewrites data downward: write amplification read key memtable first per file: Bloom filter says "no" (skip) or "maybe" (read) Cheap sequential writes bought with background rewriting, and reads that may consult many files.
Writes touch memory and a sequential log. The cost is deferred to compaction (more bytes written) and to reads (more files to check).

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

StrategyHow it worksFavoursCosts
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+1Reads 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 writesMany overlapping files, so slower reads, and extra disk while merging
Time-window (Cassandra TWCS)Group by write time, never mix windowsTTL'd, mostly immutable time series: whole windows expire at oncePoor 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 workloadsTunable trade-offMore knobs to understand
FIFO (RocksDB)Drop the oldest files at a size limitCache-like or log-like dataNot a store of record
L1 L2 L3 L4 L5 100 MB 1 GB 10 GB 100 GB 1 TB (about 90% of all data) Everything above the last level adds only about 11% to its size.
Why leveled compaction has low space amplification: the geometric series is dominated by its last term.

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.

QuantityArithmeticResult
Capacity through L5100 MB × (1 + 10 + 100 + 1,000 + 10,000) = 100 MB × 11,1111,111,100 MB, about 1.11 TB
Share of the last level10,000 ÷ 11,11190.0%
Worst-case space amplification11,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 consult1 memtable + 4 L0 files + 5 levels (one file each)10
Wasted file reads for a key that does not exist9 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 L58 earlier files × 0.01 + 1 real read0.08 wasted + 1 useful
User writes10,000 rows/s × 100 bytes1,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 B81,920,000 B/s, about 82 MB/s
LSM writes at an assumed write amplification of 201 MB/s × 2020 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).

row store column store id | country | amount | ... 100 columns id | country | amount | ... id | country | amount | ... amount country id ... SUM(amount) reads every row in full SUM(amount) reads one column only With 100 equal 8-byte columns the scan shrinks 100 times before any compression.
Layout decides how much of the disk a query must touch. The same bytes, arranged by column, can be read 100 times faster for the right query.

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 filesWrites 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 dataA 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 behindL0 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 flatMVCC 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 intoThe 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 storeRow layout makes it read every column; column layout reads the few it needs

Choosing

WorkloadReach forBecause
Transactional, read-heavy, mixed updates, range queries on a keyB-tree engine (PostgreSQL, InnoDB)Predictable reads, mature MVCC and secondary indexes
Very high write or ingest rate, time series, logs, storage-boundLSM 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 rowsColumn store (ClickHouse, Parquet on object storage)Reads only the columns asked for, compresses well
Both transactions and analyticsA B-tree OLTP database plus an analytical copy fed from its logEach 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.

✓Practise

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.

A3

Finished reading?

0 of 36 chapters read in this series

→Next stop

  1. A2Previous · A2Transactions and isolation
  2. A3you are here
  3. Next · A4Sharding & partitioning at scaleOne machine is a limit, not a bug — but the shard key you pick in week one is the decision you will still be paying for in year five.23 min read

01On this page

8 sections · 0%

  1. 01The question underneath every database
  2. 02The write-ahead log: why both families have one
  3. 03B-trees: update in place, read in a few hops
  4. 04LSM-trees: never update, always append, merge later
  5. 05Column stores: lay data out for the question, not the row
  6. 06Putting it together: why the four systems behave as they do
  7. 07Choosing
  8. 08Recognizing it in an unseen problem

[] chapters · t top