SwadhyAI Notes

All System Design notes

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

1

Sequential log first; then memory structure.

2

Newest value visible to local readers.

3

Frozen memtable becomes an SSTable.

4

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

plaintext
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 snapshots

This 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

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
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.

plaintext
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 hit

Sequence 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

Diagram text: the exact Mermaid definition below preserves the nodes, labels, and connections. Enable JavaScript to view the rendered diagram.
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.

SymptomLikely internal causeFirst knobs
Put latency spikesWAL fsync, too many immutable memtables, L0 write stallGroup commit, faster disk, raise compaction parallelism carefully
Get p99 high on missesMany SSTables, weak filters, cold cacheCompact, enlarge filter bits/key, warm block cache
Disk full despite deletesTombstones not yet compacted / retention pinCheck compaction debt and snapshot / TTL holds
CPU busy, QPS flatCompaction + compression competing with queriesThrottle 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.

plaintext
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

Waiting for one fsync per put caps throughput at disk IOPS. Group commit batches many puts into one fsync: slightly higher average latency, much higher commits/sec. Same idea as database group commit.

Write stalls

When L0 files or compaction debt exceed limits, the engine slows or stops admits so memory and file counts stay bounded. A stall is a capacity signal: more writers without more compaction bandwidth makes it worse.

Block cache vs OS page cache

Engines often use direct I/O or careful sizing so the block cache is the source of truth for hot blocks. Double caching with the OS page cache wastes RAM; under-sizing the block cache turns every miss into a disk read.

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.

plaintext
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 closes

Trade-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

plaintext
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 tails

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