πŸ“š Study Notes / Home / HLD / Session 10
Session 10 Β· NoSQL Internals β€” LSM Tree 1

How write-heavy databases store data: LSM Trees

Ever wondered how databases that swallow millions of writes per second β€” Cassandra, RocksDB, the engine behind your favourite chat app β€” actually pull it off? The secret is a clever storage design called the LSM tree. In this session we build it up from absolute zero: why a normal database struggles with heavy writes, the one trick that fixes it, and how that trick turns into a real, working storage engine. No prior storage knowledge assumed.

⏱ 35 min readπŸ“– 6 topics

1 Why a different storage engine?


Explain like I'm 5

Imagine a library where every book is kept in perfect alphabetical order on the shelves. Finding a book is super fast β€” you walk straight to the right spot. But every time a new book arrives, the librarian has to shove all the other books along to make a gap in exactly the right place. With one new book a day, fine. With a thousand new books a minute, the poor librarian spends all day shoving and no time helping readers. We need a smarter way to accept new books quickly.

Recall from Session 9 (SQL vs NoSQL & Sharding) that we split databases into two camps. Traditional SQL databases prize structure and complex queries; many NoSQL systems are built to scale out and absorb enormous write volumes. That difference isn't just in the query language β€” it goes all the way down to the storage engine, the part of a database that actually decides how bytes are laid out on disk and how reads and writes touch them.

The read-optimized world: B-trees

Most classic relational databases (PostgreSQL, MySQL/InnoDB, Oracle) store their data and indexes in a structure called a B-tree (more precisely a B+ tree). Picture our alphabetised library: a B-tree keeps keys sorted in place in fixed-size blocks called pages (often 4 KB or 8 KB). To find any key, you start at the top and follow a few pointers down to the right page β€” only a handful of page reads even in a huge table. That makes reads wonderfully fast and predictable.

The catch is writing. A B-tree does in-place updates: to insert or change a row, it finds the exact page that key belongs in and rewrites that page right where it lives on disk. Each write therefore means: locate the page, read it, modify it, write it back β€” and if the page is full, split it into two and fix up the parents. Those writes land at scattered, random locations on the disk.

Random vs sequential disk access

Disks (and even SSDs) love sequential work β€” writing a long stream to one place β€” and dislike random work β€” jumping around to tiny scattered spots. On a spinning hard disk a random write means physically moving the read/write head, which is glacially slow compared to streaming. Even SSDs pay a penalty for lots of small random writes. A B-tree's in-place updates are exactly this scattered, random pattern.

Key takeaway

A B-tree is read-optimized: sorted, in-place, few page reads per lookup β€” great for queries, but every write is a relatively expensive, random, in-place page update. For a workload that is overwhelmingly writes (logging, metrics, event streams, time-series, social feeds), that's the wrong trade.

Concrete example: a sensor firehose

Suppose you run 100,000 IoT sensors, each sending a reading every second β€” that's 100,000 writes per second, almost no reads, and the readings arrive in no particular key order. In a B-tree, each reading forces a near-random page somewhere on disk to be read, modified, and written back, plus the occasional page split. The disk head thrashes, throughput collapses, and you can't keep up. The same hardware running a write-optimized engine can comfortably absorb the firehose. The data structure β€” not the disk β€” is the bottleneck.

We'll meet B-trees properly elsewhere

We're treating B-trees here only as a contrast β€” just enough to see why a write-heavy system wants something different. The full internals of B-trees and B+ trees (page splits, fan-out, clustered indexes) are covered in the DBMS subject, so we won't repeat them here.

Recap The storage engine decides how bytes hit the disk. B-trees keep keys sorted and update them in place, which makes reads cheap but turns every write into a random, page-rewriting operation. Write-heavy NoSQL workloads need a storage engine designed for the opposite priority β€” fast writes β€” which is what the rest of this session builds.

2 The update problem in SQL


Explain like I'm 5

Imagine a row of labelled boxes on a shelf, each box just big enough for the toy inside. Now you want to swap a small toy for a slightly bigger one. If the new toy doesn't fit, you can't just cram it in β€” it would spill into the neighbour's box! So changing what's in a box turns out to be trickier than it sounds. A database has the exact same headache when you update a value, and it has a whole machine to handle it safely.

Before we can appreciate why NoSQL engines append, we need to see how a classic SQL database handles an update β€” because that machinery is exactly what NoSQL gives up. Let's follow a simple UPDATE all the way down to the disk.

SQL has a strong, explicit schema

In SQL, data lives in tables and every table has a fixed schema declared up front:

CREATE TABLE users (
    id   integer PRIMARY KEY,
    name varchar(20),
    age  smallint
);

The schema tells the database four things: what tables exist, what columns each has, what type each column is, and what constraints apply. For our table:

id   -> integer
name -> varchar(20)
age  -> smallint

This is called schema-on-write: every time data is inserted or updated, the database checks the new data against the schema. For INSERT INTO users (id, name, age) VALUES (1, 'Abdur', 20); it verifies: is id an integer? is name within 20 characters? is age a valid smallint? is id unique?

Common misconception: data type β‰  fixed-size storage

varchar(20) does not mean 20 bytes are reserved in every row. varchar(20) is a constraint on allowed values, not a promise of preallocated space. Variable-length types (varchar, text, varbinary) store only the actual value plus a little metadata; only some types (integer, smallint) are fixed-size.

Value in nameFits varchar(20)?Storage implication
AbdurYesShort value; variable-length storage keeps only the actual value plus metadata.
Abdur RehmanYesLarger than "Abdur" but still within the varchar(20) constraint.
Abdur Rehman Ibne Munir Bin Abdul AzizNoToo long for varchar(20); the database usually rejects the insert/update.

So two valid rows can occupy different amounts of physical storage even though they follow the same schema:

1 | Abdur        | 20
2 | Abdur Rehman | 21

And the real physical row size is not just the sum of declared column sizes β€” databases also store row metadata, null markers, transaction information, alignment padding, page headers, and index references.

How an update really works

Run UPDATE users SET name = 'Abdur Rehman' WHERE id = 1; and the database must: find the row, check the new value against the schema, modify the row or create a new version, update relevant indexes, make the change durable, and stay recoverable if a crash happens. This is far more than "go to disk and overwrite the old value." Modern SQL engines combine a buffer pool, pages, indexes, a write-ahead log, transaction metadata, and background flushing.

Rows live inside pages

Databases don't usually read/write individual rows from disk β€” data is stored in fixed-size blocks called pages:

Page 1
  |-- row: id=1, name='Abdur',        age=20
  |-- row: id=2, name='Abdur Rehman', age=21
  |-- free space

To update a row, the database loads the whole page into memory, modifies the row in memory, then marks the page as a dirty page.

Dirty page

A dirty page is a page that has changed in memory but whose modified version may not yet have been written back to the table or index file on disk.

Write-Ahead Log: the key idea

Before the database safely writes the modified page back to the table/index file, it first records the change in a special append-only log called the Write-Ahead Log (WAL).

The WAL rule

The log record must reach durable storage before the modified data page is considered safely written. The log is written ahead of the actual data page.

In B-tree-based storage engines, data and index pages are updated in place. If a crash happens while only some pages have been written, the structure could become inconsistent. WAL prevents this by giving the database enough information to recover safely after a crash.

Worked example: the full update flow with WAL

A simplified update flow for our UPDATE … SET name = 'Abdur Rehman':

  1. User runs the UPDATE statement.
  2. Database finds the relevant row and page, often using an index.
  3. The page is loaded into memory if not already in the buffer pool.
  4. Schema constraints are checked: "Abdur Rehman" fits inside varchar(20).
  5. The page is modified in memory and marked dirty.
  6. A WAL record describing the change is written.
  7. The WAL is flushed to disk before the transaction commits.
  8. The transaction commits.
  9. The actual table/index pages are flushed later by background processes.
πŸ“
UPDATE issued
↓
πŸ”
Find row/page
↓
🧠
Modify page in memory
↓
✍️
Write WAL record
↓
πŸ’Ύ
Flush WAL before commit
↓
βœ…
Commit transaction
↓
🧹
Flush dirty pages later

Why WAL is needed

A single update may touch multiple physical structures: the table page with the row, the primary-key index page, one or more secondary-index pages, and transaction metadata. If the database overwrote these in place and crashed halfway, it could be left inconsistent β€” e.g. the table row updated but the index not, or a B-tree page split done without updating its parent. With WAL, the database first records enough information to redo / recover the operation; after a crash it replays WAL records and brings the table and index files back to a consistent state.

WAL is not the main table storage

The WAL is not the final home of the row β€” the row still belongs in the table and index files. The WAL answers one question: if the database crashes before all changed pages are written, how do we reconstruct the committed changes?

StructurePurpose
Table / index filesMain database storage
WAL fileRecovery log used to reconstruct committed changes after a crash
Are updates applied in batches later?

Yes, with nuance. The update is applied to the page in memory immediately, but the modified (dirty) page may be written to disk later β€” triggered by checkpointing, the background writer, buffer-pool eviction, memory pressure, or shutdown. The core safety guarantee is simply: before a transaction commits, its WAL records must be durable.

What if the new value is too large?

Run UPDATE users SET name = 'Abdur Rehman Ibne Munir Bin Abdul Aziz' WHERE id = 1;. That value violates varchar(20). The database does not overflow into the next row β€” it usually rejects the update because the value is too large for the declared limit. Remember: varchar(20) is a constraint on allowed values, not a promise of 20 preallocated bytes.

Variable-length updates

What about changing Abdur β†’ Abdur Rehman? It's larger but still fits varchar(20). Since varchar is variable-length, the new value may need more physical storage, and the engine has several tactics:

  • use free space on the same page;
  • create a new row version elsewhere;
  • move large values out-of-line;
  • update pointers or indexes;
  • leave old row versions for later cleanup;
  • split or reorganize pages.

So updates aren't manageable because space was fully preallocated β€” they're manageable because the storage engine knows how to modify pages safely, maintain indexes, and recover using WAL.

Schema changes (ALTER TABLE)

Adding a column changes the table conceptually from id | name | age to id | name | age | gender:

ALTER TABLE users ADD COLUMN gender smallint;

Physically, the database may not rewrite every row. Adding a nullable column can be fast: existing rows are treated as if the new column is NULL, recorded in metadata. Same idea with a default value:

ALTER TABLE users ADD COLUMN country text DEFAULT 'India';

Older systems might rewrite every row; modern databases may store the default in metadata and return it when old rows are read. But some changes do require rewriting many or all rows, e.g.:

ALTER TABLE users ALTER COLUMN age TYPE bigint;
ALTER TABLE users ADD COLUMN created_at timestamp DEFAULT clock_timestamp();
Key takeaway

The correct statement is not "adding/deleting a column always rewrites the table." It is: some schema changes are metadata-only and fast; some require rewriting the table and are slow β€” it depends on the database and the exact ALTER TABLE.

Recap SQL is schema-on-write: every write is checked against a declared schema, and a type like varchar(20) is a constraint, not preallocated space. An update is not a raw overwrite β€” the engine loads the page, modifies it in memory (a dirty page), writes a WAL record before commit for crash recovery, and flushes dirty table/index pages later. Oversized values are rejected; variable-length growth and ALTER TABLE are handled with page/metadata tricks. This whole apparatus is what makes in-place updates safe.

3 Why NoSQL is forced to append


Explain like I'm 5

In SQL you said up front "this box holds at most a 20-letter name," so everyone knows the rules. In NoSQL there's no such rule β€” a value could be a tiny number today and a giant JSON blob tomorrow. You have no idea how big any box needs to be, and you definitely can't make every box as big as the biggest possible value (that would waste a mountain of space). So when a value grows, there's simply nowhere to put it without bumping into the neighbours. The only safe move is to write the new version somewhere fresh β€” to append.

Most NoSQL databases are schemaless (or semi-structured / loose-schema). The crucial consequence: we don't know the size of any particular entry.

KeyValueEntry size
item109 bytes (key: 4b, value: 4b, separator: 1b)
preferences{ "theme": "dark", "autoSave": false }62 bytes (key: 11b, value: 50b, separator: 1b)
contest:[id]:page[10][ {user_id, rank, submission_details}, … ]2 KB maybe?

No preallocation β€” and no way to preallocate

NoSQL databases do not pre-allocate max-space for an entry. More than that, they cannot: the possible maximum is just too large. In Redis, the size limit for a single string is 500 MB β€” preallocating that for every key would be a massive waste of space.

The overflow problem on update

When you update an entry in NoSQL, its size can change. So what happens if the new value is larger than the space it currently occupies? It would overflow and overwrite the adjacent entry. The two naive "fixes" are both terrible:

  • Truncate β€” enforce that you can't update a value to a larger size. A useless database; bad design.
  • Shift β€” push all subsequent entries to the right to make room. Ridiculously slow.
Why is truncation OK in SQL but not in NoSQL?

In SQL, the developer chose the schema and deliberately enforces the max size, so truncation is expected behaviour. In NoSQL there's no such schema, so silently truncating a value would be unexpected and surprising β€” unacceptable.

The forced conclusion

In a NoSQL database it is impossible to update a value on disk in the traditional (in-place) manner. Any entry can only be appended β€” entries are immutable. That immediately raises the challenge that drives the rest of this session: if you can only append, how do you perform updates and deletes?

Recap NoSQL is schemaless, so entry sizes are unknown and unbounded (Redis strings can hit 500 MB), making preallocation impossible. A larger update would overflow into neighbours, and the only alternatives β€” truncate or shift β€” are unacceptable. Therefore NoSQL entries are immutable and append-only, and updates/deletes must be expressed as new appended records. That's exactly the design we build next.

4 Designing a write-optimized store


Explain like I'm 5

Instead of shoving books into the perfect spot every time, imagine the librarian just drops each new book on the top of a pile by the door as it arrives. Adding a book is now instant β€” no shoving, no searching for the right gap. The pile isn't tidy, but accepting books is lightning fast. Cleaning up the pile into neat shelves can happen later, quietly, when nobody's rushing. That "drop it on the pile" idea is the heart of a write-optimized store.

So how do we make writes fast? Flip the B-tree's rule on its head with one core principle:

The one big idea

Never update data in place. Every write is just appended to the end of a file. New value? Append it. Changed value? Append the new version. Even a delete? Append a little note saying "this key is gone." We only ever add to the end, never reach back and overwrite.

This style is called an append-only design, and the simplest form is a log β€” literally a file you keep tacking records onto. Because every write lands at the end of one file, the disk writes are sequential: one smooth stream, the pattern disks are fastest at. No seeking, no page splits, no read-modify-write. The write essentially becomes "remember where the end is, write the record there, move the end pointer forward."

Worked example: the append-only log in action

We store key β†’ value records by appending. Watch the file grow as three commands come in for key user:42:

# command stream            # what gets appended to the file (end β†’)
SET user:42 = "Ann"          β†’  [ user:42 | "Ann"   ]
SET user:42 = "Annie"        β†’  [ user:42 | "Ann"   ][ user:42 | "Annie" ]
DELETE user:42               β†’  [ user:42 | "Ann"   ][ user:42 | "Annie" ][ user:42 | <tombstone> ]

Notice we never went back and erased the old "Ann" record. The newest record for a key always sits further right (later) in the file, so the last one wins. The delete didn't remove anything either β€” it appended a special marker called a tombstone meaning "as of here, this key is dead." Old data gets cleaned up later, not now.

"To find the current value, read the latest record"

If the same key was written many times, its true current value is whichever copy was appended most recently. So a naive read means scanning from the newest end backwards until you hit that key β€” slow if the log is huge. Hold that thought: making reads fast again is exactly the problem the full LSM design (Topic 3) and the indexes & bloom filters of Session 11 solve. For now, celebrate that writes are gloriously cheap.

Why this is the right trade for write-heavy systems

ConcernIn-place (B-tree)Append-only (log)
Disk write patternRandom, scattered page rewritesSequential, one stream to the end
Cost per writeLocate page, read, modify, write, maybe splitAppend a record at the tail
Old dataOverwritten immediatelyLeft behind, cleaned up later
Best forRead-heavy, complex queriesWrite-heavy ingestion
The obvious problem we're creating

An append-only log grows forever and stores stale copies and tombstones. Reads get slower and disk fills up. That's the price of fast writes β€” and the entire job of an LSM tree is to keep the writes this cheap while taming the read cost and the disk bloat. We'll see exactly how next.

Recap The write-optimized trick is: never update in place β€” always append. That turns every write into a cheap, sequential tail-write (the pattern disks love), and treats updates and deletes as new appended records (the latest wins; a delete is a tombstone). The downside β€” a growing log full of stale data and slow reads β€” is what the LSM tree is built to manage.

5 The LSM tree write path


Explain like I'm 5

Back to the library. New books still arrive fast, but now the librarian is cleverer. She keeps a small sorting table right by the door (in easy reach) and quickly slots each new book into order there β€” it's tiny, so this is instant. When the table fills up, she carries the whole neat stack to a shelf in the back and seals it as a finished shelf she'll never rearrange. Over time there are many sealed shelves; every so often, during quiet hours, she merges several sealed shelves into one bigger, even tidier shelf. Fast intake at the door, tidy shelves in the back, cleanup in the background. That's an LSM tree.

An LSM tree (Log-Structured Merge tree) is the storage engine that turns the append-only idea into something practical. It keeps the cheap sequential writes but adds a layered structure so reads stay manageable and old data gets cleaned. It has a few moving parts β€” let's meet each one.

The moving parts

  • MemTable β€” an in-memory, sorted structure (typically a balanced tree or skip list) holding the most recent writes. Because it's in RAM and self-sorting, inserting a key in order is fast. This is the "sorting table by the door."
  • Write-Ahead Log (WAL) β€” an append-only file on disk. RAM is volatile: if the server crashes, everything in the MemTable vanishes. So before a write goes into the MemTable, it's first appended to the WAL on disk. That's the durability insurance β€” after a crash we replay the WAL to rebuild the lost MemTable.
  • SSTable (Sorted String Table) β€” when the MemTable gets big enough, its contents are written out to disk as an immutable, sorted file. "Immutable" means once written it is never modified β€” only read, or eventually thrown away. These are the "sealed shelves."
  • Compaction β€” a background process that merges several SSTables into fewer, larger ones, discarding overwritten values and applying tombstones. This is the "quiet-hours tidy-up."

What a single write does, step by step

✍️
1. Append to WAL
Durable on disk first
β†’
🧠
2. Insert into MemTable
Sorted, in RAM
β†’
βœ…
3. Ack write
Tell client "done"
β†’
πŸ’Ύ
4. Flush when full
MemTable β†’ SSTable on disk
β†’
🧹
5. Compact later
Merge SSTables in background

The beautiful part: from the client's point of view a write is just steps 1–3, and both are sequential/in-memory operations β€” no random disk seeks. Steps 4 and 5 happen later, in the background, off the critical path.

Worked example: pseudocode for a write

Here's the write path in plain pseudocode. Notice the WAL append comes before the MemTable insert β€” that ordering is the whole point of "write-ahead."

def put(key, value):
    # 1. Durability first: append to the write-ahead log on disk.
    #    This is a sequential append β€” cheap and fast.
    wal.append(record(key, value))      # survives a crash
    wal.fsync()                         # force it to actually hit disk

    # 2. Update the in-memory sorted structure (skip list / balanced tree).
    memtable.insert(key, value)         # O(log n), all in RAM

    # 3. We can now safely tell the client the write succeeded.
    return OK

    # --- background, NOT on the write's critical path ---

def maybe_flush():
    if memtable.size_bytes >= FLUSH_THRESHOLD:    # e.g. 64 MB
        sstable = write_sorted_to_disk(memtable)   # one sequential write
        sstables.add_newest(sstable)               # immutable from now on
        wal.truncate()                             # those writes are now safe on disk
        memtable = new_empty_memtable()            # start fresh

def delete(key):
    put(key, TOMBSTONE)                 # a delete is just a special append

A delete is literally a put of a tombstone β€” exactly the append-only idea from Topic 2. Because the MemTable is sorted, flushing it produces a sorted file (an SSTable) in a single sequential pass.

The WAL file in detail β€” quick persistence

Any operation (insert / update / delete) must be durable β€” persisted on disk. The WAL is an append-only file on the hard disk: every new write is simply appended as a new entry at the end. Because it's append-only, the writes are sequential, so write throughput is high. The WAL acts as temporary storage: data there is committed and durable, but it has not yet been fully absorbed into the database's internal bookkeeping.

The WAL file has a max size (typically 100 MB). Once it reaches that size, we must dump it into an SSTable.

Can we just read directly from the WAL file?

Bad idea. The WAL is append-only, contains duplicates, is not sorted, and is large (100 MB). To find the latest entry for a key you'd have to scan the entire 100 MB. We need a fast index into the data instead β€” the MemTable.

The MemTable in detail β€” the read cache

The MemTable is essentially a hashmap in RAM (often also implemented as a balanced BST / sorted linked list). Writes must mandatorily go to disk (for durability), but reads can be served from RAM for ultra-high throughput. The MemTable acts as an in-memory cache over the data we just wrote.

  • Invalidation: it's a write-through cache, but super simple β€” no 2PC needed, because the entire LSM tree lives within a single server. (If the DB is sharded, each shard builds its own LSM tree.)
  • Eviction: Least Recently Used (LRU).
  • Size: bounded by the DB server's RAM β€” more RAM means a larger db-internal cache. Typically the MemTable is kept larger than the WAL's max size, which simplifies eviction and read queries.

Inside an SSTable, and why "sorted" matters

An SSTable stores its key→value entries sorted by key. Sorting buys two things. First, merging two sorted files is easy and sequential (like the merge step of merge sort — walk both with two pointers). Second, you can build a small sparse index — "key m starts at byte offset 50,000" — so a read can jump near the right spot instead of scanning the whole file. (We go deep on these read-side indexes, plus bloom filters, in Session 11.)

Some hard rules about SSTables (long-term persistence). When the WAL fills up, we dump it into a new SSTable. SSTables are immutable: once created they are never updated. They can be deleted (during compaction) and new ones created, but a table is never "edited". Inside a single SSTable, entries are sorted by key and have no duplicates β€” we deduplicate the WAL data before dumping it.

Q: Can there be duplicate entries in SSTables?

A single SSTable is deduplicated and sorted by key, so no duplicates there. However, it is possible to have duplicate entries for the same key across different SSTables. Any old entry of a key is "overridden" by the latest write, making the old entries redundant β€” wasted space that compaction will later reclaim.

Levels / tiers β€” organising the sealed shelves

Flushing produces a stream of SSTables over time. Left alone they'd pile up, so LSM trees organise them into levels (sometimes called tiers): Level 0 holds the freshest, smallest SSTables straight from flushes; deeper levels (L1, L2, …) hold larger SSTables that result from merging. Each level down is typically ~10Γ— bigger than the one above. Newer data lives shallow; older, consolidated data sinks deeper.

🧠
MemTable
RAM, sorted, mutable
↓ flush
πŸ“„
Level 0
Small, fresh SSTables
↓ compact
πŸ“š
Level 1
Merged, larger
↓ compact
πŸ—„οΈ
Level 2+
Largest, oldest

Compaction β€” the background tidy-up

Compaction takes several overlapping SSTables and merges them into new, larger SSTables, throwing away garbage along the way. Because the inputs are each sorted, the merge is a clean sequential pass. During the merge it resolves duplicates and deletes:

  • If a key appears in multiple input SSTables, keep only the newest version and drop the older copies (reclaiming the space wasted by updates).
  • If the newest version is a tombstone, drop the key entirely (this is when a delete finally frees space).
  • Write the survivors out as fresh, immutable SSTables; once readers move over, delete the old inputs.
Worked example: compacting two SSTables

We merge an older and a newer SSTable. Both are sorted by key; the newer one wins on conflicts, and a tombstone removes the key:

# OLDER sstable (sorted)        # NEWER sstable (sorted)
  a β†’ 1                           a β†’ 9            # a was updated
  b β†’ 2                           c β†’ <tombstone>  # c was deleted
  c β†’ 3                           d β†’ 4            # brand-new key

# two-pointer merge, newest-wins, tombstones drop the key:
  a β†’ 9        # newer value beats older 1
  b β†’ 2        # only in older, kept
               # c β†’ deleted: newest version is a tombstone, so dropped
  d β†’ 4        # only in newer, kept

# RESULT sstable (still sorted, smaller, garbage removed):
  a β†’ 9
  b β†’ 2
  d β†’ 4

Three goals achieved at once: stale duplicate (a β†’ 1) gone, deleted key (c) gone, and we ended up with fewer sorted files to search on the next read.

Why compaction, mechanically

Two motivations. (1) Multiple SSTables can hold duplicate (redundant) entries for the same key, wasting space β€” we want to remove the duplicates. (2) Reads must scan SSTables one by one, so we don't want the count to grow large β€” compaction reduces the number of SSTables. The mechanism: take 2 consecutive SSTables and merge them into a single SSTable. Since each SSTable is individually sorted by key, we use the merge step of merge sort; for duplicate keys we keep only the latest entry (not both). After compaction the original SSTables are deleted.

Q: When does compaction happen?

Compaction requires at least 2 tables on the same level β€” then we can compact them into the next level. It runs in the background during normal operations, so there's no downtime. But it's expensive (reads, writes, and deletes on disk), so we don't usually compact the instant two tables appear. Triggers can be:

  • amount of tables (a tableCountThreshold);
  • time passed (e.g. compact every midnight);
  • low-load time (detect when load drops below a threshold, then compact);
  • … and others.

You also don't have to read the whole file into RAM while compacting β€” because the tables are sorted, it can be done in a streaming manner.

Most common compaction strategies

  • Levelling β€” the moment you get 2 SSTables on a level, you trigger compaction: tableCountThreshold = 2.
  • Tiering β€” you allow more than 2 SSTables on a level, keeping a higher tableCountThreshold > 2 before compacting.

The compaction strategy is decided differently for each level. Poorly tuned compaction can make or break database performance, so the choice must be careful.

How this maps to real engines

Real engines build on these ideas. Size-tiered compaction merges several SSTables of similar size into one bigger one (write-friendly, used heavily by Cassandra). Leveled compaction keeps each level's SSTables non-overlapping and tightly sized (read- and space-friendly, used by RocksDB/LevelDB). The choice trades write cost against read and space cost β€” the very trade-offs we tackle next.

Q: Can we compact across levels?

No β€” that's not recommended, because across levels the file sizes differ significantly, so the merge would be inefficient. The sizes grow geometrically because each level is built by compacting two tables from the level above:

Typical WAL size                 = 100 MB
size of SSTable on Level 1  <= 100 MB   # compacted from the WAL file
size of SSTable on Level 2  <= 200 MB   # compacted from 2 Level-1 SSTables
size of SSTable on Level 3  <= 400 MB   # compacted from 2 Level-2 SSTables
...
Key takeaway

The LSM write path keeps writes cheap by doing only two fast things up front β€” append to the WAL (durability) and insert into the in-RAM MemTable (sorted). Everything expensive β€” turning memory into immutable SSTables via flush, and cleaning garbage via compaction across levels β€” is shifted to the background, off the path that the client waits on.

Recap A write goes: WAL append β†’ MemTable insert β†’ ack (both cheap), and later, in the background, the full MemTable is flushed to an immutable, sorted SSTable, while compaction merges SSTables across levels, keeping the newest value and dropping duplicates and tombstones. Fast intake at the door, tidy shelves in the back, cleanup in quiet hours.

6 Trade-offs & the real systems that use LSM


Explain like I'm 5

The clever librarian's system isn't free. To keep the shelves tidy she ends up re-carrying the same books many times as she merges shelves (extra work). For a while, old copies of books sit around taking up space before cleanup (wasted room). And when a reader asks for one book, she might have to check several shelves because the book could be on any of them (slower searching). Fast intake came at a cost β€” and engineers have names for each cost.

LSM trees buy fast writes, but every engineering choice has a bill. There are three classic costs to know, usually called the three "amplifications" plus the read penalty.

The trade-offs

Trade-offWhat it meansWhere it comes from
Write amplificationOne logical write ends up being physically written to disk several times.A record is written by the flush, then re-written every time compaction moves it to a deeper level. 1 user write can become 10Γ—+ bytes on disk over its lifetime.
Space amplificationThe data on disk is bigger than the logical data it represents.Stale (overwritten) values and not-yet-applied tombstones linger until compaction reclaims them.
Read amplificationOne logical read may have to inspect multiple places to find the answer.A key could be in the MemTable, or in any of several SSTables across levels β€” you may check several before you find it (or confirm it's absent).
Worked example: the cost of a single read

A read for key k walks from newest to oldest and stops at the first hit (newest wins):

def get(key):
    if key in memtable:                 # 1. check RAM first (newest)
        return resolve(memtable[key])    # could be a tombstone β†’ "not found"

    for sstable in newest_to_oldest(sstables):   # 2. then disk files
        if sstable.maybe_contains(key):          # (Session 11: bloom filter!)
            v = sstable.lookup(key)               # binary search via sparse index
            if v is not None:
                return resolve(v)               # first (newest) hit wins

    return NOT_FOUND                # worst case: checked everything

The painful case is a key that doesn't exist: with no shortcut, you'd touch every SSTable before concluding "not found." That is exactly why Session 11 introduces two read-savers β€” a per-SSTable index (so a hit is a quick binary search, not a scan) and a bloom filter (a tiny structure that says "this key is definitely not in this SSTable" so you skip it entirely). The maybe_contains(key) call above is the bloom-filter hook.

The central tension

Compaction is a balancing act. Compact more aggressively β†’ fewer SSTables to search (less read amplification) and less stale data (less space amplification), but more re-writing (more write amplification) and more background CPU/disk pressure. Compact less β†’ cheaper writes but slower reads and more wasted space. There's no free lunch; you tune compaction to your workload. (This is the RUM conjecture: you can optimise Read, Update, or Memory cost, but improving one tends to hurt another.)

Compared to the B-tree we started with

DimensionB-tree (read-optimized)LSM tree (write-optimized)
WritesSlower β€” random in-place page updatesFaster β€” sequential appends
ReadsFaster β€” one path down the treeSlower β€” may check MemTable + several SSTables
Disk patternRandomSequential
Main extra costRandom-write penalty, fragmentationWrite/space amplification from compaction
Sweet spotRead-heavy, transactional, complex queriesWrite-heavy ingestion, time-series, logs

Real systems built on LSM trees

  • LevelDB β€” Google's compact embedded key-value library; the original popular open-source LSM implementation (and the source of the SSTable terminology we've used).
  • RocksDB β€” Facebook/Meta's fork of LevelDB, heavily tuned; embedded inside countless systems (it's a storage engine, not a server). Uses leveled compaction by default.
  • Apache Cassandra β€” a distributed wide-column NoSQL database (recall sharding from Session 9); its on-disk format is MemTable + commit log (WAL) + SSTables with size-tiered or leveled compaction.
  • ScyllaDB β€” a C++ rewrite of Cassandra focused on raw performance; same LSM design, different implementation.
  • Also common: HBase, Bigtable (the original inspiration), and time-series stores β€” all lean on LSM-style storage for write throughput.
Why this matters for system design

When you pick a database in an HLD interview or a real project, you're implicitly picking a storage engine. "Heavy writes, simple key lookups, time-ordered data" should make you reach for an LSM-backed store (Cassandra, RocksDB) and explain why with the write-path reasoning from this session. "Complex joins, strong transactions, read-heavy" leans back toward a B-tree-backed relational database.

Recap LSM trees trade away read speed and disk efficiency for write speed. The costs are write amplification (re-writing data during compaction), space amplification (stale data before cleanup), and read amplification (checking the MemTable plus multiple SSTables). The read cost is what Session 11's indexes and bloom filters attack. Real users include LevelDB, RocksDB, Cassandra, and ScyllaDB.

β˜… Putting it all together


You just learned how write-heavy databases actually store data. Here's the one-paragraph story that connects all four topics:

Classic databases use B-trees, which keep data sorted and update it in place β€” wonderful for reads, but every write is a random, page-rewriting operation that buckles under heavy write loads. So write-optimized engines flip the rule: never update in place β€” always append, turning writes into cheap sequential tail-writes (updates become new records, deletes become tombstones). The LSM tree makes that practical: each write is appended to the WAL for durability and inserted into the in-RAM, sorted MemTable; when the MemTable fills, it's flushed to an immutable, sorted SSTable; and a background compaction merges SSTables across levels, keeping newest values and dropping duplicates and tombstones. The bill is write, space, and read amplification β€” and that read cost (checking many SSTables) is exactly what Session 11 tackles with indexes and bloom filters. This design powers LevelDB, RocksDB, Cassandra, and ScyllaDB.

Quick self-check

Does varchar(20) mean the database reserves 20 bytes per row?

No. varchar(20) is a constraint on allowed values (reject values longer than 20 chars), not a promise of preallocated space. varchar is variable-length and stores only the actual value plus metadata, so two valid rows can occupy different physical sizes.

In SQL, why must the WAL record be flushed before a transaction commits?

B-tree data/index pages are updated in place, so a crash mid-write could leave the structure inconsistent (e.g. row updated but index not). Writing the WAL record durably first means recovery can replay it to redo the operation and restore a consistent state. The data pages themselves are flushed later by background processes.

Why can't NoSQL update values in place the way SQL does?

NoSQL is schemaless, so entry sizes are unknown and unbounded (a Redis string can be up to 500 MB), making preallocation impossible. A larger update would overflow into adjacent entries; the only alternatives β€” truncate (useless DB) or shift everything right (ridiculously slow) β€” are unacceptable. So entries are immutable and updates/deletes are appended.

Why not just read directly from the WAL file?

It's append-only, has duplicates, isn't sorted, and is large (~100 MB), so finding the latest entry for a key would require scanning the whole file. The MemTable (an in-RAM hashmap/BBST) provides fast indexed access instead.

What's the difference between levelling and tiering compaction?

Levelling triggers compaction as soon as there are 2 SSTables on a level (tableCountThreshold = 2). Tiering allows more than 2 SSTables per level before compacting (tableCountThreshold > 2). The strategy is chosen per level, and compaction across levels is avoided because level file sizes differ too much (100 MB, 200 MB, 400 MB, …).

Why are B-trees considered read-optimized but a poor fit for write-heavy workloads?

They keep keys sorted and update them in place, so a read follows just a few pointers down the tree (fast), but every write must locate, read, modify, and rewrite a specific page β€” a random, scattered disk operation, plus occasional page splits. Under heavy writes the disk thrashes.

What is the single core principle behind a write-optimized store?

Never update data in place β€” always append. Writes go to the end of a file as new records, which makes them cheap sequential writes. Updates append a newer version (latest wins) and deletes append a tombstone.

Why does the WAL append happen before the MemTable insert?

The MemTable lives in RAM, which is lost on a crash. Writing to the on-disk write-ahead log first means a crash can be recovered by replaying the WAL to rebuild the MemTable. Durability before acknowledging the write.

What does compaction actually do, and what does it clean up?

It merges several sorted SSTables into fewer, larger ones in a sequential pass. Along the way it keeps only the newest version of each key (discarding stale duplicates) and drops keys whose newest version is a tombstone β€” reclaiming space and reducing the number of files reads must check.

Name the three "amplifications" and which one Session 11 most directly addresses.

Write amplification (data re-written during compaction), space amplification (stale data lingering before cleanup), and read amplification (checking the MemTable plus multiple SSTables). Session 11's indexes and bloom filters most directly attack read amplification.

Give two real databases or engines that use an LSM tree, and one workload they suit.

Examples: RocksDB, LevelDB, Apache Cassandra, ScyllaDB. They suit write-heavy workloads such as time-series data, event logs, metrics, and high-ingest feeds with simple key-based lookups.

πŸ“š References & Further Reading


Class material

Papers, docs & deep dives

Optional resources (from the handout)