Books Deep-Read · DDIA · Chapter 3
Designing Data-Intensive Applications · Ch 3 · Martin Kleppmann · 2017
Every order you place, every message you send, eventually has to land on some spot on a disk—and the next time you look, it has to be found again, fast. That's the whole job of a database's guts: put data down, get it back. Chapter 3 lifts the lid and shows you two very different ways to store data, and why the database "people use" and the database "analysts use" are, deep down, not the same animal at all.
Think of a database as a bookkeeper with two styles. The running-ledger style: every transaction just gets appended to the end of the notebook, never going back to edit old entries; when a little notebook fills up, it's copied out into a big ledger that's sorted alphabetically, and at night several old ledgers get merged and tidied. The ring-binder style: an alphabetically-tabbed binder where, to change an entry, you flip to that page and erase-and-rewrite it. Both work, but they have opposite temperaments—one writes blazingly fast, the other finds things rock-steady.
The laziest way to store data is the running ledger: append each record to the end of a file—writing is lightning fast. But to find one record you must scan from the top; with lots of data that's a disaster. So you build an index: like the index at the back of a book, it lets you "jump straight to the page for this name." But there's no free lunch: an extra index makes reads faster, yet every write now has to update the index too, so writes get slower. That's why a database won't index everything for you—you have to choose.
The running-ledger family (databases call it LSM) writes blazingly fast because it only ever appends and never turns back to edit; the price is that finding a record may mean flipping through several ledgers, and while it merges old ledgers in the background it occasionally competes with your normal reads and writes for the disk, making the odd request stall. The ring-binder family (the famous B-tree) finds things steadily, edits cleanly, and keeps each record in exactly one slot; the price is that a write means flipping back to erase-and-rewrite in place, and every page leaves a little blank space—some waste. Facebook once switched its social data from ring-binder to running-ledger and cut disk usage by more than sixty percent.
Normally a database stores data "one person, one whole row": looking up everything about you is fast, it's all in one place. But when the boss wants "the average age of all users," the system has to flip through hundreds of millions of rows and use only the "age" field from each—reading a mountain of stuff it doesn't need. So databases built for analytics do the opposite—store by column, sideways: everyone's "age" piled together, everyone's "city" piled together. Averaging reads only the age pile; and because one column looks alike (all ages are numbers), it compresses down tiny. That's why companies build a separate "data warehouse" for reports instead of running them on the database you use every day.
A database stores data in one of two families: the running ledger (writes fast, merges in the background) and the ring binder (reads steady, edits in place); then look at purpose—store by row for people, by column for analytics. Pick the right guts and the same query can run tens of times faster.
Want the actual mechanisms, structure diagrams and real systems? → switch to Deep mode
Chapter 2 asked "what does data look like from the application's point of view"; Chapter 3 drops one level and asks the harder question: how does a database actually write data to disk, and how does it find it again? The chapter is framed by two oppositions—storage engines come in two great families, log-structured (LSM-tree) and page-oriented (B-tree); and workloads come in two, transaction processing (OLTP) and analytics (OLAP), the latter giving rise to column-oriented storage. Understand what happens inside the engine and you can finally pick—and tune—the right database.
4 KB); a B-tree reads and writes one whole page at a time.This chapter is still in Part I, "Foundations of Data Systems." Chapter 2 looks up—how you model data as relational / document / graph; Chapter 3 looks down—how that data is actually laid out and found on disk, and Chapter 4 follows with "how to encode it into bytes without breaking compatibility." In real life this chapter is exactly the decisions you make daily: should I use MySQL or Cassandra? Why did this analytics query scan for ages? Should this table get another index? Without understanding the engine, these are all guesswork.
You probably won't hand-write a storage engine, but you must pick one, and know how to tune it. The cost of picking wrong is real: a database tuned for transactions is unusably slow at analytics, and vice versa. To choose wisely you need a rough but correct mental model of "what happens under the lid."
The starting point of that model is an almost absurdly simple fact: the simplest database is just appending to the end of a file. Writing is O(1)—append a line, absurdly fast; but reading is O(n)—finding one record means scanning top to bottom, which collapses as data grows. Hence the index, and the iron law that runs through the whole chapter: every index trades slower writes for faster reads. This is what the chapter answers: how real engines index, and which side of that ledger each one lands on.
DDIA opens with a few lines of shell: db_set appends key,value to a file, db_get uses grep to find the last one from the top. Writing is fast, reading is terrible. To rescue reads you maintain an index—an extra structure derived from the primary data. The core trade-off fits in one line: an index makes reads faster but writes slower, because every write must also update every relevant index. So databases don't index everything by default—which columns to index is a choice you make.
The most naive index is an in-memory hash map: key → the byte offset of that key in the log file. Write: append to the log, update the map; read: look up the offset, seek straight there. This is Bitcask (Riak's default engine). Fast, but with two hard limits: the hash map must fit entirely in memory; and keys are unordered, so range queries ("find all keys between 100 and 200") are impossible. And the log only grows, so it eventually fills the disk—the fix is to break it into segments and run compaction in the background: merge old segments, keep only the latest value per key, delete via a tombstone marker.
Sort each segment by key and it becomes an SSTable (Sorted String Table). That one property—being sorted—buys three big things: first, merging streams like merge sort, without reading whole segments into memory; second, the index can be sparse—one key every few KB is enough, so a read locates the rough range then scans; third, records in a range can be compressed as a block before hitting disk, saving space and bandwidth.
But writes arrive out of order—how do you keep disk sorted? The answer is tiering: keep an in-memory sorted balanced tree (the memtable—a red-black tree or skip list); writes go into the memtable first; when it grows past a few MB, the whole thing is flushed to disk as a sorted SSTable segment and never modified again. Reads check the memtable, then scan SSTables newest to oldest. The background continuously compacts small segments into bigger ones. To keep the memtable from being lost in a crash, a WAL is appended before each memtable write.
This structure is the LSM-tree (Log-Structured Merge-Tree), from Patrick O'Neil et al.'s 1996 paper. LevelDB, RocksDB, Cassandra, HBase, ScyllaDB are all this, and the full-text engine Lucene's term dictionary uses the same idea. A key optimization is the Bloom filter: an extremely memory-thrifty probabilistic structure that can quickly say "this key is definitely not in that segment," skipping a doomed disk read—especially handy when looking up keys that don't exist.
The B-tree is the most widely used, most standard index—almost every relational database and many non-relational ones use it. Its approach is the opposite of LSM: instead of chopping the database into variable-length log segments to append, it chops it into fixed-size pages (traditionally 4 KB) and reads/writes one whole page at a time. Pages reference each other by disk address—like memory pointers, only pointing at disk. One page is the root, holding some keys and pointers to child pages; follow the pointers level by level down to a leaf page holding the actual value.
The number of child pages per page is the branching factor, typically several hundred—so the tree is short and fat, with depth O(log n). DDIA's figure is vivid: branching factor 500, 4 KB pages, and 4 levels can store 256 TB—any record is at most 4 page-reads away. Changing data is an update in place: find the leaf page, edit, and write the whole page back to its original spot; if a page is full it splits into two and the parent's pointers are updated. This is a sharp contrast to LSM's "never touch old files."
Update-in-place has a reliability hazard: a page split rewrites several pages, and a crash midway can leave it half-written and corrupt the tree. The B-tree's answer is also a WAL (here called a redo log)—before changing a page, append the change to the log, then replay the log to recover after a crash. Multiple threads touching the same tree also need latches (lightweight locks) to prevent reading a half-written page.
The above is the primary-key index; in practice there's a family of variants, each explained on sight: a secondary index builds a directory on a non-primary column (e.g., look up a user by email), and both LSM and B-tree support them. A clustered index stuffs the entire row right into the index's leaf page, so a lookup gets the row with no second hop—MySQL's InnoDB primary key works this way. A covering index carries a few extra columns so some queries are "index-only," never touching the main table. A multi-column (concatenated) index combines several columns into one key; geospatial queries use a specialized R-tree. And there are in-memory databases (Redis, Memcached, VoltDB): they're fast not mainly because they "avoid reading disk," but because they skip the overhead of "encoding in-memory structures into bytes for durability."
Everything above is OLTP—read/write a few records at a time, point-lookup by key, powering your orders and transfers. But companies have another job: OLAP (analytics)—one query scans millions or billions of rows yet cares about only a few columns (e.g., "total sales by region, by month"). Running analytics on OLTP row storage means, just to average one column, hauling every whole row off disk and throwing most of it away—hugely wasteful. So enterprises typically build a separate data warehouse, using ETL (extract-transform-load) to copy operational data over for analytics, often in a star schema (a central fact table with dimension tables around it).
The warehouse's killer feature is column-oriented storage: instead of "all columns of one row stored adjacently," it's "all rows of one column stored adjacently." An analytics query reads only the columns it needs, so disk I/O drops immediately. Better still, values in one column are highly similar (a "city" column repeats just a few hundred values), so compression is excellent—DDIA's bitmap encoding plus run-length encoding often squeezes a column to a fraction of its size, and combined with CPU vectorized batch processing, analytics throughput can beat row storage by one to two orders of magnitude. The cost is that writes get awkward: inserting a row must be split and written into every column, so column storage is used almost only for "bulk load, heavy query" analytics, never for frequently-changing online business.
The soul of this chapter is three trade-offs. First the two engine families—LSM-tree vs B-tree—the one table you most want in your head when choosing a database:
Table 1 · LSM-tree (log-structured) vs B-tree (page-oriented)
| LSM-tree (log-structured) | B-tree (page-oriented) | |
|---|---|---|
| Write style | append-only, sequential to disk, never rewrites old files | update in place, whole page, includes random writes |
| Write throughput | higher—sequential is fast, write amplification usually lower | lower—each edit reads-modifies-writes a whole page |
| Space / compression | leaner—no in-page fragmentation, block compression (Facebook measured 62% saved) | in-page slack + split fragmentation, larger footprint |
| Reads | may scan several segments (eased by Bloom filters + sparse index) | each key in one place, path short and stable |
| Latency predictability | worse—background compaction steals disk bandwidth, occasional tail stalls | steadier—no background merge to interfere |
| Transactions / locking | a key spread across segments makes locking trickier | each key in one place, naturally lock-friendly, smoother isolation |
| Representative systems | RocksDB, Cassandra, HBase, ScyllaDB, LevelDB | PostgreSQL, MySQL (InnoDB), nearly all traditional RDBMS |
| Better for | write-heavy, space-conscious, tolerant of occasional tail latency | read-heavy, strong transactions, stable low latency |
Second is the workload—row-oriented OLTP vs column-oriented OLAP—essentially "point-lookup a few records" vs "scan huge rows, take a few columns":
Table 2 · Row-oriented OLTP vs column-oriented OLAP
| Row · OLTP (transactions) | Column · OLAP (analytics) | |
|---|---|---|
| Typical query | read/write a few records by key (order, view profile) | scan millions–billions of rows, aggregate a few columns |
| Disk layout | a row's columns stored adjacently | a column's rows stored adjacently |
| Compression | moderate | excellent—similar values per column (bitmap / RLE) |
| Writes | frequent, random, low latency | mostly bulk load, rarely changed |
| Representative systems | MySQL, PostgreSQL, Oracle | Redshift, BigQuery, Snowflake, ClickHouse, Vertica; file formats Parquet / ORC |
Third runs throughout: the trade-off of an index itself—one more index means faster reads, slower writes, and more space. So don't index every column; index the ones actually used as query conditions. The industry sums this up as the RUM conjecture: read amplification, write (update) amplification, and memory (space) amplification cannot all be optimal at once—optimizing one usually sacrifices the other two. LSM lowers write amplification but raises read amplification, B-tree the reverse—a living illustration of the conjecture.
This chapter hands you a pair of X-ray glasses: see Cassandra / HBase / RocksDB and you know it's LSM underneath—high write throughput, space-lean, but compaction steals bandwidth; see PostgreSQL / MySQL InnoDB and you know it's a B-tree—steady reads, strong transactions, update-in-place; see Redshift / BigQuery / Snowflake / ClickHouse and you know it's columnar, built to "scan huge, compute a few columns," not for high-frequency point lookups. When an interview asks "why Cassandra for write-heavy, why a separate warehouse for analytics," the answer lives in these three trade-offs. And these claims aren't armchair theory—big companies have backed them with public, checkable practice:
① In one sentence: a database's guts do two things—how to store, how to find; storage engines split into the log-structured (LSM) and page-oriented (B-tree) families.
② The iron law of indexes: every index trades "slower writes + more space" for "faster reads"; so index selected columns, not everything.
③ The LSM line: an in-memory memtable buffers writes → flushed into immutable sorted SSTables → compacted in the background; Bloom filters skip doomed disk reads. Append-only, blazing writes, space-lean.
④ B-tree: data cut into 4 KB pages nested by pointers, updated in place, each key in one spot; 4 levels address 256 TB, steady reads, strong transactions, a WAL for crash safety.
⑤ Family trade-off: LSM is write-heavy / space-lean but compaction steals bandwidth and tail latency jitters; B-tree is read-heavy / strong-transaction, latency steadier (RUM conjecture: read, write, space amplification can't all win).
⑥ A different world: OLTP (point-lookup a few records, row-oriented) vs OLAP (scan huge rows for a few columns, column-oriented); enterprises build a separate warehouse, ETL in, star schema.
⑦ Column storage: a column's rows stored adjacently, analytics reads only needed columns + compresses well, throughput up one to two orders of magnitude; the cost is awkward writes, fit only for bulk load.
⑧ In practice: Cassandra/HBase/RocksDB = LSM, PostgreSQL/MySQL = B-tree, Redshift/BigQuery/ClickHouse = columnar—Facebook's MyRocks saved 62% space, C-Store→Vertica proved columnar; all living examples.