KnowSys
⛁Build it yourself

Build a database storage engine

A key-value store that grows into a real engine: fixed-size pages on disk, a B+tree or an LSM tree, a write-ahead log, crash recovery and a buffer pool, with a small SQL layer on top if you keep going.

Ambitious⏱ 6–10 weekendsRust · Go · C · C++

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.
  • fsync becomes a real decision. You'll know what "durable" costs, and why a database that skips fsync benchmarks 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:

What your engine does on `put(k, v)`
●
⌨
API
put / get / scan
✎
WAL
append + fsync
▤
Buffer pool
cached pages
⋔
Index
B+tree or LSM
⇩
Checkpoint
flush dirty pages
↻
Recovery
replay the log
Step 1. The caller asks to store a key and value. Nothing has touched the disk yet, so nothing is promised.
1 / 6

?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 needWhyWhere to get it
Comfort with file I/O: pread, pwrite, fsyncEvery milestone reads and writes pages at offsetsChapter 08
A mental model of what a disk does with a writeDurability and torn writes depend on itChapter 09
Binary encoding: fixed-width ints, varints, checksumsPages and log records are byte layouts you designYour language's byte-buffer library, plus CRC32
A way to kill your process mid-writeYou can't test recovery politelykill -9 in a loop from a shell script
One systems languageYou need control over bytes and memoryRust 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.

1

An append-only log store

1 evening

Write 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.

You’ll learnappend-only filesrecord framingin-memory indexBitcask
Done when: Restarting the process keeps every key, and a get costs one seek into the file.
2

Survive a crash

1 weekend

Add 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.

You’ll learnfsynctorn writeschecksumsdurability vs throughput
Done when: A script that kill -9s the store mid-write 1,000 times never finds a corrupt record or a lost acknowledged write.
3

Compaction

1 evening

Old 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.

You’ll learngarbage collectionatomic renamehint filesspace amplification
Done when: After overwriting one key a million times the data file is small again, and a crash during compaction loses nothing.
4

Sorted pages: a B+tree

2–3 weekends

Lay 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.

You’ll learnslotted pagesnode splitsfan-outrange scans
Done when: A million random inserts followed by a full scan returns every key in sorted order, and the tree's height stays small.
5

Or sorted runs: an LSM tree

2–3 weekends

Buffer 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.

You’ll learnmemtableSSTablesbloom filtersleveled vs tiered compaction
Done when: Writes run at close to sequential disk speed, and a get for a missing key skips most SSTables thanks to the bloom filters.
6

A buffer pool

1 weekend

Stop 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.

You’ll learnpage cachepinningLRU / clock evictiondirty pages
Done when: With a pool of 64 pages, a workload over 10,000 pages runs correctly, and a hot-key benchmark shows a hit rate above 95%.
7

A write-ahead log and real recovery

2 weekends

Now 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.

You’ll learnWALLSNsredo loggingcheckpointstorn pages
Done when: kill -9 during a burst of B+tree splits, restart, and every acknowledged key is present and the tree passes a structural check.
8

Transactions, a little

1–2 weekends

Group 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.

You’ll learnatomic batchescommit recordssnapshot readsMVCC basics
Done when: A batch of 100 writes interrupted by kill -9 is either fully present or fully absent after restart.

05Traps that catch everyone

SymptomCauseFix
Data written just before a crash is gonewrite returned, but the bytes were still in the OS page cachefsync the file before acknowledging; test with kill -9 and, better, a VM power-off
A renamed file vanishes after a crashThe directory entry was never flushedfsync the parent directory after rename
Recovery reads garbage at the end of the logA torn final recordChecksum every record and stop at the first bad one
The B+tree loses keys after many insertsA split updated the child but not the parent, or the reverseWrite a checker that walks the tree and verifies key order and parent pointers after every test
Everything works until the dataset outgrows RAMThe buffer pool never evicts, or evicts pinned pagesTest with a pool much smaller than the data from the start
LSM reads get slower every hourCompaction can't keep up with writesMeasure the number of SSTables per read; throttle writes or compact more aggressively
Endianness bugs when reading old filesIntegers written in native byte orderPick little-endian and encode explicitly

06Stretch goals

  • A small SQL layer. Parse CREATE TABLE, INSERT and SELECT ... 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_DIRECT and 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

cstack, Let's Build a Simple Database

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.

Alex Petrov, Database Internals

The book for this project. Part I covers B-trees, page layout, buffer management, recovery and LSM trees in the depth you need.

CMU 15-445/645, Database Systems

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.

Martin Kleppmann, Designing Data-Intensive Applications

Chapter 3 compares log-structured and page-oriented engines more clearly than anything else. Read it before choosing between milestones 4 and 5.

Mohan et al., ARIES

The 1992 paper behind recovery in most relational databases. Dense, but after milestone 7 you'll follow the redo pass without trouble.

SQLite file format documentation

A precise description of a production B-tree page layout, rollback journal and WAL, from a database small enough to read end to end.

LevelDB source

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.

Chapters that back this project

Next project◈ a Redis clone→