03

Storage and retrieval

A database does two things: store the data you give it, and give it back when you ask. How it does that inside decides which workloads it handles well. This chapter starts with a two-line shell script and works up through the two big families of storage engines: log-structured ones (hash indexes, SSTables, LSM-trees) and page-oriented B-trees. Then it switches to analytics, where warehouses, star schemas, column storage, compression and data cubes optimize for scanning billions of rows rather than finding one.

The world's simplest database

append-only-logstorage-index
Two bash functions: db_set appends a key,value line to a file and db_get greps for the last match. Writes are as fast as they can be; reads get slower as the file grows.
key

database (a plain text file)

3 lines
1 123456,{"name":"London","attractions":["Big Ben"]}
2 42,{"name":"San Francisco","attractions":["Golden Gate Bridge"]}
3 42,{"name":"San Francisco","attractions":["Exploratorium"]}

what each call did

db_set appended one line to the end of the file. Nothing is overwritten; the old values for the key are still there above it.

echo "$1,$2" >> database

Cost: one sequential append, no matter how big the file is.

cost per operation vs. database size

db_get: O(n) scandb_set: O(1) append
050100150200050100150200records in the loglines touched
records (lines)
3
file size
176 B
2 distinct keys
write cost
1 append
independent of size
read cost
3 lines
grows linearly with the log

Try: db_get 42, then add 50 random writes and db_get again. Writes stay one append; every read rescans the whole file. That gap is what an index is for.

A hash index over the log (Bitcask)

hash-indexsegment-compaction
Keep a hash map in memory from every key to the byte offset of its newest value. To stop the log growing forever, roll it into segments and compact frozen segments in the background, keeping only each key's latest value.
key

in-memory hash map

mewseg 2 @ 28
purrseg 2 @ 9
yawnseg 2 @ 19
hiss—
scratch—

Every key must fit in RAM. Values live on disk.

segment files on disk

oldest → newest
segment 1 · frozen (immutable) · 47 B
mew:1079purr:2104purr:2105mew:1080mew:1081
segment 2 · active, receiving writes · 37 B
mew:1082purr:2106yawn:512mew:1083

bright = live (pointed to by the hash map) · struck-through = overwritten garbage · red = tombstone

Each write appends key:value to the active segment and points the in-memory hash map at its byte offset.
entries on disk
9
84 B across 2 segments
live keys
3
garbage
67%
space reclaimable by merging
disk reads per get
1
one seek to the offset

Try: press +1 play on mew a few times and watch old entries get struck through, then Compact & merge. Play traffic to see segments roll and a background merge keep the file count bounded while gets stay one seek.

Writes go to a sorted in-memory memtable. When it fills up it's written out as an immutable sorted file (SSTable), and compaction merges SSTables like mergesort. Reads check the memtable, then each SSTable from newest to oldest, using a Bloom filter to skip tables and a sparse index to jump to the right block.

memtable (RAM)

lynx=9···

sorted tree in memory; each write also appended to a WAL for crash recovery

SSTables on disk

read order: newest → oldest
#2L0
ibis
ibis=8lynx=5vole=7
wolf
wolf=6
#1L0
hen
hen=3jay=4koi=1
lynx
lynx=2

last event

put lynx=9 into memtable (+ WAL append)

flushes · compactions
2 · 0
write amplification
1.88×
bytes to disk ÷ bytes written by you (WAL + flush + compaction)
SSTables a read may visit
2
compaction keeps this small

Try: stream writes and watch the memtable flush into sorted SSTables, then compaction merge them into one. Then get a key that was never written: the Bloom filters let it skip every table. Delete a key and get it: the tombstone shadows older values until compaction drops it.

B-trees: pages, splits and the write-ahead log

b-treepage-splitwrite-ahead-log
The most widely used index structure. It breaks the database into fixed-size pages and overwrites them in place. Each page holds sorted keys with references to child pages covering the ranges between them. A full page splits in two, and the tree grows from the root.

pages on disk

■ written■ split / new■ lookup path
1020p13035p46070p290p5255080p3

write-ahead log (append-only, fsynced before pages are touched)

14 records
  1. LSN 007 page 1: insert key 30
  2. LSN 008 page 2: insert key 60
  3. LSN 009 page 2: insert key 90
  4. LSN 010 page 1: insert key 25
  5. LSN 011 split page 1 → 1 + new page 4, push 25 up
  6. LSN 012 page 4: insert key 35
  7. LSN 013 page 2: insert key 70
  8. LSN 014 split page 2 → 2 + new page 5, push 80 up
keys
10
branching factor 4
depth
2
≈ log4(10) = 1.7
pages · splits
5 · 3
page fill
67%
splits leave half-empty pages: fragmentation

Try: Play inserts and watch a full page split in two (amber), pushing its middle key up, until the root splits and the tree grows a level. Switch to ascending keys: only the rightmost pages split, leaving a trail of half-full pages. Lower keys-per-page to see depth rise.

Overwriting pages in place is risky: a split writes several pages, and a crash halfway through leaves orphaned or dangling pages. So every change is first appended to the WAL. Concurrent access also needs latches on pages. Log-structured engines avoid both problems because they never modify a file once written.
Rule of thumb: LSM-trees are faster for writes and B-trees for reads. The real answer depends on your workload, record size and hardware. A rough cost model makes the trade-off concrete.

throughput on one spinning disk (log scale)

LSM-treeB-tree
1001.0k10k100k0%20%40%60%80%100%fraction of operations that are writesops / secondyour workload

write amplification (disk bytes per byte written)

LSM-tree
22.0×
4 levels × 10/2 + WAL + flush
B-tree
42.0×
WAL + whole 4096 B page

But the LSM's bytes are sequential; each B-tree write is a random page overwrite. On a spinning disk that difference dominates.

read amplification (disk reads per point lookup)

LSM-tree
1.06
8 sorted runs, Bloom FP 0.8%
B-tree
1.00
inner pages cached

Set Bloom bits to 0: every lookup must probe every level.

LSM-tree
283 ops/s
B-tree
150 ops/s
winner here
LSM-tree
1.9× the other
LSM levels
4
64 MB memtable × 10^L ≥ 100 GB

Try: start on a spinning disk at 50% writes, then slide writes to 0%: the B-tree catches up only on pure reads. Switch to SSD and grow the record size: the B-tree's page-rewrite penalty shrinks and the gap narrows. A crude model, but it shows why the book says to benchmark with your workload.

What the averages hide: compaction runs in the background and shares disk bandwidth with live traffic, so LSM-tree response times at high percentiles can be unpredictable. If writes outpace compaction, unmerged segments pile up until the disk fills. B-trees also keep each key in exactly one place, which makes range locks for transactions simple.

OLTP vs OLAP, and the star schema

oltp-olapdata-warehousestar-schema
Transactional queries fetch a handful of rows by key. Analytic queries scan millions of rows but only a few columns, then aggregate. Warehouses hold a copy of the data in a star schema: a huge fact table of events surrounded by dimension tables.

star schema

joins: dim_date, dim_product
dim_date3,650 rows
date_keyyearmonthdayweekdayis_holiday
dim_product40,000 rows
product_skskudescriptionbrandcategory
dim_store1,200 rows
store_skstatecity
fact_sales8.4 billion rows · fact table
date
product
store
promotion
customer
quantity
net
discount

every row is one sale event; sk columns are foreign keys into the dimensions

dim_customer12 million rows
customer_sknamedate_of_birth
dim_promotion350 rows
promotion_sknamead_typecoupon_type
SELECT d.weekday, p.category, SUM(f.quantity)
  FROM fact_sales f
  JOIN dim_date d    ON f.date_key = d.date_key
  JOIN dim_product p ON f.product_sk = p.product_sk
 WHERE d.year = 2013 AND p.category IN ('Fresh fruit','Candy')
 GROUP BY d.weekday, p.category;

OLTP vs OLAP

transaction processing (OLTP)analytics (OLAP)
read patternsmall number of records, fetched by keyaggregate over a large number of records
write patternrandom-access, low-latency writes from user inputbulk import (ETL) or event stream
used byend user / customer, via web applicationinternal analyst, for decision support
data representslatest state of data (current point in time)history of events that happened over time
dataset sizegigabytes to terabytesterabytes to petabytes
bottleneckdisk seek timedisk bandwidth
workload
OLAP
fact rows read
8.4 billion
full scan (filters applied after)
fact columns used
3 of 8
real warehouses: often 3 of 100+
cells lit (sample)
54
in the 18-row sample grid

Try: flip between "show my order" and "fruit sales by weekday". OLTP lights one whole row via an index; OLAP lights a few thin columns all the way down. That shape is why warehouses store data differently.

Column-oriented storage and compression

column-oriented-storagebitmap-encoding
If queries touch only a few columns of a wide fact table, store each column in its own file. Each column then holds many repeats of a few values, so it compresses well, especially with bitmaps and run-length encoding on sorted data.

SELECT SUM(…) over a few columns

showing 20 of 100 columns · 8 rows
c1
c2
c3
c4
c5
c6
c7
c8
c9
c10
c11
c12
c13
c14
c15
c16
c17
c18
c19
c20
bytes on disk, in file order (row 1 all columns, then row 2…):

■ needed by the query · ■ read from disk anyway (same page) and thrown away

bytes read
800.0 GB
8 bytes per value, before compression
useful fraction
3.0%
scan time @ 1 GB/s
13.3 min
column store speedup
33.3×
cols in table ÷ cols needed

Try: with 100 columns and a 3-column query, flip between row- and column-oriented. A row store must pull every row's 100 values off disk to use 3; a column store reads only the three column files. Push columns to 300 and watch the gap.

product_sk column → one bitmap per distinct value

values
692929292930296930313129316830312930316969302931293130293029303031296829293029302930296968692929
click a bitmap to add it to WHERE product_sk IN (…)
OR

Right column: run-length encoding, alternating counts of 0s and 1s (the last run is implied). Sorting turns each bitmap into one long run.

raw (4-byte ints)
192 B
bitmaps
30 B
5 × 48 bits
bitmaps + RLE
84 B
79 runs, varint-encoded
compression ratio
2.3×

Try: flip to sorted and watch the run lists collapse to a couple of numbers each. Then add values to the IN list: the query is just a bitwise OR of bitmaps, which a CPU does on 64 rows per instruction.

Materialized views and data cubes

materialized-view
Analytic queries keep computing the same aggregates. A data cube precomputes them as a grid over chosen dimensions, with totals along each axis. Reads become lookups, writes have to update the cube, and questions outside its dimensions fall back to the raw data.

SUM(net_price) by product × day, precomputed

click a cell
MonTueWedThuFriSatSunΣ
apples3673693102593204575162598
bananas6074694864534616789624116
candy4434393733462814724682822
bread2713653473254525366402936
milk3243483684002685235962827
Σ201219901884178317822666318215299

answer: apples 2,598 · bananas 4,116 · candy 2,822 · bread 2,936 · milk 2,827

query cost
5 precomputed cells
read the aggregate
fact rows
2,400
the raw data stays the source of truth
write cost of last insert
1 + 4
fact row + cell, row Σ, column Σ, grand Σ
cube cells updated
0
over 0 inserts

Try: insert a sale and watch its cell and three totals update (the price of a materialized view). Switch to the virtual view: writes get cheap, every query rescans. Then ask for SUM by store, a dimension the cube never kept.

  • takeawayIndexes trade write cost for read speed. Log-structured engines (Bitcask, LSM-trees) only ever append and merge immutable files, which turns random writes into sequential ones.
  • takeawayB-trees overwrite fixed-size pages in place, protected by a WAL. Each key has one home, so reads are predictable. LSM-trees usually win on write throughput and compression and pay for it with compaction and multi-table reads.
  • takeawayAnalytics reverses the pattern: scan a few columns of billions of rows. Column storage, bitmap/RLE compression, sort order and materialized aggregates cut down the bytes you have to read.

Key concepts · 21

book chapter 3
  1. Append-only log

    A data file that is only ever added to at the end; updates and deletes are new records, never in-place edits.

    Appending is the cheapest write a disk can do and makes crash recovery simple, but without an index every read has to scan the whole log.

    ↑ see it in the visualization
  2. Index

    An additional structure derived from the primary data that helps locate records quickly.

    Indexes speed up reads but slow down every write, since each one must also update the index. That trade-off is why databases don't index everything by default.

    ↑ see it in the visualization
  3. Hash index

    An in-memory hash map from each key to the byte offset of its latest value in a data file.

    Reads cost one seek and writes one append, which is ideal when there are many updates to few keys. The catch: all keys must fit in RAM, and range queries are impossible.

    Bitcask (Riak)
    ↑ see it in the visualization
  4. Segments, compaction and merging

    The log is split into fixed-size segment files; frozen segments are compacted (keeping only the latest value per key) and merged in the background.

    This reclaims space taken by overwritten values without ever modifying a file in place. Reads and writes continue on the old segments until the merged one replaces them.

    ↑ see it in the visualization
  5. Tombstone

    A special deletion record appended to the log in place of removing data.

    Since nothing is edited in place, a delete has to be a write. Compaction later discards the tombstone and every older value it shadows.

    ↑ see it in the visualization
  6. SSTable (Sorted String Table)

    An immutable segment file whose key-value pairs are sorted by key, with each key appearing once.

    Sorting lets segments be merged like mergesort, needs only a sparse index, and lets blocks be compressed, which removes most of a hash index's limits.

    LevelDBRocksDBCassandraHBase
    ↑ see it in the visualization
  7. Memtable

    An in-memory balanced tree (e.g. red-black or skip list) that buffers recent writes in sorted order.

    When it exceeds a threshold it is written out as a new SSTable in one sequential pass. A write-ahead log protects it against crashes.

    ↑ see it in the visualization
  8. LSM-tree (Log-Structured Merge-tree)

    A storage engine built from a memtable plus a cascade of SSTables that are merged and compacted in the background.

    It turns random writes into sequential ones, giving high write throughput and good compression. In exchange, reads may check several tables and compaction competes with live traffic.

    RocksDBCassandraScyllaDBLucene
    ↑ see it in the visualization
  9. Sparse index

    An in-memory index holding only the first key of each block of a sorted file.

    Because the file is sorted, you can jump to the right block and scan a few kilobytes. A few keys per block suffice instead of one entry per key.

    ↑ see it in the visualization
  10. Bloom filter

    A compact bit array that can say a key is definitely absent or possibly present.

    LSM-trees keep one per SSTable so lookups for missing keys skip almost every table instead of reading them all from disk.

    ↑ see it in the visualization
  11. Size-tiered vs leveled compaction

    Two policies for when and which SSTables to merge: by similar size, or into levels of non-overlapping key ranges.

    Leveled compaction bounds how many tables a read touches and how much space garbage takes, at the cost of more rewriting. Size-tiered writes less but reads more.

    HBase (size-tiered)LevelDB (leveled)Cassandra (both)
    ↑ see it in the visualization
  12. B-tree

    A balanced tree of fixed-size pages (typically 4 KB), each holding keys and references to child pages, overwritten in place.

    With branching factor b and n keys the depth is O(log_b n), so a four-level tree of 4 KB pages with b = 500 holds 256 TB. Every key lives in exactly one place.

    PostgreSQLMySQL InnoDBSQLiteLMDB
    ↑ see it in the visualization
  13. Page split

    When an insert overflows a full page, the page is split in two halves and the parent gets a new key and reference.

    Splits keep the tree balanced and grow it upward one level at a time, but they write several pages at once and leave half-empty pages (fragmentation).

    ↑ see it in the visualization
  14. Write-ahead log (WAL)

    An append-only file where every modification is recorded before it is applied to the tree's pages.

    A crash in the middle of a multi-page split would otherwise leave a corrupted tree. On restart the WAL restores consistency. LSM memtables use one for the same reason.

    ↑ see it in the visualization
  15. Write amplification

    The number of bytes written to disk per byte the application writes, because of WAL, page rewrites and repeated compaction.

    In write-heavy workloads it's what caps throughput. LSM-trees usually amplify less than B-trees and, more importantly, write sequentially.

    ↑ see it in the visualization
  16. OLTP vs OLAP

    Transaction processing (few records by key, low-latency writes) vs analytics (scans and aggregates over huge numbers of records).

    The two access patterns differ so much that they run on separate systems, optimized differently for each.

    ↑ see it in the visualization
  17. Data warehouse and ETL

    A separate read-only database for analysts, loaded from OLTP systems by Extract–Transform–Load jobs or streams.

    Analysts can run expensive queries without hurting the transactional systems, on a schema and storage layout built for analytics.

    SnowflakeBigQueryRedshiftTeradata
    ↑ see it in the visualization
  18. Star schema (fact and dimension tables)

    A central fact table of events, each row referencing dimension tables (who, what, where, when, how) by foreign key.

    It's the standard warehouse model. Fact tables are huge and often very wide, while dimensions are small. A snowflake schema further normalizes the dimensions.

    ↑ see it in the visualization
  19. Column-oriented storage

    Storing all values of each column together in separate files instead of each row together.

    An analytic query that touches 4 of 100 columns reads only those 4 columns' data, cutting disk bandwidth by an order of magnitude or more.

    ParquetClickHouseVerticaDuckDB
    ↑ see it in the visualization
  20. Bitmap and run-length encoding

    One bitmap per distinct value of a column, then compressed by storing run lengths of zeros and ones.

    Columns with few distinct values compress dramatically, especially when sorted. Filters become bitwise AND/OR on compressed bitmaps, executed in vectorized fashion.

    ↑ see it in the visualization
  21. Materialized view and data cube

    A stored copy of a query's results (e.g. a grid of aggregates by dimension) that's updated when the underlying data changes.

    Common aggregates become lookups instead of scans, but every write gets more expensive and the cube can't answer queries on dimensions it doesn't keep.

    ↑ see it in the visualization