Building a Bitcask-style storage engine from scratch
A database is only as trustworthy as the layer that actually puts bytes on disk. So I started my
distributed-systems build at the very bottom — before any network or replication — with one question:
where do the bytes go, and how do I get them back after a kill -9?
What I built
A single-node key-value store — Put, Get, Delete — in a couple hundred lines of Go, modeled on
Riak’s Bitcask. Two moving parts:
append-only log ──▶ [k1|v1][k2|v2][k1|v3]... (source of truth, on disk)
in-memory index ──▶ { k1: offset=…, k2: offset=… } (rebuilt from the log on startup)
Every write appends one record — crc | timestamp | keyLen | valLen | key | value — to the end of a
single file. An in-memory map (the keydir) remembers where each key’s latest record lives. A read is
one map lookup plus one ReadAt. That’s the whole engine.
The concepts that made it work
- Append-only beats in-place. Sequential writes crush random writes on both SSDs and spinning
disks, and “only ever append” makes recovery and concurrency almost trivial — one writer, always
moving forward. An overwrite or delete is just a newer record. - The index is a cache; the log is the truth. The keydir holds nothing you can’t reconstruct. On
startup I throw it away and rebuild it by replaying the log — later records win, tombstones erase
keys. That one principle — durable log + derived projection — is the spine of everything that
follows. - Every record carries a CRC. Four bytes of checksum per record means every read and every
recovery scan self-verifies. Corruption gets caught, not silently served. - Compaction reclaims the dead. Overwritten and tombstoned records pile up forever, so a
Merge()
pass rewrites the log keeping only what the keydir still points at — because the index, by
definition, is the set of live records.
The thing that bit me
Crash-safety isn’t “just add fsync.” The subtle part is recovery. A process killed mid-write
leaves a half-written record at the tail of the log. My first instinct — treat any decode failure
as corruption — would refuse to open a database that’s actually fine. The right model: a truncated
record at the very end is normal (the last write didn’t finish) → stop cleanly and truncate it off.
A checksum mismatch in the middle of the log is real damage → surface it loudly. Telling those two
apart is the entire game.
Tradeoffs & the road not taken
The defining limitation: every key must fit in RAM, because the keydir is in memory. And there are
no range scans — keys sit on disk in write order, not sorted. I chose Bitcask anyway: the index
fits comfortably at this scale, point reads/writes are all I need, and its simplicity keeps the
ideas legible. When I’d switch:
- LSM-tree (RocksDB, Cassandra) — writes dominate and the dataset dwarfs RAM; sorted data and
range scans come back. - B-tree (Postgres, InnoDB) — reads dominate and you need in-place updates and transactions.
Where this sits in the system
Last week: nothing — this is the foundation. Next: put this store behind an RPC server, where a client
that retries a dropped request forces me to admit that “exactly-once” is a comfortable lie. And
this append-only log? It comes back — replicated across machines — as Raft’s log, and then as a Kafka.
Same primitive, all the way up.
Part of a series building a distributed database from scratch.