RocksDB

RocksDB is an embeddable persistent key-value store from Meta's database engineering team, built on the earlier LevelDB work of Sanjay Ghemawat and Jeff Dean, and it has quietly become the storage engine that other systems are made of: MyRocks puts it under MySQL, and TiKV, Kafka Streams, and Flink's state backend all keep their bytes in it. It is the LSM tree in production form. This is a full chapter, not a tour: a practical tutorial, a systems-internals walkthrough that follows one Put and one Get from the API through the WAL, memtable, flush, and compaction to the SST levels and back up through bloom filters and the block cache, deep dives into the load-bearing subsystems, a staged plan for reading the repository, and labs built on db_bench and sst_dump. File names and defaults were checked against the current main branch of the repository.

Part I: The mental model

write:  Put(k,v) ─▶ WriteBatch ─▶ WAL append ─▶ memtable (skiplist, sorted)
                                                   │ fills (64 MB default)
                                                   ▼
                                     flush ─▶ L0 SST file (sorted, immutable)
                                                   │ L0 piles up (4 files)
                                                   ▼
                              compaction ─▶ L1 ─▶ L2 ─▶ ... ─▶ L6 (each ~10× larger)

read:   Get(k) ─▶ memtable ─▶ immutable memtables ─▶ L0 files (newest first)
                     ─▶ L1..Ln (one binary-searched file per level)
                          each file: bloom filter ─▶ index block ─▶ data block
                                          (block cache in front of the disk)

The one-sentence identity: RocksDB is a log-structured merge tree in a library: writes are absorbed by a sorted in-memory buffer and a sequential log, disk data lives only in sorted immutable files, and a background merge process perpetually renegotiates the price of reads, writes, and space. There is no server, no port, no SQL; you link the library into your process and it manages a directory of files. Everything in the repository serves one of the three verbs in the diagram: absorb writes (WAL plus memtable), reorganize data (flush plus compaction), or answer reads (filters, indexes, caches, merging iterators).

The deep reason for the shape is the difference between random and sequential I/O. A B-tree updates pages in place, paying a random write per update but keeping one copy of each key. An LSM never updates anything in place: every write is an append or a new sorted file, which turns the write path into pure sequential I/O that both flash and disks love, and pushes the deferred cost into background merges and into reads that must consult several places. RocksDB is what happens when that idea is engineered for a decade against production workloads at Meta: the trade-offs become explicit options, and the bookkeeping (which files exist, at which level, holding which key ranges) becomes its own small database, the MANIFEST.

Part II: Using it

Installing on Linux and macOS

RocksDB is a C++ library. Prebuilt packages exist (librocksdb-dev on Debian and Ubuntu, the rocksdb formula on Homebrew), and building from source is the canonical route and the one you want for reading anyway:

# Debian/Ubuntu build prerequisites
sudo apt install build-essential libgflags-dev libsnappy-dev \
    zlib1g-dev libbz2-dev liblz4-dev libzstd-dev

git clone https://github.com/facebook/rocksdb.git
cd rocksdb
make static_lib -j$(nproc)        # librocksdb.a
make db_bench sst_dump ldb -j$(nproc)   # the tools this chapter uses
cd examples && make simple_example

On macOS, brew install rocksdb gives you the library and tools (the benchmark binary may be installed as rocksdb_bench and similar prefixed names). The debug default of the Makefile is slow by design; when you benchmark, build with DEBUG_LEVEL=0.

A first real program

The natural language is C++, and the minimal program is short enough to read in one breath (this is a lightly trimmed examples/simple_example.cc):

#include "rocksdb/db.h"
#include "rocksdb/options.h"

rocksdb::Options options;
options.create_if_missing = true;
options.IncreaseParallelism();
options.OptimizeLevelStyleCompaction();

rocksdb::DB* db;
rocksdb::Status s = rocksdb::DB::Open(options, "/tmp/testdb", &db);
assert(s.ok());

s = db->Put(rocksdb::WriteOptions(), "key1", "value");
std::string value;
s = db->Get(rocksdb::ReadOptions(), "key1", &value);
assert(s.ok() && value == "value");

Iterators give ordered range scans, which is the capability that separates an LSM store from a plain hash store:

rocksdb::Iterator* it = db->NewIterator(rocksdb::ReadOptions());
for (it->Seek("user:"); it->Valid() && it->key().starts_with("user:");
     it->Next()) {
  // keys arrive in sorted order across memtables and all SST files
}
delete it;

Mistake one: ignoring Status

Nothing throws. Every call returns a rocksdb::Status, and a Get for a missing key is not an error you can skip:

// wrong: value is garbage-or-stale on any failure, and a missing
// key is silently conflated with success
db->Get(rocksdb::ReadOptions(), key, &value);
use(value);

// right
auto s = db->Get(rocksdb::ReadOptions(), key, &value);
if (s.IsNotFound()) { /* distinct, normal case */ }
else if (!s.ok())   { /* corruption, I/O error: handle or die loudly */ }
else                { use(value); }

Mistake two: unbatched multi-key writes

Related updates written as separate Puts are neither atomic nor fast; a WriteBatch is both, applied as one WAL record and one memtable pass:

// wrong: a crash between the two Puts leaves the pair inconsistent
db->Put(wopts, "order:42", order_bytes);
db->Put(wopts, "order_index:2026-07:42", "");

// right: atomic, and cheaper than two round trips through the write path
rocksdb::WriteBatch batch;
batch.Put("order:42", order_bytes);
batch.Put("order_index:2026-07:42", "");
db->Write(wopts, &batch);

Mistake three: opening the same directory twice

RocksDB is an embedded, single-process store. A LOCK file makes the second DB::Open on the same directory fail rather than corrupt, and the fix is architectural, not a flag: one process owns the DB, others talk to that process (or use the read-only and secondary open modes for followers).

Mistake four: reading your own writes with stale iterators

An iterator (and a snapshot) sees the database as of its creation. Beginners create one iterator at startup and wonder why new writes never appear; iterators are cheap views meant to be created, used, and deleted, and long-lived ones also pin old files on disk, inflating space.

Mistake five: benchmarking the debug build with tiny values

The default Makefile target carries assertions and no optimization, and single-record loops measure your syscall overhead rather than the engine. Use DEBUG_LEVEL=0, realistic value sizes, and db_bench (Lab 1) before drawing any conclusion about throughput.

Part III: When it is the right tool

RocksDB is the right tool when you are building a system that needs a fast, persistent, ordered key-value core inside its own process: a database engine, a stream processor's state store, a message broker's index, a blockchain node, an embedded cache that must survive restarts. Its sweet spots are write-heavy workloads (the LSM absorbs bursts sequentially), datasets larger than memory on flash, and any design that needs prefix scans over sorted keys. It scales down badly in one specific sense: it is not a shared database, and it brings real operational surface (compaction tuning, stalls, file descriptors) that a simple app may not want.

The alternatives frame the boundaries. SQLite is the other great embedded engine: B-tree storage, real SQL, superb for read-mostly relational data, but its page-oriented write path and single-writer model make it the wrong core for a write-hammered state store. LMDB is a memory-mapped copy-on-write B-tree, outstanding for read-dominated workloads with modest write rates. LevelDB is RocksDB's ancestor and still fine for small embedded uses, but it lacks column families, real concurrency, and a decade of tuning. And when multiple services need the data, you want a served database; my databases note maps that decision space, and my key-value store design write-up derives this same LSM architecture from requirements instead of from code.

The architecture-shaped warning: RocksDB is a library with exactly one writing process; do not architect as if it were a shared network service, and do not put its directory on NFS or any shared filesystem. The LOCK file, the WAL, and the MANIFEST all assume local-filesystem semantics and a single owner.

safe:      service A ──RPC──▶ storage service ──▶ RocksDB on local SSD
           service B ──RPC──▶      (one process owns the directory)

dangerous: service A ──▶ /shared-nfs/db ◀── service B
           two writers, network filesystem locks: corruption waiting to happen

Part IV: The full life of one operation

This is the core of the chapter. We follow Put("k1", "v1") all the way to a compacted SST file, then follow Get("k1") back up. Every stage names the files that implement it, so this section doubles as a map of db/.

Stage 1: Put becomes a WriteBatch

DB::Put is a convenience: it wraps the single key-value pair in a WriteBatch, the unit the write path actually processes. A batch is a small serialized buffer of operations (Put, Delete, Merge, and friends) with a count and a sequence-number slot in its header, which is why a batch is atomic for free: it is one blob that either lands in the WAL or does not.

Stage 2: the write group forms

The batch enters DBImpl::WriteImpl in db/db_impl/db_impl_write.cc, and the first thing that happens is social. Concurrent writers register with WriteThread (db/write_thread.cc), which links them into a queue; the front writer becomes the leader, absorbs the batches of the writers behind it into one group, and does the WAL work for all of them while the others wait. This is group commit, the same trick every serious storage engine plays: many logical writes, one physical log write, one fsync. Each operation in the merged group receives a monotonically increasing sequence number, the timestamp of the whole MVCC story: snapshots are just sequence numbers, and a newer version of a key is simply the entry with the higher sequence.

Stage 3: the WAL append

The leader appends the group's batches to the current WAL, a .log file written by db/log_writer.cc. The format is records packed into fixed 32 KB blocks, each record framed by a checksum, size, and type, where the type marks whether a record is full or a first/middle/last fragment spanning blocks. Durability is a dial: WriteOptions::sync forces an fsync before acknowledging; the default leaves the data in OS buffers, so a machine crash (not just a process crash) can lose recent writes, and disableWAL removes even process-crash safety for callers who have an external log of their own.

Stage 4: the memtable insert

Only after the WAL append does the group insert into the memtable (db/memtable.cc), by default an InlineSkipList from memtable/: a lock-free-for-readers skiplist keyed by the internal key, which is user key plus inverted sequence number plus type. That composite key is defined in db/dbformat.h, and it is the detail that makes everything else work: entries for the same user key sort newest-first, so a lookup finds the latest version first, and a Delete is just an entry of type tombstone. Nothing is ever modified in place; a write is always a new versioned entry, and reconciliation is deferred to reads and compaction. The Put is now done and acknowledged, typically in microseconds: one queue hop, one log append, one skiplist insert.

Stage 5: flush freezes and writes L0

When the memtable reaches write_buffer_size (64 MB default), it is marked immutable, a fresh memtable takes over, and a background flush job (db/flush_job.cc) walks the frozen skiplist in sorted order and hands the stream to BlockBasedTableBuilder (table/block_based/block_based_table_builder.cc), which emits an SST file into level 0. Once the file is durable, the WAL segments covering that memtable become garbage and are deleted; this is the moment the log has served its purpose. L0 is special: because each L0 file is a frozen memtable, L0 files overlap each other's key ranges, and every one of them must be checked by reads. That is a debt the next stage exists to pay down.

Stage 6: compaction carries it downward

When L0 accumulates level0_file_num_compaction_trigger files (4 by default), the compaction picker (db/compaction/compaction_picker_level.cc for the default leveled style) selects the L0 files plus the overlapping files in L1, and a CompactionJob (db/compaction/compaction_job.cc) merge-sorts them, keeping only the newest version of each key, dropping tombstones when nothing older can exist below, and writes fresh files into L1. Each level except L0 is one sorted run, non-overlapping files of about target_file_size_base (64 MB), and each level's total budget grows by max_bytes_for_level_multiplier (10×) from max_bytes_for_level_base (256 MB), out to num_levels (7). When a level exceeds its budget, files are compacted into the next one down. Our "k1" will migrate down over time until it settles in the bottom populated level or is superseded by a newer write. Every file change is committed as a VersionEdit logged to the MANIFEST, which the third deep dive covers.

Stage 7: Get, from the top

Get("k1") enters DBImpl::GetImpl, grabs the current superversion (a consistent bundle of memtable, immutable memtables, and SST file list), and checks in newest-first order: the active memtable, then immutable memtables awaiting flush. A hit at any point wins immediately, including a tombstone hit, which returns NotFound. Only on a miss does the read go to disk via Version::Get in db/version_set.cc: every L0 file newest to oldest, then for each deeper level a binary search over file metadata locates the single file whose range could contain the key. This ordering is why read amplification in an LSM is "number of sorted runs you might have to consult", and why L0 pileup is the classic read-latency incident.

Stage 8: inside one SST file

For each candidate file, the table cache (db/table_cache.cc) provides an open BlockBasedTableReader. If the file has a bloom filter, the filter block is consulted first and usually answers "definitely not present" for the files that do not hold the key, saving their data-block reads entirely. On a maybe, the index block maps the key to a data block, the block is fetched (through the block cache if resident, from disk if not, decompressing on the way), and a scan inside the block finds the entry. The value returns up the stack; a Get that hits the block cache never touches storage at all. Round trip complete: a write that cost a log append and a skiplist insert, a read that cost a memtable probe plus at most a few filtered block reads.

Part V: Internals deep dives

Deep dive: the LSM tree and the three amplifications

Every LSM design lives inside a triangle of costs. Write amplification: total bytes written to disk per byte of user write, incurred as data is rewritten on its way down the levels. Read amplification: how many places a read might consult. Space amplification: bytes on disk per byte of live data, from not-yet-merged old versions and tombstones. You cannot minimize all three at once, and choosing a compaction style is choosing which one you agree to pay.

              write amp
                 ▲
                / \      leveled:    low read amp, low space amp, HIGH write amp
               /   \     universal:  low write amp, higher space + read amp
              /     \    FIFO:       almost no compaction, drops old data
             ▼       ▼
        read amp ◀─▶ space amp

Leveled compaction (the default) keeps each level a single sorted run with exponentially growing budgets. Reads are cheap: one run per level plus L0, so a handful of candidate files. Space is tight: stale versions survive only until the next merge, and the 10× fanout means roughly 90% of data sits in the bottom level, bounding space overhead. The bill is write amplification: a key rewritten at every level boundary, with a per-level cost on the order of the fanout, can cost tens of physical writes per logical write across a deep tree. Universal (tiered) compaction inverts the deal: it accumulates sorted runs of similar size and merges them together only when there are too many, rewriting data far less often, at the price of more runs to consult on reads and transiently holding duplicate data (a full merge can briefly need on the order of twice the space). Ingest-heavy, scan-light workloads take that trade happily. FIFO compaction barely compacts at all: it drops the oldest files past a size budget, which turns RocksDB into a persistent TTL-ish cache for time-ordered data.

The famous trap in this area is flow control. Compaction is not optional housekeeping; it is the engine's debt service, and RocksDB enforces it: at 20 L0 files (default) writes are slowed, at 36 they stop entirely until compaction catches up. A benchmark that writes flat out for a minute measures the memtable; production throughput is compaction throughput. The second trap: deletes are writes. A Delete adds a tombstone that must itself flow down the tree before the space comes back, and a mass-delete followed by a scan is a worst-case workload, because iterators must step over the tombstones (the wiki's answer is DeleteRange and periodic compaction).

Deep dive: anatomy of an SST file

┌──────────────────────────┐
│ data block │ ~4 KB, sorted keys, prefix-compressed, restart points
│ data block │
│    ...     │
│ filter block   │ bloom bits for every key in the file (if configured)
│ properties     │ counts, sizes, options used, user-defined stats
│ index block    │ last-key-of-each-data-block → block offset
│ footer         │ fixed size: offsets of index+meta, magic number
└──────────────────────────┘  read a file back-to-front: footer first

The block-based table is the default and only format you need to know. Data blocks hold about block_size (4 KB uncompressed by default) of sorted entries; within a block, keys are prefix-compressed, with periodic restart points where a full key is stored so binary search can land and scan forward. The index block is a sorted map from each data block's last key to its offset, so point lookups binary-search the index, then one data block. The properties block records everything about how the file was made, which is why sst_dump (Lab 4) is such a good teacher. A reader starts from the fixed-size footer at the end, which locates the metaindex and index and carries the magic number.

Bloom filters deserve their own paragraph because the default surprises people: filter_policy defaults to null, so a stock RocksDB has no bloom filters until you configure them. The conventional setting, NewBloomFilterPolicy(10), spends 10 bits per key for roughly a 1% false-positive rate, converting most "check this file" operations into a memory test instead of a disk read; point-lookup-heavy workloads should consider it mandatory. The related knobs are about where this metadata lives: by default index and filter blocks are held by open table readers, and cache_index_and_filter_blocks moves them into the block cache (32 MB by default if you do not supply one, far too small for serious use) so their memory is bounded and observable. Compression is per-level in spirit: cheap-or-none at upper levels that get rewritten often, strong (zstd) at the bottom level where 90% of the data rests.

Deep dive: the MANIFEST and version sets

An LSM database is, at any instant, "these exact SST files at these exact levels." That statement is itself state, it changes with every flush and compaction, and it must survive crashes. RocksDB stores it the same way it stores everything: in a log. The MANIFEST file is an append-only log of VersionEdit records (file added at level N, file deleted, WAL number advanced), and a Version (db/version_set.cc, version_edit.cc) is the in-memory result of folding those edits: an immutable list of the live files per level for one column family. The VersionSet owns the chain of versions; the CURRENT file names the active MANIFEST; recovery is "read CURRENT, replay the MANIFEST, then replay WALs newer than the last recorded flush."

CURRENT ─▶ MANIFEST-000123: {edit: +file 7 @L0} {edit: +8 @L0} {edit: -7 -8, +9 @L1} ...
                                  fold ▶ Version N: L0=[]  L1=[9]  L2=[3,4]
reads pin a Version ──▶ files of that Version cannot be deleted while pinned

Versions are immutable and reference-counted, which is how reads, iterators, and compactions all proceed without locking each other: each pins the Version it started with, and obsolete files are physically deleted only when no Version references them. Two practical consequences double as the traps of this subsystem. First, a long-lived iterator or snapshot pins old versions, so disk usage can stay high long after deletes, and the fix is to keep read views short-lived. Second, never "clean up" SST files by hand; a file that looks old may be referenced by the MANIFEST, and deleting it corrupts the database as surely as deleting a B-tree page. The LOG file (plain text, despite the name) narrates all of this machinery live, which is Lab 3.

Deep dive: the tuning surface, at concept level

The options struct has hundreds of fields, but the load-bearing ones map one-to-one onto the pipeline, and the wiki's own tuning guide says most users should touch only a few. On the write side, write_buffer_size (64 MB) and max_write_buffer_number (2) set how much RAM absorbs writes before flush pressure; bigger buffers mean fewer, larger flushes and better dedup of hot keys before disk. The L0 triggers (4 compact, 20 slow down, 36 stop) are the flow control connecting ingest rate to compaction throughput, and max_background_jobs (2) is how much parallelism compaction gets to keep up. On the shape side, target_file_size_base (64 MB) and max_bytes_for_level_base (256 MB) with its 10× multiplier set the granularity and depth of the tree. On the read side, bloom bits per key and the block cache size are the two levers that matter, and per-level compression choices trade CPU for space. The right method is not memorizing numbers: identify which amplification hurts your workload, then move the one or two knobs that trade away something you have spare. Column families exist so that differently-shaped data inside one process can make different choices while sharing one WAL and atomic batches.

Part VI: Reading the repository

Stage 0, the contract. Read include/rocksdb/db.h, options.h, advanced_options.h, and table.h; the option comments are some of the best documentation in the project. Then examples/simple_example.cc and its siblings. Questions: what is a column family? What does a Status-based API imply about error handling? Which options in this chapter can you now find with their documented defaults?

Stage 1, the write path. db/db_impl/db_impl_write.cc (WriteImpl), db/write_thread.cc, db/log_writer.cc, db/memtable.cc, and the internal key in db/dbformat.h. Questions: how does group commit pick a leader? Why does the WAL append happen before the memtable insert? Why do internal keys embed an inverted sequence number?

Stage 2, flush and the file format. db/flush_job.cc, then table/block_based/block_based_table_builder.cc and its reader counterpart. Questions: what makes L0 special? In what order are the blocks of an SST written, and why is the footer last? Where would a bloom filter be consulted?

Stage 3, metadata. db/version_edit.h and db/version_set.cc (start from the class comments), plus db/column_family.cc. Questions: what exactly is in a VersionEdit? How does recovery rebuild the file list? What keeps an SST file alive on disk?

Stage 4, compaction. db/compaction/compaction_picker.cc, the leveled and universal pickers beside it, and compaction_job.cc. Questions: what makes a level "need" compaction under the leveled picker? When can a tombstone finally be dropped? Where do write stalls come from?

Stage 5, the extras. cache/ for the block cache, tools/ for db_bench_tool.cc, sst_dump.cc, and ldb_cmd.cc, and utilities/ for transactions, backup, and TTL, which show how far the core primitives stretch.

Where not to start: db/db_impl/db_impl.cc read top to bottom (it is the hub of everything and reads as noise until you know the spokes), the transaction layer, and BlobDB; and do not begin with the options code, which is machinery, not ideas.

Part VII: Hands-on labs

Lab 1: db_bench basics

./db_bench --benchmarks=fillseq,readrandom --num=1000000 \
           --db=/tmp/rocks_lab --value_size=100

Expect a report per benchmark with micros/op and ops/sec (numbers vary enormously by hardware and build flags; a release build on SSD will show fillseq far faster than readrandom). Concept taught: sequential absorption versus multi-place reads, the LSM's fundamental asymmetry.

Lab 2: watch SST files appear on flush

./db_bench --benchmarks=fillrandom --num=2000000 \
           --db=/tmp/rocks_lab2 --write_buffer_size=1048576 &
watch -n1 'ls -l /tmp/rocks_lab2/*.sst | tail; echo; ls /tmp/rocks_lab2 | wc -l'

With a deliberately tiny 1 MB write buffer, memtables fill fast and .sst files appear every few seconds, then periodically consolidate as compaction merges them. Concept taught: flush creates files, compaction consumes them; the directory is the LSM tree made visible.

Lab 3: read the LOG file

grep -E "flush|compact" /tmp/rocks_lab2/LOG | head -30
grep "Compaction start" /tmp/rocks_lab2/LOG | head
grep -A12 "Compaction Stats" /tmp/rocks_lab2/LOG | tail -20

The LOG narrates every flush and compaction with byte counts, and the periodic Compaction Stats table shows per-level file counts, sizes, and the read/write bytes that compose write amplification. Concept taught: the amplification triangle, measured on your own machine.

Lab 4: dissect a real SST file

SST=$(ls /tmp/rocks_lab2/*.sst | head -1)
./sst_dump --file=$SST --show_properties --command=none
./sst_dump --file=$SST --command=scan --read_num=5

The properties dump shows entry counts, data and index block sizes, compression, and whether a filter is present (with stock options: no filter, confirming the null default); the scan prints real internal entries with sequence numbers and types. Concept taught: SST anatomy from the deep dive, on disk rather than in a diagram.

Lab 5: bloom filters change the read path

./db_bench --benchmarks=fillrandom,readrandom --num=2000000 \
           --db=/tmp/rocks_bloom --bloom_bits=10 --cache_size=8388608
./db_bench --benchmarks=fillrandom,readrandom --num=2000000 \
           --db=/tmp/rocks_nobloom --bloom_bits=-1 --cache_size=8388608

Compare readrandom micros/op with and without 10-bit bloom filters; the gap grows with dataset size relative to cache. The exact ratio varies, so treat the direction, not the number, as the result. Concept taught: filters convert disk probes into memory tests.

Lab 6: dump the MANIFEST

./ldb --db=/tmp/rocks_lab2 manifest_dump | head -40

You will see the folded state: each level's files with their numbers, sizes, and smallest/largest keys, exactly the Version the engine holds in memory. Delete nothing by hand; instead, re-run Lab 2 briefly and dump again to watch the file lists shift. Concept taught: the MANIFEST as the database's database.

Part VIII: Understanding checks

What is RocksDB in one sentence? An embeddable LSM-tree key-value library: writes go to a WAL and a sorted memtable, disk holds only sorted immutable SST files organized into levels, and background compaction continuously trades write work for read and space efficiency.

Why are LSM writes fast? Because the foreground write path is one sequential log append plus one in-memory skiplist insert, independent of how much data exists on disk. All reorganization is deferred to background flush and compaction.

Walk the path of a Put. Put wraps into a WriteBatch; WriteThread forms a group and assigns sequence numbers; the leader appends the group to the WAL; the batch inserts into the memtable keyed by user key plus inverted sequence; on memtable fill, flush writes an L0 SST and frees the WAL segment; compaction later merges it down the levels, with each file change logged to the MANIFEST.

Walk the path of a Get. Check the active memtable, then immutable memtables, then all L0 files newest-first, then one binary-searched file per deeper level; within each candidate file, bloom filter first, then index block, then one data block via the block cache. The first hit wins, and a tombstone hit means NotFound.

What are the three amplifications, and why can't you have all three? Write amp (disk bytes per user byte written), read amp (places consulted per read), space amp (disk bytes per live byte). They trade against each other because keeping data more merged (good for reads and space) requires rewriting it more often (bad for writes); leveled favors reads and space, universal favors writes.

Why is L0 special? Each L0 file is a flushed memtable, so L0 files overlap in key range and every one must be consulted on a read, unlike deeper levels where non-overlapping files allow a single binary-searched candidate. That is why L0 count drives both compaction triggering (4) and write stalls (20 slowdown, 36 stop).

What is in an SST file? Prefix-compressed ~4 KB data blocks with restart points, an index block mapping each block's last key to its offset, an optional bloom filter block, a properties block describing how the file was built, and a fixed footer, read first, that locates everything.

Do you get bloom filters by default? No; filter_policy defaults to null. NewBloomFilterPolicy(10) is the conventional choice, about 10 bits per key for roughly 1% false positives, and point-read-heavy workloads should treat it as mandatory.

What is the MANIFEST and why does it exist? An append-only log of VersionEdits recording every SST file addition and deletion per level. The live file list is itself mutable crash-critical state, so RocksDB stores it the LSM way: as a replayable log named by the CURRENT file, folded into immutable, reference-counted in-memory Versions.

What is a tombstone and why do deletes cost space before they free it? A Delete writes a marker entry that shadows older versions; the actual space returns only when compaction merges the tombstone far enough down that no older version can exist beneath it. Until then, deletes add bytes, and scans must skip over them.

Why did writes suddenly stall? Ingest outran compaction: L0 reached the slowdown (20) or stop (36) trigger, or pending-compaction bytes tripped their limits. Fixes are throttling ingest, giving compaction more parallelism (max_background_jobs), larger write buffers, or accepting universal compaction's trade.

How does a crash recover? Read CURRENT to find the MANIFEST, replay its edits to rebuild the file lists, then replay WAL records newer than the last flushed sequence into a fresh memtable. Both replays are logs; recovery is the same idea applied twice.

Why choose RocksDB over SQLite, or the reverse? RocksDB when you need a write-heavy, ordered key-value core inside a system you are building and are willing to bring your own data model; SQLite when you want relational queries, ad-hoc reads, and a stable single file with a B-tree's read-mostly economics. They are both embedded, and they sit on opposite corners of the write-versus-query-power trade.

A long-running iterator is held open for hours. What goes wrong? It pins a superversion and therefore old SST files and memtables, so disk space and memory stay elevated regardless of deletes and compaction, and the view grows stale. Read views should be short-lived; snapshots are cheap to take and retake.

Why does compaction happen in the background at all, rather than merging on write like a B-tree? Merging on write would put random I/O and rewrite cost on the foreground path, which is exactly what the LSM exists to avoid. Deferring merges keeps write latency flat and lets the engine batch reorganization into large sequential operations, at the price of scheduling debt that the stall mechanism enforces.

Part IX: Design lessons

Turn mutation into accumulation plus merge. The LSM's core move, never update in place, always append and reconcile later, reappears in Git, in CRDTs, in event-sourced services, and in columnar warehouses' delta-plus-base files. It buys sequential I/O and trivial crash stories, and costs a background debt service.

Make the trade-off a first-class option. RocksDB does not pick a winner among the three amplifications; it exposes compaction styles and per-column-family options so each embedder picks. Engines that hardcode one point on the curve (LevelDB) get outgrown by their users.

Metadata deserves the same rigor as data. The MANIFEST is a WAL for the file list, with the same append-replay-checkpoint lifecycle as user data. Any system whose "what exists right now" state must survive crashes ends up reinventing this (Kubernetes' etcd, Iceberg's metadata layers), so it pays to recognize the pattern.

Group commit is free throughput. Letting one leader do the physical log write for a queue of waiting writers multiplies throughput at essentially no latency cost, and the same structure appears in Postgres's WAL, in Kafka's producer batching, and in every syscall-batching layer.

Backpressure must be designed, not discovered. The L0 slowdown and stop triggers are an explicit admission that background debt needs foreground flow control. Systems that lack a designed stall mechanism still stall; they just do it unpredictably, at the OOM killer or the full disk.

Part X: Memorization framework

The one-sentence summary: RocksDB absorbs writes into a log and a sorted memory buffer, spills them as immutable sorted files, merges those files downward forever, and answers reads by checking newest-to-oldest with filters and caches shielding the disk.

write: Put → Batch → Group → WAL → MemTable → Flush → L0 → Compact → L1..L6
read:  Get → MemTable → Imm → L0* → L1..L6 (bloom → index → block cache → disk)

db_impl_write.cc → write_thread.cc → log_writer.cc → memtable.cc
→ flush_job.cc → block_based_table_builder.cc
→ compaction_picker*.cc → compaction_job.cc → version_set.cc (MANIFEST)

Memorize these blocks:

The defaults: write buffer 64 MB, two of them; L0 triggers 4 / 20 / 36 (compact / slow / stop); level base 256 MB growing 10× across 7 levels; target file size 64 MB; block size 4 KB; block cache 32 MB if unset; bloom filters absent until configured, 10 bits per key ≈ 1% false positives.

The invariants: nothing on disk is ever modified, only written and deleted; every entry is (user key, sequence, type); L0 overlaps, deeper levels do not; a Version is immutable and pins its files; recovery is MANIFEST replay plus WAL replay.

The costs: leveled pays write amp for read and space; universal pays space and read for write; deletes are writes until compacted away; stalls are compaction debt made visible.

Key takeaway: RocksDB is the LSM tree taken seriously as an engineering artifact. Writes are absorbed in memory and logged, files on disk are sorted and immutable, and compaction continuously renegotiates the balance between write, read, and space amplification; the MANIFEST gives the file hierarchy itself a crash-safe log, and column families and the options surface exist so that each embedded use can strike the balance differently without forking the engine.