Skip to main content

Overview

Durable Key-Value Store

0. Interview framing​

Goal: design an embeddable get / put / delete key-value store optimized for SSD/NVMe with strong crash and power-loss durability.

The key architectural choice is an LSM-tree:

write
↓
WAL append + fdatasync
↓
mutable memtable
↓
immutable memtable
↓
flush
↓
L0 SSTables
↓
leveled compaction
↓
L1 ... Ln SSTables

Why LSM here:

  • Durable writes are sequential appends.
  • Recent data is served from memory.
  • Disk files are immutable and easy to recover safely.
  • Compaction moves random update cost out of the foreground write path.
  • Bloom filters + block indexes keep point reads efficient.

1. Requirements

Functional​

interface KVStore {
put(key: Uint8Array, value: Uint8Array, durability?: 'SYNC' | 'ASYNC'): Promise<void>;
get(key: Uint8Array): Promise<Uint8Array | null>;
delete(key: Uint8Array, durability?: 'SYNC' | 'ASYNC'): Promise<void>;
}

Semantics:

put(k, v) = upsert
delete(k) = append tombstone
get(k) = newest visible PUT unless newest visible record is DELETE

Out of scope:

- multi-key transactions
- distributed consensus
- range scans
- secondary indexes

Non-functional​

Durability:
SYNC writes acknowledged only after WAL fsync.

Crash safety:
restart must recover all acknowledged writes.

Power-loss safety:
no half-installed SSTables or metadata versions.

Concurrency:
high write throughput, lock-free reads.

Performance:
point reads optimized by block cache + Bloom filters.

Storage:
bounded read amplification, write amplification, and space amplification.

2. Reference architecture

┌───────────────────────────────┐
│ KV API │
│ put / delete / get │
└───────────────┬───────────────┘
│
writes │ reads
│ │ │
▼ │ ▼
┌──────────────────┐ │ ┌─────────────────┐
│ MPSC Write Queue │ │ │ stable_seq snap │
└────────┬─────────┘ │ └────────┬────────┘
▼ │ ▼
┌──────────────────┐ │ ┌─────────────────┐
│ Group Committer │ │ │ Mutable Memtable│
│ seqno assignment │ │ └────────┬────────┘
└──────┬─────┬─────┘ │ ▼
│ │ │ ┌─────────────────┐
│ └─────────────┼───────▶│ Immutable Mems │
│ │ └────────┬────────┘
▼ │ ▼
┌──────────────────┐ │ ┌─────────────────┐
│ WAL append │ │ │ Block Cache │
│ + fdatasync │ │ └────────┬────────┘
└────────┬─────────┘ │ ▼
│ durable │ ┌─────────────────┐
▼ │ │ SSTables L0..Ln │
┌──────────────────┐ │ │ Bloom + index │
│ Memtable apply │◀──────┘ └────────┬────────┘
└────────┬─────────┘ │
▼ │
┌──────────────────┐ │
│ publish stable │ │
│ sequence number │ │
└──────────────────┘ │
▼
┌─────────────────────┐
│ Background │
│ compaction │
└──────────┬──────────┘
▼
┌─────────────────────┐
│ MANIFEST + CURRENT │
│ live version set │
└─────────────────────┘

3. Five core components

3.1 WAL​

The Write-Ahead Log is the source of durability.

Invariant:

ACK(write) ⇒ WAL record has passed the requested durability barrier

For SYNC:

append WAL
→ fdatasync
→ apply to memtable
→ publish stable_seq
→ ACK client

For ASYNC:

append WAL
→ apply to memtable
→ ACK client
→ background fsync every T ms

Trade-off:

SYNC:
survives power failure
higher latency

ASYNC:
lower latency
acknowledged writes within last T ms can be lost after power loss

3.2 Memtable​

Use a sorted in-memory structure, typically a concurrent skiplist.

Internal key:

(user_key, seqno DESC)

Example:

apple @ 105 = PUT("red")
apple @ 101 = DELETE
apple @ 97 = PUT("green")

A reader at snapshot S = 103 sees:

apple @ 101 = DELETE
→ NOT_FOUND

This gives simple MVCC visibility.


3.3 SSTables​

Immutable sorted disk files.

[data block]
[data block]
...
[filter block]
[index block]
[meta-index]
[footer]

Typical block size:

16–32 KiB

Each block has:

CRC32C

Point lookup:

Bloom filter says "definitely absent"
→ skip file

Bloom says "maybe"
→ binary search index block
→ read one data block
→ verify CRC
→ search internal keys

3.4 Manifest + CURRENT​

The manifest defines the one authoritative live version.

CURRENT
└── MANIFEST-000042

MANIFEST edit examples:
+ add SSTable 31 to L0
+ delete SSTable 24 from L0
+ add SSTable 40 to L1
+ set last_seqno = 102931
+ set wal_checkpoint = 100000

Rule:

A disk file is live iff referenced by the current version.

Everything else is garbage.


3.5 Compaction​

Compaction:

- merges sorted files
- removes overwritten versions
- reclaims tombstones safely
- keeps read amplification bounded
- keeps space amplification bounded

Default policy: leveled compaction.

L0: overlapping key ranges
L1: non-overlapping
L2: non-overlapping
...
each level ≈ 10× previous level

4. Write path

SYNC write​

Client
│
│ put(k,v)
▼
Write Queue
│
▼
Committer assigns seqno = 1001
│
▼
WAL append
│
▼
fdatasync()
│
├── crash after here? WAL replay restores write
▼
Memtable apply
│
▼
stable_seq = 1001
│
▼
ACK

Pseudocode:

def commit_batch(batch):
for req in batch:
req.seqno = next_seqno()
wal.append(encode(req))

wal.fdatasync()

for req in batch:
memtable.apply(req)

atomic_store(stable_seq, batch[-1].seqno)

for req in batch:
req.ack()

5. Group commit

This is the main throughput optimization.

Instead of:

write 1 → fsync
write 2 → fsync
write 3 → fsync
...

do:

[w1 w2 w3 ... wN]
↓
one WAL append batch
↓
one fdatasync
↓
ACK all writes in batch

Example batching policy:

batch = queue.drain(
max_bytes = 512 * KiB,
max_wait = 1 * ms
)

Trade-off:

larger batch window:
+ higher throughput
- higher latency

smaller batch window:
+ lower latency
- more fsyncs

Staff-level interview point:

The single committer is not necessarily the scalability bottleneck. The real foreground bottleneck is usually the durability barrier, so group commit often gives excellent throughput with much simpler correctness than fully concurrent WAL writers.


6. WAL record format

┌──────────┬───────┬───────┬──────┬─────────┬─────────┬─────┬───────┐
│ length │ crc │ seqno │ type │ key_len │ val_len │ key │ value │
│ 4 bytes │ 4 B │ 8 B │ 1 B │ varint │ varint │ ... │ ... │
└──────────┴───────┴───────┴──────┴─────────┴─────────┴─────┴───────┘

CRC covers:

seqno ... value

Purpose:

detect:
- torn tail writes
- partial records
- corruption

7. WAL segmentation

Directory:

db/
├── CURRENT
├── MANIFEST-000042
├── wal/
│ ├── 000016.log
│ └── 000017.log
└── sst/
├── 000030.sst
└── 000031.sst

Rotate WAL when:

- segment reaches 64–256 MiB
- corresponding memtable has been flushed

A WAL segment can be deleted only when:

all records in it are represented by durable live SSTables
AND
manifest checkpoint has advanced past the segment

8. Memtable lifecycle

Mutable Memtable
│
│ reaches size threshold
▼
Freeze
│
├── new mutable memtable immediately starts accepting writes
▼
Immutable Memtable
│
▼
Background flush
│
▼
SSTable.tmp
│
▼
fsync file
│
▼
rename → .sst
│
▼
manifest version edit + fsync
│
▼
advance WAL checkpoint

Typical memtable:

64–256 MiB

9. Crash-safe file installation

For any newly generated SSTable:

1. write 000123.sst.tmp
2. fsync(000123.sst.tmp)
3. rename(000123.sst.tmp, 000123.sst)
4. fsync(parent_directory)
5. append manifest version edit
6. fsync(manifest)
7. only now may old input files eventually be deleted

Important:

atomic rename ≠ durable rename

After power loss, the directory entry may disappear unless the parent directory itself was fsynced.

This is a classic storage-engine durability bug.


10. Crash recovery

Startup:

1. Read CURRENT
2. Replay MANIFEST
3. Reconstruct live SSTable set
4. Find WAL segments newer than wal_checkpoint
5. Replay valid WAL records in seqno order
6. Stop at truncated / CRC-invalid tail
7. Rebuild memtable
8. Remove orphan *.tmp files
9. Remove unreferenced SSTables
10. Delete obsolete WAL segments
11. publish stable_seq
12. begin serving

Pseudocode:

def recover():
version = replay_manifest(read_current())

next_seqno = version.last_seqno + 1

for seg in wal_segments_after(version.wal_checkpoint):
for record in seg:
if record.truncated or not record.crc_ok:
seg.truncate_at(record.offset)
break

memtable.apply(record)
next_seqno = max(next_seqno, record.seqno + 1)

gc_orphans(version)
stable_seq.store(next_seqno - 1)

11. Crash matrix

Crash pointState on diskRecovery
WAL append before fsyncrecord may disappearno SYNC ACK was issued
after fsync before memtabledurable WALreplay rebuilds memtable
mid SST flushtemp file onlydelete orphan, replay WAL
SST fsynced before manifestfile exists but not livedelete orphan
mid compactioninputs still livediscard new outputs
mid manifest appendtorn last editstop at last valid edit
after rename before dir fsyncname may not survivedirectory fsync required
crash after durable manifestnew version authoritativerecover from new version

Core invariant:

Never delete the old durable version before the new durable version is committed.

12. Read path

Reader snapshots:

snapshot = atomic_load(stable_seq)

Search order:

1. mutable memtable
2. immutable memtables, newest → oldest
3. L0 SSTables, newest → oldest
4. one candidate SSTable per level L1...Ln

Pseudocode:

def get(key):
snap = atomic_load(stable_seq)

for source in read_sources_newest_first():
entry = source.find(key, max_seqno=snap)

if entry is None:
continue

if entry.type == DELETE:
return None

return entry.value

return None

13. Why stable_seq matters

Suppose a group commit contains:

seq 100
seq 101
seq 102

Bad publication:

apply 100
stable_seq = 100

apply 101
stable_seq = 101

reader starts here

apply 102
stable_seq = 102

The reader can observe a partial durability batch.

Better:

fdatasync batch
apply 100
apply 101
apply 102
stable_seq = 102 ← one atomic publication
ACK writers

Reader sees either:

before batch
or
whole batch

14. Consistency guarantee

For SYNC mode, the target is:

linearizable single-key operations

Reasoning:

A write is acknowledged only after:
1. WAL record is durable
2. memtable contains the write
3. stable_seq includes its seqno

Therefore:

get() starting after ACK
snapshots stable_seq >= write.seqno
⇒ sees the write or a newer write

15. Compaction

Leveled compaction​

L0
├── SST A [a..z]
├── SST B [b..m]
└── SST C [d..x]
│
│ compact overlapping files
▼
L1
├── SST D [a..f]
├── SST E [g..p]
└── SST F [q..z]

At L1+:

key ranges do not overlap

This means a point lookup generally checks:

multiple files in L0
+
at most one file per lower level

16. Tombstone correctness

A delete is:

k @ seq=200 => TOMBSTONE

Do not immediately remove the tombstone during compaction.

Why:

L2 contains old PUT(k, "value") @ seq=100

If we drop the tombstone before proving all older versions are gone:

get(k)
→ falls through
→ finds old L2 value
→ deleted key resurrects

Safe tombstone removal requires:

1. no older version can exist in any lower level
2. no live snapshot requires the older history

Use:

min_snapshot_seq

to gate version/tombstone deletion.


17. Background compaction algorithm

pick level L
↓
pick candidate SSTable(s)
↓
find overlapping files in L+1
↓
k-way merge by internal key
↓
keep newest version needed by snapshots
↓
drop obsolete versions
↓
conditionally drop tombstones
↓
write output *.tmp
↓
fsync outputs
↓
rename + dir fsync
↓
single manifest edit:
add outputs
remove inputs
↓
fsync manifest
↓
retire old inputs

Manifest edit must be atomic from the version-set perspective.


18. Backpressure

Without write throttling:

write ingest > compaction throughput
↓
L0 file count grows
↓
read amplification grows
↓
p99 get latency explodes
↓
eventually disk fills

Use thresholds:

L0 > soft_limit
→ slow writes

L0 > hard_limit
→ temporarily stall writes

Expose:

- L0 file count
- compaction debt bytes
- pending compaction bytes
- flush queue depth
- write stall duration

19. Torn writes and corruption

WAL​

Detect:

length prefix
+
CRC32C

Recovery behavior:

valid record
valid record
valid record
partial record ← crash
───────────────
truncate here

SSTable block​

Every block:

payload + CRC

On corruption:

return explicit corruption error
do not silently return wrong value

Optional hardening​

- 4 KiB alignment
- checksummed manifests
- duplicate metadata footer
- periodic background scrub
- backup / replication above the embedded engine

20. Lock-based write path

Simple default:

many app threads
│
▼
lock-free MPSC queue
│
▼
single committer thread

Benefits:

- total mutation order
- trivial seqno assignment
- easy group commit
- no WAL holes
- simple ACK ordering

Pseudo-interface:

def put(k, v):
future = Future()
queue.push(Request("PUT", k, v, future))
return future.wait()

21. Lock-free / optimistic WAL reservation

Possible advanced design:

def reserve(n_bytes):
while True:
cur = atomic_load(tail)

nxt = Cursor(
seqno = cur.seqno + 1,
offset = cur.offset + n_bytes
)

if cas(tail, cur, nxt):
return cur

Writers:

1. CAS-reserve [seqno, offset]
2. write bytes into reserved region
3. mark reservation complete
4. committer fsyncs contiguous completed prefix
5. advance stable_seq only with no gaps

Problem:

writer for seq=100 stalls
writer for seq=101 finishes

cannot publish 101
because stable durable prefix has a hole at 100

Trade-off:

+ more CPU parallelism
- CAS contention
- reservation bookkeeping
- stable prefix complexity
- harder recovery reasoning

Interview recommendation:

Start with single committer + group commit. Only introduce concurrent WAL writers if profiling proves the committer itself is CPU-bound.


22. Safe memory / file reclamation

Compaction may remove an SSTable from the manifest while a reader still has a pointer to it.

Use:

epoch-based reclamation
or
reference counting

Example:

guard = epoch.enter()
try:
result = get_from_version(version)
finally:
epoch.leave(guard)

A retired SSTable can be physically deleted only after:

global_min_reader_epoch > retire_epoch

23. Snapshot tracking

Maintain active reader snapshots:

reader A → seq 100
reader B → seq 115
reader C → seq 121

Then:

min_snapshot_seq = 100

Compaction may drop versions older than a newer shadowing version only if:

no active snapshot can still observe them

24. Sharding for scale-out

For a larger node:

shard = hash(key) % N

Each shard owns independent:

WAL
memtable
immutable memtables
SSTables
compaction queue
stable_seq

Architecture:

Router
hash(key) % N
/ | \
v v v
Shard 0 Shard 1 Shard 2
│ │ │
WAL WAL WAL
│ │ │
LSM LSM LSM

Benefits:

- parallel writers
- parallel compactions
- no cross-shard point-operation locking
- preserves per-key ordering

Trade-off:

range scans become more complex
hot keys still hot
resharding requires data migration

25. Performance estimates

Assume:

key = 16 B
value = 1 KiB
WAL meta ≈ 40 B

record ≈ 1.06 KiB

At:

50,000 puts/sec

WAL bandwidth:

50,000 × 1.06 KiB
≈ 53 MiB/sec

This is usually not the main problem.

Steady-state leveled compaction may produce roughly:

~10× write amplification

Then total device traffic can approach:

~530 MiB/sec

So the long-run capacity bottleneck is usually:

compaction write amplification

not the WAL itself.


26. Read amplification

Worst case:

mutable memtable
+ immutable memtables
+ several overlapping L0 files
+ one file per lower level

Mitigations:

- Bloom filters
- partitioned indexes
- block cache
- bounded L0 file count
- non-overlapping L1+
- compaction prioritization

27. Space amplification

Leveled compaction typically gives better space amplification than tiered compaction.

Trade-off:

Leveled:
+ low read amplification
+ low space amplification
- higher write amplification

Tiered:
+ lower write amplification
+ better ingest throughput
- higher read amplification
- higher temporary space use

28. Cache strategy

Block cache​

Cache:

- decompressed data blocks
- index blocks
- filter blocks

Policy:

LRU / CLOCK / TinyLFU-style admission

Do not blindly cache:

large compaction scans

or compaction will evict hot foreground-read blocks.

Use cache admission hints:

foreground point read → cache
compaction sequential read → bypass / low priority

29. Manifest rollover

An append-only manifest grows forever.

Periodically:

1. write new MANIFEST-N.tmp containing full current version snapshot
2. fsync
3. rename to MANIFEST-N
4. fsync directory
5. write CURRENT.tmp = MANIFEST-N
6. fsync
7. rename CURRENT.tmp → CURRENT
8. fsync directory
9. delete old manifest later

Recovery reads only the current manifest.


30. Failure handling

fdatasync fails​

- do not ACK affected SYNC writes
- transition store to write-error state
- surface disk error

Disk full during flush​

- keep immutable memtable + WAL
- do not advance checkpoint
- stop / throttle new writes before memory exhaustion

Disk full during compaction​

- inputs remain live
- discard partial outputs
- retry after space is freed

Corrupt SSTable​

single-node embedded store:
explicit corruption error

replicated system above it:
repair from healthy replica / backup

31. Observability

Critical metrics:

Write path:
put_qps
WAL_bytes_sec
fsync_latency_p50/p95/p99
group_commit_batch_size
group_commit_wait_ms

Memtable:
mutable_memtable_bytes
immutable_memtable_count
flush_latency

Compaction:
L0_file_count
compaction_pending_bytes
compaction_bytes_read
compaction_bytes_written
write_amplification
compaction_stall_ms

Read:
get_qps
block_cache_hit_rate
bloom_positive_rate
bloom_false_positive_rate
blocks_read_per_get

Storage:
live_bytes
obsolete_bytes
WAL_bytes
space_amplification

Recovery:
recovery_time
WAL_records_replayed
orphan_files_deleted

32. Testing strategy

Unit tests​

WAL:
encode/decode
CRC mismatch
truncated record
sequence ordering

SSTable:
block lookup
index lookup
Bloom behavior
CRC errors

Manifest:
version edit replay
checkpoint replay
CURRENT swap

Property tests​

Generate random:

PUT
DELETE
GET
RESTART

Compare against a reference in-memory map.

Invariant:

recovered state == all acknowledged SYNC operations

33. Crash fault-injection matrix

Inject process death at:

WAL append
WAL fsync
memtable apply
memtable freeze
mid flush
SSTable fsync
rename
directory fsync
manifest append
manifest fsync
CURRENT rename
compaction merge
input deletion

After every restart verify:

1. no acknowledged SYNC write is lost
2. no unacknowledged partial mutation becomes visible incorrectly
3. deleted keys do not resurrect
4. manifest references only valid files

34. Power-failure test

Process crash testing is not sufficient.

Use:

- actual power cut
- filesystem fault injector
- dm-flakey / equivalent
- forced write reordering

Workload:

continuous SYNC writes
record ACKed seqnos externally
cut power
restart
assert recovered stable_seq >= last ACKed seqno
assert all ACKed keys are correct

35. Alternatives

B+ tree + WAL​

Better when:

- read-heavy workload
- ordered scans are common
- low read amplification is critical

Trade-off:

+ fewer files to search
+ strong range scans
- more random updates
- page split complexity
- copy-on-write / page WAL complexity

LSM tree​

Better when:

- write-heavy
- SSD/NVMe
- point reads
- large ingest rate

36. Staff-level trade-off discussion

Why not fsync every individual write?​

fsync latency dominates
→ throughput collapses

Group commit amortizes the barrier.

Why immutable SSTables?​

- no in-place corruption window
- safe concurrent readers
- simple compaction
- atomic version swap

Why keep WAL after flush starts?​

Because:

flush output is not durable/live until manifest commit

Why manifest rather than scanning all SSTables at startup?​

Scanning filenames cannot tell:

- which files belong to the latest logical version
- whether a compaction output was fully committed
- which old files are obsolete

The manifest is the source of truth.

Why not delete old SSTables immediately after compaction?​

Readers may still reference them.

Use epoch/refcount-based reclamation.


37. Interview deep-dive: exact crash invariant

The system continuously preserves:

At least one complete durable representation of every acknowledged write exists.

Before flush:

WAL

After new SSTable is fsynced but before manifest commit:

WAL + old live version

After manifest commit:

new live SSTable version

Only then can:

old SSTables
old WAL

be reclaimed.

That invariant makes crash reasoning much easier.


38. Minimal class layout

class DB {
private wal: WAL;
private mem: MemTable;
private immutables: MemTable[];
private versions: VersionSet;
private cache: BlockCache;
private stableSeq: bigint;

async put(key: Uint8Array, value: Uint8Array): Promise<void> {}
async delete(key: Uint8Array): Promise<void> {}
async get(key: Uint8Array): Promise<Uint8Array | null> {}

private async flush(mem: MemTable): Promise<void> {}
private async compact(level: number): Promise<void> {}
private async recover(): Promise<void> {}
}

Subsystems:

DB
├── WAL
├── WriteCoordinator
├── MemTable
├── SSTableBuilder
├── SSTableReader
├── VersionSet
├── Manifest
├── CompactionPicker
├── CompactionJob
├── BlockCache
└── RecoveryManager

39. Recommended interview answer flow

Use this order on the whiteboard:

1. clarify durability + API
2. choose LSM
3. draw WAL → memtable → SSTable
4. explain fsync-before-ACK
5. explain group commit
6. explain manifest + atomic file install
7. walk one crash recovery
8. explain compaction + tombstones
9. explain read path + Bloom/cache
10. explain concurrency + stable_seq
11. discuss sharding
12. estimate write amplification
13. finish with failure injection

This avoids prematurely diving into every file format detail.


40. Final interview summary

Durability
checksummed WAL
fsync-before-ACK

Fast writes
sequential append
group commit

Fast reads
memtable
block cache
Bloom filters
sparse SSTable index

Crash safety
immutable files
temp → fsync → rename → directory fsync
manifest defines the one live version

Space reclamation
leveled compaction
safe tombstone dropping

Consistency
monotonically increasing seqno
stable_seq snapshot
lock-free readers

Concurrency
single committer by default
sharding for parallel write scaling
CAS WAL reservation only if needed

Validation
deterministic crash injection
power-failure testing
model-based verification

One-line staff answer:
Build an LSM-tree whose durability boundary is a checksummed, group-committed WAL; whose disk state is immutable SSTables installed through an fsync + atomic-manifest protocol; whose readers use MVCC snapshots over stable_seq; and whose compaction, tombstone GC, backpressure, and sharding keep performance bounded without weakening crash correctness.