Every database you use is, underneath the query planner and the wire protocol, a program that turns "put this key" into bytes on a disk and promises to find them again after the power goes out. That promise is a bit harder than it looks, and most of the interesting engineering in Postgres, SQLite and RocksDB goes into keeping it.
This project builds that bottom layer. You'll start with a hash map that writes
to a file, and end with an engine that stores sorted data in pages, survives
kill -9 in the middle of a write, keeps hot pages in memory, and answers range
scans. If you keep going, you put a tiny SQL front end on it and run
SELECT against your own files.
01Why build this
Most engineers meet the storage engine only when it misbehaves. Having written one changes what you notice:
- Write amplification stops being jargon. You'll count how many bytes hit the disk for every byte the user wrote, and see why it differs so much between a B+tree and an LSM.
fsyncbecomes a real decision. You'll know what "durable" costs, and why a database that skipsfsyncbenchmarks so well.- Index choices make sense. Once you've split a B+tree node by hand, you'll know why random UUID keys hurt inserts and why a covering index saves a lookup.
- Postgres and RocksDB settings get readable.
shared_buffers,checkpoint_timeout, compaction style and bloom filter bits are all knobs on pieces you will have built.
It also rewards stopping early. A log-structured KV store with recovery is a useful program by milestone 3.
02What you're building
A write in the finished engine goes through these stages:
?Why write everything twice, once to the log and once to the page?
Because the two writes have different shapes. A log append is sequential and
small, so it's cheap to fsync on every commit. A page update lands wherever
the key lives, and a page can be torn if the power fails halfway through
writing it. The log lets you acknowledge fast and fix the pages lazily, and it
gives recovery a record of what the pages should contain.
03Before you start
| You need | Why | Where to get it |
|---|---|---|
Comfort with file I/O: pread, pwrite, fsync | Every milestone reads and writes pages at offsets | Chapter 08 |
| A mental model of what a disk does with a write | Durability and torn writes depend on it | Chapter 09 |
| Binary encoding: fixed-width ints, varints, checksums | Pages and log records are byte layouts you design | Your language's byte-buffer library, plus CRC32 |
| A way to kill your process mid-write | You can't test recovery politely | kill -9 in a loop from a shell script |
| One systems language | You need control over bytes and memory | Rust or Go are comfortable; C shows every byte |
04The roadmap
Eight milestones. Milestones 4 and 5 fork: build a B+tree, an LSM, or both if you want to see the trade-off for yourself.
An append-only log store
1 eveningWrite each put as a record at the end of a file: length, key, value. Keep a
hash map in memory from key to file offset. A get looks up the offset and
reads one record. On startup, scan the file and rebuild the map.
It's the design of Bitcask, and it's pretty fast. Its limits show up right away: the file only grows, the index must fit in RAM, and range scans are impossible because a hash map has no order.
get costs one seek into the file.Survive a crash
1 weekendAdd a CRC to every record and, on startup, stop reading at the first record
whose checksum fails. That's how you detect a write the crash cut in half.
Then decide when to fsync: on every write, or batched every few milliseconds.
Measure both. The gap is probably larger than you expect, and it's the trade every database
exposes as a setting, like synchronous_commit in Postgres or appendfsync in
Redis. Write down which writes you promise to keep and test that promise.
kill -9s the store mid-write 1,000 times never finds a corrupt record or a lost acknowledged write.Compaction
1 eveningOld versions of overwritten keys waste space. Write the live records to a new
file, fsync it, then rename it over the old one. rename is atomic on
POSIX filesystems, so a crash leaves either the old file or the new one.
Remember to fsync the directory too. Without it the rename itself can be
lost, which is one of the more obscure ways to lose data on Linux.
Sorted pages: a B+tree
2–3 weekendsLay out the file as fixed-size pages. Each page holds a header, an array of slot offsets, and variable-length cells packed from the end. Interior pages hold separator keys and child page numbers; leaves hold keys and values and link to their right sibling for scans.
The hard part is the split. When a leaf is full, move half its cells to a new page and push a separator into the parent, which may split too. Get inserts right before you attempt deletes and merges. Postgres, for one, never merges half-empty pages; it only reclaims pages once they're completely empty.
Or sorted runs: an LSM tree
2–3 weekendsBuffer writes in a sorted in-memory memtable backed by your log from milestone 2. When it's full, write it out as an immutable sorted file (an SSTable) with an index block at the end. Reads check the memtable, then SSTables from newest to oldest.
Reads now get slower as files pile up, so add a bloom filter per SSTable, then background compaction that merges files. Deletes become tombstones that compaction eventually drops. You'll see exactly why LSMs win on writes and pay for it on reads.
get for a missing key skips most SSTables thanks to the bloom filters.A buffer pool
1 weekendStop reading pages straight from the file. Keep a fixed array of in-memory frames and a map from page number to frame. Callers pin a page while they use it, and only unpinned pages can be evicted. A dirty page has to be written back before its frame is reused.
Start with LRU, then try clock, which is roughly LRU without a linked list to update on every hit. A sequential scan will flush your hot pages out of a naive LRU. Postgres fights that with ring buffers for large scans.
A write-ahead log and real recovery
2 weekendsNow pages are updated in place, so a crash can leave a split half-written: a new page exists, but the parent doesn't point to it. Log every page change before you make it, stamp each page with the LSN of its last change, and never flush a page before its log records are on disk.
Recovery reads from the last checkpoint and redoes any change whose LSN is newer than the page's. That's the redo half of ARIES. The undo half only matters once you have transactions that can abort, so leave it for the stretch goals.
kill -9 during a burst of B+tree splits, restart, and every acknowledged key is present and the tree passes a structural check.Transactions, a little
1–2 weekendsGroup writes into a batch with a single commit record in the log. Recovery applies a batch only if it finds the commit. That gives you atomicity.
For isolation, the cheapest start is a single writer plus snapshot reads: tag each version with a sequence number and let readers ignore anything newer than the one they started at. It's a small version of how RocksDB snapshots and Postgres MVCC let reads run without blocking writes.
kill -9 is either fully present or fully absent after restart.05Traps that catch everyone
| Symptom | Cause | Fix |
|---|---|---|
| Data written just before a crash is gone | write returned, but the bytes were still in the OS page cache | fsync the file before acknowledging; test with kill -9 and, better, a VM power-off |
| A renamed file vanishes after a crash | The directory entry was never flushed | fsync the parent directory after rename |
| Recovery reads garbage at the end of the log | A torn final record | Checksum every record and stop at the first bad one |
| The B+tree loses keys after many inserts | A split updated the child but not the parent, or the reverse | Write a checker that walks the tree and verifies key order and parent pointers after every test |
| Everything works until the dataset outgrows RAM | The buffer pool never evicts, or evicts pinned pages | Test with a pool much smaller than the data from the start |
| LSM reads get slower every hour | Compaction can't keep up with writes | Measure the number of SSTables per read; throttle writes or compact more aggressively |
| Endianness bugs when reading old files | Integers written in native byte order | Pick little-endian and encode explicitly |
06Stretch goals
- A small SQL layer. Parse
CREATE TABLE,INSERTandSELECT ... WHERE, encode rows as values keyed by primary key, and run a scan with a filter. Add a secondary index as a second tree. Chapter 20 covers the executor side. - Concurrency. Latch crabbing in the B+tree so readers and writers can work on different parts of the tree at once.
- Full ARIES. Undo logging, compensation records, and aborting a transaction after its pages were flushed.
- Direct I/O. Bypass the OS page cache with
O_DIRECTand see what your buffer pool has to do that the kernel used to do for you. - Compression and prefix encoding in pages or SSTable blocks, and a benchmark that shows what it buys.
07References worth your time
A step-by-step SQLite clone in C, from a REPL to a B-tree on disk. A good companion for milestone 4 if you get stuck on the page layout.
The book for this project. Part I covers B-trees, page layout, buffer management, recovery and LSM trees in the depth you need.
Andy Pavlo's course. The lectures are free online, and the BusTub projects (buffer pool, B+tree index, query execution) line up with milestones 4 to 8.
Chapter 3 compares log-structured and page-oriented engines more clearly than anything else. Read it before choosing between milestones 4 and 5.
The 1992 paper behind recovery in most relational databases. Dense, but after milestone 7 you'll follow the redo pass without trouble.
A precise description of a production B-tree page layout, rollback journal and WAL, from a database small enough to read end to end.
A compact LSM engine from Google, and the ancestor of RocksDB. The memtable, SSTable and compaction code are all short enough to read in a weekend.