Storage and retrieval
database (a plain text file)
3 lineswhat 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
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.
in-memory hash map
| mew | seg 2 @ 28 |
| purr | seg 2 @ 9 |
| yawn | seg 2 @ 19 |
| hiss | — |
| scratch | — |
Every key must fit in RAM. Values live on disk.
segment files on disk
oldest → newestbright = live (pointed to by the hash map) · struck-through = overwritten garbage · red = tombstone
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.
SSTables and LSM-trees, step by step
memtablesstablelsm-treesparse-indexbloom-filtertombstonecompaction-strategymemtable (RAM)
sorted tree in memory; each write also appended to a WAL for crash recovery
SSTables on disk
read order: newest → oldestlast event
put lynx=9 into memtable (+ WAL append)
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.
pages on disk
■ written■ split / new■ lookup pathwrite-ahead log (append-only, fsynced before pages are touched)
14 records- LSN 007 page 1: insert key 30
- LSN 008 page 2: insert key 60
- LSN 009 page 2: insert key 90
- LSN 010 page 1: insert key 25
- LSN 011 split page 1 → 1 + new page 4, push 25 up
- LSN 012 page 4: insert key 35
- LSN 013 page 2: insert key 70
- LSN 014 split page 2 → 2 + new page 5, push 80 up
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.
throughput on one spinning disk (log scale)
write amplification (disk bytes per byte written)
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)
Set Bloom bits to 0: every lookup must probe every level.
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.
star schema
joins: dim_date, dim_productevery row is one sale event; sk columns are foreign keys into the dimensions
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 pattern | small number of records, fetched by key | aggregate over a large number of records |
| write pattern | random-access, low-latency writes from user input | bulk import (ETL) or event stream |
| used by | end user / customer, via web application | internal analyst, for decision support |
| data represents | latest state of data (current point in time) | history of events that happened over time |
| dataset size | gigabytes to terabytes | terabytes to petabytes |
| bottleneck | disk seek time | disk bandwidth |
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.
SELECT SUM(…) over a few columns
showing 20 of 100 columns · 8 rows■ needed by the query · ■ read from disk anyway (same page) and thrown away
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
Right column: run-length encoding, alternating counts of 0s and 1s (the last run is implied). Sorting turns each bitmap into one long run.
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-viewSUM(net_price) by product × day, precomputed
click a cell| Mon | Tue | Wed | Thu | Fri | Sat | Sun | Σ | |
|---|---|---|---|---|---|---|---|---|
| apples | 367 | 369 | 310 | 259 | 320 | 457 | 516 | 2598 |
| bananas | 607 | 469 | 486 | 453 | 461 | 678 | 962 | 4116 |
| candy | 443 | 439 | 373 | 346 | 281 | 472 | 468 | 2822 |
| bread | 271 | 365 | 347 | 325 | 452 | 536 | 640 | 2936 |
| milk | 324 | 348 | 368 | 400 | 268 | 523 | 596 | 2827 |
| Σ | 2012 | 1990 | 1884 | 1783 | 1782 | 2666 | 3182 | 15299 |
answer: apples 2,598 · bananas 4,116 · candy 2,822 · bread 2,936 · milk 2,827
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- 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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