System Design · Storage Internals
Key-Value Store Internals
A put returns in a millisecond, yet the bytes may live in a log, a skiplist, and three immutable files before compaction rewrites them again. This lesson follows those bytes on one node: indexes, , , , and why deletes need .
It is not the interview case study for designing a geo-replicated KV product (partitioning, quorums, gossip). Those layers sit above this engine. If you only memorize the case-study boxes, you cannot explain write stalls, read amplification, or why a delete still occupies disk.
One put, one machine
Sequential log first; then memory structure.
Newest value visible to local readers.
Frozen memtable becomes an SSTable.
Merge files; drop obsolete versions safely.
What actually happens
Engines cluster into a few families. A over an append-only log (Bitcask-like) optimizes point gets when all keys fit in RAM. An LSM stack (RocksDB, LevelDB, Cassandra storage) optimizes sustained writes with memtables and SSTables. A B+ tree / page engine (classic InnoDB-style) updates pages in place with a WAL—covered deeply in storage-engine notes; here we focus on the log-structured KV patterns interviews expect when they say “how does the node store keys?”
Across families the contract is the same: after a successful put, a crash must not lose the acknowledged value (under the stated fsync policy), a get must see a single modern value (or a defined version), and a delete must stay deleted until every older copy is gone.
Hash index + append-only log
PUT user:42 → value V
1. append record {key, value, crc} to data.log → offset 8192
2. hash_index[user:42] = 8192
3. fsync policy: every write | group commit | periodic
GET user:42
1. offset = hash_index[user:42] // miss → not found
2. pread(data.log, offset) + verify crc
3. return value
Limits:
- all keys (not values) must fit in RAM
- range scan ≈ full scan unless you add another structure
- crash: rebuild index by scanning log, or keep index snapshotsThis shape is excellent for high churn of values with a bounded key cardinality (session stores, feature flags). It collapses when you have tens of billions of tiny keys: the index itself becomes the capacity bottleneck. LSM designs push more index structure onto disk with sparse indexes and Bloom filters.
LSM write path
Read the diagram definition
flowchart TD
C[Client PUT] --> W[Append WAL]
W --> M[Insert / update memtable]
M --> Ack[Acknowledge client]
M --> Full{Memtable full?}
Full -->|yes| Freeze[Freeze memtable]
Freeze --> Flush[Flush to SSTable L0]
Flush --> Comp[Compaction scheduler]
Comp --> Ln[Merged SSTables at lower levels]The write path separates acknowledge from organize. Durability is the WAL (and its fsync). Visibility is the memtable. Organization is flush + compaction. Confusing those three steps is how people mis-diagnose latency: a slow put is often WAL fsync or a write stall, not “compaction is evil” in the abstract.
PUT k → v (simplified LSM node)
append(WAL, {k,v,seq})
durable_flush_if_required() // sync or group commit
memtable.insert(k, v, seq)
if memtable.size >= limit:
immutable_memtables.push(memtable)
memtable = new_empty()
schedule_flush()
flush:
write SSTable from immutable memtable (sorted)
fsync table + manifest pointing to new file
free WAL segments covered by this flush
schedule compaction if L0 / debt thresholds hitSequence numbers (or hybrid timestamps) order versions. A reader merging memtables and SSTables always prefers the highest seq for a key. That merge cursor is the heart of get and of compaction.
LSM read path
Read the diagram definition
flowchart TD
G[Client GET] --> MT[Search active + immutable memtables]
MT -->|found| Ret[Return newest value or tombstone]
MT -->|miss| BF[Check Bloom filters newest → oldest]
BF -->|definitely miss| Skip[Skip file]
BF -->|maybe| Idx[Probe sparse index + data block]
Idx --> Pick[Pick newest live version across hits]
Skip --> More{More files?}
Pick --> More
More -->|yes| BF
More -->|no| Ret2[Return value or not-found]Each SSTable carries a and a . Block cache holds hot data blocks; index/filter blocks are often pinned or cached aggressively because they are tiny relative to values.
Range scans cannot skip with Bloom filters (membership is per key). They merge iterators across memtables and overlapping files, which is why leveled layouts that limit overlap help scan latency even when point gets already looked fine.
| Symptom | Likely internal cause | First knobs |
|---|---|---|
| Put latency spikes | WAL fsync, too many immutable memtables, L0 write stall | Group commit, faster disk, raise compaction parallelism carefully |
| Get p99 high on misses | Many SSTables, weak filters, cold cache | Compact, enlarge filter bits/key, warm block cache |
| Disk full despite deletes | Tombstones not yet compacted / retention pin | Check compaction debt and snapshot / TTL holds |
| CPU busy, QPS flat | Compaction + compression competing with queries | Throttle compaction, revisit compression codec |
Tombstones, snapshots, and MVCC
A delete is a write: append a with a new sequence. Gets that see the tombstone return not-found. Compaction may drop covered older values immediately, but the tombstone itself must survive until every replica and every snapshot that could still read an older file has moved past it.
Engines that offer repeatable-read style snapshots implement light : readers pin a sequence number, and garbage collection refuses to drop versions newer than that pin. A stuck snapshot is a classic “disk never shrinks” bug.
Versions for key K (high seq wins):
seq 10: put "a"
seq 20: put "b"
seq 30: tombstone
Reader at seq 25 → "b"
Reader at seq 35 → not found
Compaction after all readers > 30 → drop all of K
Too-early drop of tombstone + old SSTable still around
→ resurrect "b" (split-brain of time, not of replicas)Compaction policies and amplification
is the price of reorganizing. Reading amplification is how many files/blocks a get touches. Space amplification is how many obsolete bytes linger. You cannot minimize all three at once; pick the axis your workload cares about (write-heavy ingest vs read-heavy serving).
Size-tiered
Flush files accumulate; compact when you have several of similar size. Lower write amp, higher read amp until merges catch up. Common for heavy ingest.
Leveled
Each level has a target size; compaction moves data downward into non-overlapping files. Higher write amp, bounded overlapping files for point gets.
Checksums, manifests, and crash safety
Every record and data block should carry a checksum. A manifest (CURRENT / MANIFEST in LevelDB vocabulary) records which SSTables are live. Recovery: open the last consistent manifest, delete orphan files, replay WAL into memtables. Never advertise an SSTable until its bytes and the manifest update that references it are durable in the right order.
Group commit
Write stalls
Block cache vs OS page cache
Iterators, snapshots, and read consistency on one node
Engines expose iterators that heap-merge memtables and SSTables by key, then pick the winning sequence. With a snapshot sequence, any version newer than the snapshot is invisible—this is local MVCC, not distributed isolation. Long-lived iterators that pin sequences delay compaction garbage collection the same way stuck transactions do in page engines.
iterator for snapshot S:
streams = [memtables..., sstables...]
while next_key = min(stream.peeks):
candidates = all versions of next_key with seq <= S
pick highest seq among candidates
if live value: yield
if tombstone: skip
advance all streams past next_key
Pinning S for hours ⇒ obsolete files cannot drop
⇒ disk usage climbs until the iterator closesTrade-offs to weigh
Hash-log vs LSM vs B-tree
Hash-log: simplest point gets, RAM-bound keys. LSM: write-friendly, compaction tax. B-tree: stable read amp, pricier random writes.
Sync every write vs group commit
Stronger durability latency vs throughput. Document what “acked” means in failure drills.
Filter memory vs miss cost
More bits per key cut false positives; at huge scale filters are a first-class capacity line item.
Aggressive compaction vs ingest bandwidth
Clean levels help reads; stealing too much disk bandwidth during ingest creates write stalls.
Worked micro-estimate
Assume:
10M keys, avg value 500 B, 3 historical versions before compact
Bloom filters ~10 bits/key ≈ 12.5 MB
Sparse index ~1/16 keys × 40 B ≈ 25 MB
Raw live data ≈ 10M × 500 B = 5 GB
With space amp 1.5× before compaction catches up ≈ 7.5 GB on disk
Memtable 64 MB → flush every ~64 MB / write_rate seconds
Point-get miss with 8 overlapping files:
8 filter checks in RAM + ~1 false-positive disk read
After leveled compaction to ~1–2 candidates: much tighter tailsChoosing and explaining key-value store internals
A complete explanation connects the mechanism to a requirement. Describe what improves when you introduce it, which new failure cases become possible, and how you would know that the trade-off is worthwhile.
Explain the core idea
On one node, puts append a WAL, update a memtable, then flush to immutable sorted files that compaction merges later.
Gets search newest memory first, use Bloom filters to skip files, then merge versions by sequence number.
Deletes are tombstones; space returns only after compaction and snapshot retention allow it.
When it belongs
You need to explain latency, stalls, or disk growth inside a single KV node.
A case-study design must justify LSM vs in-memory hash indexes.
You are debugging why deletes do not free space yet.
How it fails
Write stalls when compaction debt or L0 files exceed limits.
Read amplification from too many uncompacted SSTables.
Tombstone or snapshot pins preventing space reclaim; checksum failures on silent corruption.
Trade-offs to weigh
Write amplification versus read/space amplification under different compaction policies.
Per-write fsync durability versus group-commit throughput.
RAM for filters and indexes versus disk seeks on misses.
Common misunderstandings
Treating the distributed case-study architecture as a substitute for engine internals.
Assuming delete immediately frees bytes.
Blaming “compaction” without separating WAL sync, flush, and merge debt.
Wrap-up
Internals turn API verbs into logs, memory tables, immutable files, and merge policies. Master that vocabulary, and the distributed case study becomes “replicate this engine’s durable state across owners,” not a pile of disconnected buzzwords.
Key takeaway
Separate durability (WAL), visibility (memtable), and organization (SSTables + compaction). Tombstones and snapshots explain delayed space reclaim. Amplification numbers tell you which knob actually hurts your workload.