Module 08 · Lesson 6 of 15Free
LSM trees: storage designed for fast writes
- Level
- Advanced
- Topic
- Storage engines and on-disk data structures
- Reading time
- 19 min
When you finish this page you can
- Trace an update through memory, sorted files and background merging.
- Resolve updates and deletions across several sorted runs.
- Calculate whether background storage work can keep up with incoming writes.
- Choose between append-only, B-tree and LSM storage for a specific workload.
Builds on 2 lessons
Start with lesson 218 minVideo lesson · coming soon
Fast appends leave unfinished work
A seller import sends 4,096 product updates a second into one catalog . Appending them is easy, but the seller’s ordered export still has to assemble scattered product records.
Kartly now has separate services and partitioned data. For this lesson, we examine one local catalog , not checkout .
Our illustrative partition contains two million products. Each incoming update replaces a product’s complete stored value. The engine must support exact-key reads and exports ordered by product key.
How can it accept updates quickly without leaving reads and cleanup an ever-growing amount of work?
You sort each new batch before writing it. Does that make the whole database one sorted sequence?Show answer
A is a sequence ordered by key. Think of several alphabetized lists, each containing corrections to earlier lists.
The LSM idea is to batch changes in memory, then merge them into larger disk components. It defers organization rather than maintaining one fully updated disk structure after every change.1
Diagram not drawn yet: lsm-batched-runs.svg
The catalog service calls a local engine. The engine records updates in a recovery log and a memory buffer. The buffer later produces sorted runs A and B. Both runs can contain the same product key.
That gives writes a short path. It also creates the central obligation: those batches cannot accumulate forever.
Sorting changes the storage bargain
In Append-only logs and Bitcask, a memory directory locates the latest record for each key.
That remains a useful solution. But our partition needs ordered exports, and a directory entry for every distinct product consumes memory. Adding more product keys increases that directory even when write traffic stays unchanged.
An changes what must be organized immediately. The buffer handles current changes. Older records live in sorted files with their own lookup metadata.
The name means log-structured merge-tree. Its essential trade is deferred, batched maintenance instead of immediate disk-index maintenance.1
What should Kartly organize when an update arrives?
Keep the append-only design.
An append plus a memory-directory update gives a short write path. Ordered exports need additional ordering work, and the directory grows with distinct keys.
Maintain an ordered disk tree.
A B-tree keeps one searchable ordered structure. Updates maintain its pages, and its buffer can combine some changes before writing them.
Build an LSM-style engine.
Collect changes, write sorted runs, and reconcile them later. This supports ordered access without requiring a full key-to-record directory in memory, but creates read and merge work.
Why this choice
We choose sorted batches because this partition receives sustained imports and needs ordered exports. We accept extra background work in exchange for avoiding immediate maintenance of one disk tree.
This is not a claim that LSM storage always wins. If point reads dominate and the directory fits comfortably, the append-only design may be easier. If predictable ordered-read performance matters more than import , test a B-tree first.
B-trees develop that alternative. Here, the decision changes if measured read costs outweigh the savings from batching.
The choice moves work through time. We now need to specify where an update lives before that work finishes.
Memory holds the newest changes
Call the engine’s active memory structure a . When it fills, the engine writes a sorted string table, or , to storage. RocksDB uses memtables, recovery logs and sorted files as its basic storage constructs.3
For our teaching engine, the local interface is small:
| Operation | Input | Result |
|---|---|---|
put |
Key and replacement value | Success or storage error |
delete |
Key | Success or storage error |
get |
Key | Value, not found, or storage error |
scan |
Start key and exclusive end key | Current records in key order |
A storage error must remain different from “not found”. Otherwise an unreadable file could make an existing product disappear from an export.
The engine assigns each accepted mutation a sequence number: a local integer defining update order. We assume one serialized writer and no historical reads. This keeps the example focused on current values.
Our write path is:
- Assign the next sequence number.
- Record the mutation in the .
- Apply it to the active memtable.
- Acknowledge after the configured durability requirement is met.
- When the buffer fills, freeze it and direct new writes to another buffer.
- Write the frozen buffer as a sorted file.
“Freeze” means nobody can change that buffer. A background writer can now traverse stable input while the active buffer receives new updates.
Do not confuse acceptance with flushing. Our contract requires durable recovery information before a durable acknowledgment; an SSTable need not exist yet. The exact ordering and recovery protocol belong to The LSM write path.
The buffer therefore buys time, not permanent storage capacity. A slow flush eventually leaves nowhere to put the next update.
Newer records hide older records
Consider these illustrative mutations. p10 and p20 are product keys. The values are opaque product payloads.
| Location | Key | Sequence | Stored content |
|---|---|---|---|
| Older run | p10 |
40 | old-product |
| Older run | p20 |
41 | retired-product |
| Newer run | p10 |
42 | new-product |
| Memtable | p20 |
43 | Delete marker |
A current read of p10 returns new-product. A read of p20 returns not found.
The delete marker is called a tombstone. Sorted tables retain deletion markers to hide values in older tables.2
For this example, resolve a key by choosing its greatest sequence number across the relevant sources. If that record is a tombstone, stop with not found. Do not continue searching for an older value.
Suppose you remove the tombstone when flushing the memtable. Nothing would hide sequence 41. The retired product would reappear in the seller’s export.
A applies the same rule repeatedly:
- Position each relevant sorted source at the range’s start.
- Compare the next keys.
- Collect records for the smallest key.
- Select that key’s newest record.
- Emit it only if it is not deleted.
- Advance those sources and repeat.
These rules explain correctness, not an efficient lookup implementation. Searching every file for every request would be expensive. The LSM read path adds ways to avoid unnecessary searches.
The important distinction is between physical records and logical products. Several physical records can represent one current product.
Turning a batch into ordered records
The engine’s flush preparation can discard earlier versions within this batch, because our model has no historical readers. It must preserve the latest deletion marker.
These functions model that transformation, not durable file writing. Keys are ASCII product identifiers, and sequences are unique positive integers within JavaScript’s safe integer range.
from dataclasses import dataclass
@dataclass(frozen=True)
class Entry:
key: str
seq: int
value: str | None
def prepare_run(entries: list[Entry]) -> list[Entry]:
latest: dict[str, Entry] = {}
for entry in entries:
previous = latest.get(entry.key)
if previous is None or entry.seq > previous.seq:
latest[entry.key] = entry
return [latest[key] for key in sorted(latest)]
batch = [
Entry("p20", 41, "retired-product"),
Entry("p10", 42, "new-product"),
Entry("p20", 43, None),
]
run = prepare_run(batch)
assert [(e.key, e.seq) for e in run] == [
("p10", 42), ("p20", 43)
]type Entry = Readonly<{
key: string;
seq: number;
value: string | null;
}>;
function prepareRun(entries: readonly Entry[]): Entry[] {
const latest = new Map<string, Entry>();
for (const entry of entries) {
const previous = latest.get(entry.key);
if (previous === undefined || entry.seq > previous.seq) {
latest.set(entry.key, entry);
}
}
return [...latest.values()].sort((a, b) =>
a.key < b.key ? -1 : a.key > b.key ? 1 : 0
);
}
const batch: Entry[] = [
{ key: "p20", seq: 41, value: "retired-product" },
{ key: "p10", seq: 42, value: "new-product" },
{ key: "p20", seq: 43, value: null },
];
const run = prepareRun(batch);
console.log(run.map(({ key, seq }) => [key, seq]));Both produce p10 at sequence 42, followed by the p20 tombstone at sequence 43. Empty input produces an empty run.
For a batch of n records with u distinct keys, the map examines every record once. Sorting its keys costs about u log u comparisons. The transformation needs memory proportional to u.
A production ordered memtable can avoid this final sorting step. Our function makes the ordering and version-selection rules visible without implementing that data structure.
It still does nothing about obsolete values in earlier files.
Merging pays the deferred cost
Suppose every flush produces another run. An absent key may require checking them all in our unoptimized design. An export must reconcile them all. Repeated product replacements also leave obsolete payloads occupying storage.
Background merging is called compaction. LevelDB merges selected sorted files into new files and removes overwritten values when they are no longer needed.2
The merge itself is straightforward. With two sorted inputs, compare their next keys and advance the smaller one. When keys match, apply the version rule. Each input record is visited once, giving linear work in the total input size.
The harder question is how much to merge at once.
When should Kartly combine sorted runs?
Merge the entire partition after every flush.
Rewriting a large partition after every small batch repeats nearly all existing data work. Reads see fewer sources, but imports pay for that simplicity.
Never merge.
Flushes stay cheap, but every additional run leaves more reconciliation and obsolete data behind. A short benchmark hides the accumulating cost.
Use bounded background merging.
Merge selected runs in the background and constrain the amount of unfinished work. This requires scheduling and spare capacity, but avoids both extremes.
Why this choice
Assume the partition contains about 2 GiB of current payloads. A 64 MiB batch is small beside it. Rewriting the full partition for each batch would write about 32 times the batch size, before logging and reading inputs.
We choose selective merging because our imports continue throughout the day. A whole-partition rebuild could be reasonable for a small database loaded once overnight.
The choice also depends on what Kartly values. Keeping fewer overlapping sources reduces read reconciliation. Keeping more sources can postpone rewriting, but requires more storage and read work. There is no policy that removes all three costs.
Diagram not drawn yet: lsm-bounded-merging.svg
The local engine writes a recovery log and active or frozen memtables. Frozen memtables flush into fresh sorted runs. A background merger reads fresh and older runs, writes replacement runs, then publishes a new live-file set. Existing inputs remain available until replacement is safe.
For our engine, the components now have explicit responsibilities:
| Component | Responsibility | Talks to | State kept | Failure effect |
|---|---|---|---|---|
| Local engine | Order mutations and resolve reads | All storage components | Sequence and live view | Requests fail until restart |
| Recovery log | Preserve unflushed mutations | Engine, recovery | Ordered mutations | Durable writes cannot succeed |
| Memtables | Hold recent changes | Engine, flusher | Active and frozen records | RAM state needs recovery |
| Sorted runs | Serve older records | Engine, merger | Persistent ordered records | Affected reads return errors |
| Background merger | Replace selected runs | Runs, live-file metadata | Job progress | Unfinished work accumulates |
| Live-file metadata | Identify committed runs | Engine, merger, recovery | Current file set | Recovery cannot guess safely |
LevelDB records its serving file set in a manifest. That makes file membership explicit rather than inferred from directory contents.2
Our replacement protocol must keep the old inputs until the outputs are complete and their membership is durably published. Existing readers also need their input files to remain accessible until they finish.
A crash before publication leaves unused output files. A crash afterward must recover the published replacement. Neither case should turn a partially written output into the database.
Exact publication mechanics and merge policies belong to Compaction. Here, their consequence is enough: background work needs both bandwidth and temporary space.
A buffer cannot fix overload
Assume each incoming mutation occupies 1 KiB, including its record overhead. Give this illustrative engine a 64 MiB flush threshold. Ignore compression and repeated-key collapse for this calculation.
Convert updates into bytes
4,096 updates/s × 1 KiB/update = 4 MiB/s
This is the logical incoming stream for our partition, not Kartly’s total traffic.
Estimate time between flushes
64 MiB ÷ 4 MiB/s = 16 s
The threshold measures encoded record bytes here. Real memory use also includes data-structure overhead.
Budget sustained writes
4 MiB/s × 5 = 20 MiB/s
Assume measured SSTable is five. Include flushes and compaction outputs, but exclude the recovery log.
Add the recovery stream
20 MiB/s + 4 MiB/s = 24 MiB/s
This simplified write budget excludes filesystem overhead. Compaction input reads need capacity too.
The factor of five is an assumption, not an LSM constant. Measure it over a representative steady-state workload.
Now suppose only 16 MiB/s remains available for SSTable writes after other work. At amplification five, the engine supports about 3.2 MiB/s of logical input. Our import sends more than that.
Increasing the buffer only delays the consequence. Doubling its threshold doubles the interval before it fills; it does not raise sustainable storage throughput.
The tempting belief is “background work does not affect request latency”. But background and foreground operations share CPU, memory and storage. RocksDB explicitly describes writes stalling when full memtables cannot be flushed because are occupied.3
For Kartly, the visible failure is an import that stops making progress. Product changes remain unavailable, and a seller cannot finish preparing the sale.
Operate for sustained progress
For our engine, watch the age of the oldest frozen buffer and the amount of pending merge work. A rising trend means the engine is falling behind, even if individual writes still look fast.
Also measure import completion time and export latency. Engine throughput alone does not say whether sellers can finish their work.
Use the observed failure to choose a response:
| Observation | Likely problem to investigate | Useful response |
|---|---|---|
| Frozen buffers wait despite idle storage | Flush scheduling or CPU work | Reserve flush capacity; inspect worker activity |
| Merge work rises with busy storage | Insufficient sustained capacity | Reduce ingestion or add measured capacity |
| Reads slow during large merges | Resource contention | Limit merge concurrency and preserve read capacity |
| Free space approaches the safety reserve | Old inputs plus new outputs | Slow imports before replacement work runs out of space |
| One file returns a read error | Storage failure or corruption | Return an error; recover through the established repair process |
These are investigation paths, not automatic diagnoses. Adding workers helps only when useful resources remain available.
Bound the number of frozen buffers and unfinished runs. When those bounds are reached, our engine waits or rejects new import writes rather than consuming unlimited memory.
Retries must respect that pressure. An immediate retry loop adds work without adding storage capacity.
For crash recovery, our design reloads the committed live-file set and replays recoverable log records. It does not treat every file found on disk as valid database content.
Where this shows up
- Design: a durable event log like Kafka also separates quick appends from background storage maintenance, though its record model differs.
- Design: a distributed key-value store needs a local storage engine beneath replication and partition routing.
We have treated recovery and publication as contracts; next, we will follow one mutation through the memtable, WAL and SSTable boundary.
Common mistakes
- Using file creation time as update order. A compaction output is newly created but can contain old records. Preserve logical sequence information.
- Deleting a tombstone because the key looks absent. Absence is the result the marker enforces. Prove that older values and required historical views no longer need it.
- Benchmarking only an empty engine. Run long enough for flushes and merges to compete with incoming traffic.
- Counting only application bytes. Capacity planning needs repeated table writes, recovery writes and merge reads.
- Treating an LSM as a transaction or replication guarantee. This lesson’s local replacement rules do not establish multi-key transactions or cross-server consistency.
Check your understanding
A newly created compaction file contains p10 at sequence 40. An older-created file contains sequence 42. Which value wins?Show answer
A merge includes a tombstone but excludes an older file containing that key’s value. Can it discard the tombstone?Show answer
The buffer doubles, but incoming bytes and available storage throughput stay unchanged. Does sustained write capacity double?Show answer
An export encounters an unreadable run. Should the engine skip it and return the other products?Show answer
Kartly changes to a small, mostly read-only partition with strict export latency. Must it retain the LSM design?Show answer
Summary
- LSM trees accept changes in batches and merge sorted storage later; sustainable speed requires keeping that deferred work bounded.
- Memtables hold recent changes, recovery logs protect unflushed mutations, and SSTables store sorted records.
- Reads resolve logical update order across sources. Tombstones hide older values rather than erasing them immediately.
- Compaction replaces selected runs, reducing obsolete records and reconciliation work at the cost of reads, writes and temporary space.
- Size for steady-state amplification and background progress. A larger buffer cannot solve a permanent throughput deficit.