๐Ÿ“š Study Notes / Home / HLD / Session 11
Session 11 ยท NoSQL Internals โ€” LSM Tree 2

How LSM-tree databases read data fast

Last session we learned how LSM-tree databases write data blazingly fast by only ever appending. But there's a catch: if you just keep dumping data into more and more files, how do you ever find anything again? This session is all about the read side. We start with a story, then build up โ€” step by step, from scratch โ€” the three clever tricks (the read path, the sparse index, and the bloom filter) that make reads fast again. No prior knowledge needed.

โฑ 37 min read๐Ÿ“– 5 topics

1 The read problem in LSM trees


Explain like I'm 5

Imagine you write every new fact you learn on a fresh sticky note and toss it into a big box. Writing is super easy โ€” just scribble and drop it in! But now a friend asks, "What's Grandma's phone number?" Uh oh. You have to dig through every single sticky note in the box, newest first, hoping to find it. With ten notes that's fine. With ten thousand, you'll be there all day. That's exactly the problem LSM-tree databases have: writing is easy, but finding things again is hard.

Quick recall from Session 10: an LSM tree (Log-Structured Merge tree) never updates data in place. Instead it:

  • Buffers new writes in memory in a sorted structure called the MemTable.
  • When the MemTable fills up, it gets flushed to disk as an immutable, sorted file called an SSTable (Sorted String Table).
  • Over time you accumulate many SSTables, each a frozen snapshot of writes from a different point in time.

This append-only design is what makes writes so fast (we covered that last session). But it creates a brand-new headache on the read side. When someone asks for the value of a key, where is it?

Why a key could be anywhere

A single key โ€” say user:42 โ€” might live in several places at once, because every write created a new copy somewhere newer:

LocationCould the key be here?Why
The MemTable (in memory)YesIt may have been written very recently and not flushed yet.
The newest SSTable on diskYesIt may have been flushed a moment ago.
An older SSTableYesIt may have been written long ago and never touched since.
Nowhere at allYesThe key may simply not exist โ€” but we don't know that until we've looked everywhere!
The big idea

In an LSM tree, a key has no single fixed home. The same key can appear in the MemTable and in many SSTables, each version written at a different time. A read must somehow locate the newest version โ€” or prove the key doesn't exist โ€” without scanning every file end-to-end.

The naive read: check everything

The simplest possible read works like this:

๐Ÿง 
MemTable
Look here first
โ†’
๐Ÿ“„
SSTable #9
Newest on disk
โ†’
๐Ÿ“„
SSTable #8
Scan it too
โ†’
๐Ÿ“„
โ€ฆ
โ€ฆand all the rest

If we check newest-to-oldest and stop the moment we find the key, that's correct โ€” but it can be agonisingly slow. The truly painful case is a key that doesn't exist: to be sure, we'd have to read every SSTable completely before giving up.

Why naive reads hurt

With N SSTables, a naive lookup can cost up to N disk searches per read. Disk reads are thousands of times slower than memory, so this is the LSM tree's Achilles' heel. A database that's fast to write but slow to read is only half a database. The rest of this session is the toolkit that fixes reads.

The three tools we'll build

ToolJobTopic
The read pathA correct, ordered way to search MemTable โ†’ newest โ†’ oldest, handling deletes.Topic 2
Sparse indexJump straight to the right block inside one SSTable instead of scanning the whole file.Topic 3
Bloom filterSkip entire SSTables that definitely don't contain the key.Topic 4
Recap LSM trees write fast by appending, but that scatters each key's versions across the MemTable and many SSTables. A naive read may have to check all of them โ€” up to N files, especially for keys that don't exist. The read path, sparse index, and bloom filter are the three optimisations that make reads fast again.

2 The read path & tombstones


Explain like I'm 5

Back to the sticky-note box. The smart way to find Grandma's phone number is to read the newest notes first. Why? Because if you wrote her new number yesterday and her old number last year, the newest note is the one that's actually true now. The moment you find a note with her number, you stop โ€” you don't care about the older ones. And if a note says "Grandma โ€” number deleted", then she has no number, and you stop there too, even though older notes still have the old number.

The read path is the exact ordered procedure the database follows to answer "give me the value for key K." The golden rule is newest wins: because newer data was written later, it overrides anything older.

The order of search

๐Ÿง 
1. MemTable
Newest data of all โ€” in memory
โ†’
๐Ÿ“„
2. Newest SSTable
Most recently flushed
โ†’
๐Ÿ“„
3. Older SSTables
In descending age
โ†’
๐Ÿ›‘
4. Stop
On first match โ€” return it

We always look in the MemTable first because it holds the very latest writes (they haven't even been flushed yet). If the key isn't there, we move to disk and check SSTables from newest to oldest. The first place we find the key has the current value โ€” we return it immediately and never look at older copies.

Key takeaway

Searching newest-first isn't just an optimisation โ€” it's required for correctness. The newest version of a key is the true current value; older versions are stale leftovers waiting to be cleaned up. Stop at the first hit and you automatically get the right answer.

Deletes are tricky โ€” meet the tombstone

Here's a puzzle. SSTables are immutable โ€” once written, you can never go back and erase a key from them. So how do you delete something? You can't reach into an old, frozen file and rub it out.

The trick: a delete is just another write. To delete key K, the database writes a special marker called a tombstone โ€” a record that says "K has been deleted" instead of a real value. Because it's the newest write for K, the read path finds it first and reports the key as gone, even though older SSTables still contain the old value.

Two naive deletion ideas (and why they fail)

Before we land on tombstones, it's worth seeing why the obvious approaches don't work:

Naive approachWhat it doesDoes it work?
1. Delete from MemTable onlyRemove the key from the in-memory MemTable.No. After the delete, a read falls through to the SSTables and finds the old value there. This doesn't really delete the key โ€” it merely undoes the last write, exposing the stale copy underneath.
2. Delete from MemTable + WAL + all SSTablesPhysically erase the key everywhere it lives.Technically yes, but unacceptable. It's incredibly expensive, and SSTables are immutable โ€” you can't shift the surrounding data, so erasing leaves gaps on disk.
The big idea: any delete happens via a write

A tombstone (also called a sentinel, flag, marker, or guard) is a special value that means "this key has been deleted." In practice it's a long, random, essentially-unguessable string so that real user data can never collide with it by chance:

TOMBSTONE = "PZpaIBk8rbaIQVoUqGD2NS04qD3gONn0QH1Cm2DKBkoktwGuEt"
// it is practically impossible for your data to contain this exact string by chance

With a tombstone constant, delete is implemented as a set, and get simply treats the tombstone as "not found":

void set(key, value) {
    ...
}

void delete(key) {
    set(key, TOMBSTONE)          // a delete is just a write
}

string _get(key) {
    // check memtable
    // check sstables
    // ...
}

string get(key) {
    value = _get(key)
    if (value == TOMBSTONE) {
        raise KeyNotFoundError!
    }
    return value
}
Key rule: when can a tombstone be dropped?

Compaction can only delete a tombstone from the oldest SSTable (the one starting from SSTable 0). If an SSTable is not the oldest, its tombstone entries must be kept โ€” because an even older SSTable could still hold a live value for that key, and dropping the tombstone too early would let that stale value resurface.

Worked example: write, delete, read

Suppose these things happen to key "color" over time:

  • Long ago โ†’ wrote color = "blue" โ†’ now sits in old SSTable #3.
  • Later โ†’ wrote color = "green" โ†’ now sits in SSTable #7.
  • Recently โ†’ deleted color โ†’ a tombstone sits in the MemTable.

Now someone reads color. The read path checks the MemTable first, finds the tombstone, and immediately answers "this key does not exist." It never even looks at SSTable #7 or #3 โ€” and that's correct, because the delete is the newest fact.

The read path in pseudocode

function read(key):
    # 1. Check the in-memory MemTable first (newest data)
    result = memtable.get(key)
    if result is not NONE:
        return unwrap(result)   # could be a value OR a tombstone

    # 2. Walk SSTables from newest to oldest
    for sstable in sstables_newest_to_oldest:
        # (bloom filter + sparse index go here โ€” Topics 3 & 4)
        result = sstable.get(key)
        if result is not NONE:
            return unwrap(result)

    return NOT_FOUND          # looked everywhere; key truly absent

function unwrap(result):
    if result.is_tombstone:
        return NOT_FOUND      # a delete marker means "gone"
    return result.value

How compaction helps reads

Recall compaction from Session 10: a background process that merges several SSTables into one, keeping only the newest version of each key and physically dropping tombstones (and the dead values they shadow). It runs while writes keep flowing, so it never blocks the user.

Compaction is the read side's best friend for three reasons:

  • Fewer files to search. Merging 10 SSTables into 1 means a read checks 1 file instead of 10.
  • Less duplication. Old, overwritten versions of a key are thrown away, so reads don't waste time stepping over stale copies.
  • Tombstones eventually disappear. Once a tombstone has out-lived every older copy of its key, compaction drops it entirely, reclaiming disk space.
The write/read tension

This is the classic LSM trade-off: writes are cheap because we defer the work, but that deferred work (sorting, merging, de-duplicating) has to happen sometime โ€” and that's compaction. More aggressive compaction = faster reads but more background disk work; less compaction = cheaper writes but slower reads. Tuning this balance is a core job of any LSM-based database.

Watch out

Until compaction runs, a heavily-deleted dataset can pile up many tombstones. A range scan must still read past every tombstone, so a table that's mostly deletes can read slowly even though it "contains nothing." This is a real, well-known LSM gotcha.

Recap The read path searches MemTable โ†’ newest SSTable โ†’ oldest SSTable and stops at the first match, because newest wins. Deletes are recorded as tombstones (special "deleted" markers) since immutable SSTables can't be edited. Compaction merges files, keeps only the newest version of each key, and eventually removes tombstones โ€” making reads steadily faster.

3 The cost of disk reads & why we need an index


Explain like I'm 5

Binary search is like the "guess my number" game: every guess cuts the range in half, so even a huge range gets solved in only a few guesses. That sounds great! But here's the catch โ€” for an SSTable, every "guess" means walking across the room to a giant filing cabinet (the disk) and pulling out a folder. The thinking is fast; the walking is slow. So even ~30 guesses can take half a second. We want to do the thinking in our head (RAM) and walk to the cabinet just once.

Binary search is wonderfulโ€ฆ in theory

Binary search runs in O(log n), which is ultra-fast compared to linear search's O(n). Consider a database with 1 trillion entries:

SearchSteps for 1 trillion entries
Binary search โ€” O(logโ‚‚ 1 trillion)โ‰ˆ 40 steps
Linear search โ€” O(n)1 trillion steps

That makes binary search about 25 billion times faster than linear search here. So why not just binary-search the SSTable and call it a day?

How big do SSTables get?

SSTables grow as they're compacted up the levels. With a typical WAL size of 100 MB, each level roughly doubles (because each SSTable is compacted from two SSTables of the level below):

LevelMax SSTable sizeWhy
WAL100 MBTypical write-ahead-log size
Level 1โ‰ค 100 MBCompacted directly from the WAL
Level 2โ‰ค 200 MBCompacted from 2ร— Level-1 SSTables
Level 3โ‰ค 400 MBCompacted from 2ร— Level-2 SSTables
โ€ฆโ€ฆโ€ฆkeeps doubling
Worked example: the true cost of binary-searching a big SSTable

Take an SSTable of size 100 GB, with each entry (key, value, timestamp) averaging 100 bytes:

  • Number of entries = 100 GB / 100 bytes = 1 billion (10โน).
  • Binary search iterations = logโ‚‚(10โน) = 9 ร— logโ‚‚(10) โ‰ˆ 30 iterations.
  • Each iteration is a random read on disk, and each random read takes ~18 ms.
  • Time to check one SSTable = 30 ร— 18 ms = 540 ms.

Now scale up: with 10 SSTables, a single read can cost up to 5.4 seconds in the worst case. That's unacceptable. We need an index.

The idea

What if we get rid of the O(log n) binary search on disk and instead keep an in-memory (RAM) index that pin-points the location of an entry in the SSTable in O(1)? In the example above, that's a 30ร— speedup: each SSTable lookup drops from 540 ms to a single 18 ms disk read. Across 10 SSTables, worst-case reads fall from 5.4 s to 180 ms.

It's even better than 180 ms in practice

That 180 ms is a worst case. In reality the MemTable acts as a read cache: ~90% of reads are served directly from RAM (โ‰ค 0.1 ms at worst). Only ~10% of the time do we hit the disk, and only in that case do we pay up to 180 ms. So the effective read latency is ~20 ms on average.

Attempt 1: a full index (and why it's too big)

The simplest index: keep a hashmap of {key: offset} in RAM for each SSTable. Given a key, we don't binary-search the SSTable at all โ€” we look it up in the index, which hands us the exact byte offset to read. Reads become O(1) per SSTable โ€” the 30ร— speedup.

The problem is memory. A full index has one entry per SSTable entry:

QuantityValue
Entries in the SSTable1 billion
Bytes per index entry~20 bytes (key) + 8 bytes (offset) = 28 bytes
Full index for 1 SSTable1 billion ร— 28 bytes = 28 GB
Full index for 10 SSTables280 GB
Too much RAM

280 GB of RAM just for indexes is wildly impractical. We need an index that gives us the O(1)-ish speedup without storing an entry for every key. That's the sparse index โ€” the subject of the next section.

Recap Binary search is fast in step-count (~30 iterations for a billion entries) but brutal on disk, because each iteration is an ~18 ms random read โ€” 540 ms per SSTable, up to 5.4 s across 10. An in-memory index turns each SSTable lookup into a single 18 ms disk read (30ร— faster). A full index does this but costs ~28 GB per SSTable (280 GB for 10) โ€” far too much RAM. We need something smaller: the sparse index.

4 Sparse index


Explain like I'm 5

Think of a giant dictionary. To find the word "octopus," you don't read every page from the start. You use the little tabs on the edge โ€” A, B, Cโ€ฆ โ€” to flip straight to "O," then scan just that part. You only need a few tabs, not one tab per word, because once you're on the right page you can read it quickly with your eyes. A sparse index is exactly those edge tabs for an SSTable: a few signposts that get you close, so you only have to read one small chunk.

Topic 2 told us which SSTables to search and in what order. But there's a second problem: once we've picked an SSTable, how do we find a key inside it without reading the whole file? SSTables can be huge โ€” gigabytes. We need a way to jump close to the key.

The crucial advantage: SSTables are sorted

Remember the "Sorted" in Sorted String Table. Inside an SSTable, the keys are stored in sorted order. That single fact is what makes everything fast, because sorted data lets us use binary search (repeatedly halving the search range) instead of reading linearly. But binary search over data on disk still means many separate disk seeks. The sparse index cuts that down further.

Dense vs sparse: why "sparse"?

A dense index would store the exact disk position (the offset โ€” the byte location in the file) of every single key. That's a lot of index โ€” potentially as big as the data itself, and too big to keep in memory.

A sparse index stores the offset of only some keys โ€” say, the first key of every disk block (a fixed-size chunk of the file, e.g. 4 KB read in one shot). It's small enough to live entirely in memory.

Dense indexSparse index
StoresAn offset for every keyAn offset for some keys (e.g. one per block)
SizeLarge (often must stay on disk)Small (fits in memory)
LookupFind exact key directlyFind the block, then scan a little
LSM usesRarelyYes โ€” the standard choice

How a lookup works with a sparse index

Suppose an SSTable's sparse index (held in memory) looks like this โ€” only the first key of each block, with its byte offset:

Indexed keyOffset (byte position in file)
apple0
cherry4096
mango8192
peach12288
Worked example: finding "lemon"

We want the key "lemon". Steps:

  • Binary search the sparse index (it's in memory, so this is instant). We need the largest indexed key that is โ‰ค "lemon". Comparing alphabetically: cherry โ‰ค lemon < mango.
  • So "lemon", if it exists, must be in the block that starts at offset 4096 (the "cherry" block) and ends just before offset 8192 (where "mango" begins).
  • One disk read: load that single ~4 KB block from offset 4096.
  • Scan within the block (a tiny, in-memory linear scan of maybe a few dozen keys) to find "lemon" โ€” or to conclude it isn't there.

Total cost: a free in-memory binary search plus exactly one disk read. Compare that to scanning the entire multi-gigabyte file!

Just how much smaller is a sparse index?

A sparse index is built for each SSTable, kept in RAM, and constructed once when the SSTable is created. The key insight: because data is always read/written in fixed-size blocks, we only need to index the first key of each block, not every key. Reusing the 100 GB SSTable from the previous section:

QuantityValue
Typical block size4 KB
Avg entry size (key, value, timestamp)100 bytes
Keys per block4 KB / 100 bytes = 40 keys
Shrink factor vs. full index40ร— (one indexed key per 40)
Sparse index entries (was 1 billion full)~25 million
Sparse index size (was 28 GB full)~100 MB per SSTable
Key takeaway

The sparse index is just a sorted list of the first key of each block, held in RAM. By indexing one key per block instead of every key, it shrinks by the number of keys per block (~40ร—) โ€” turning a 28 GB full index into a ~100 MB sparse index per SSTable, while still letting us reach any key with a single disk read.

The picture

๐Ÿ”‘
Want "lemon"
Key to find
โ†’
๐Ÿง 
Binary search index
cherry โ‰ค lemon < mango
โ†’
๐Ÿ“ฆ
Read block @4096
One disk read
โ†’
๐Ÿ”Ž
Scan block
Find lemon / not present

Sparse index lookup in code

# Python
from bisect import bisect_right

# The sparse index: sorted (first_key_of_block, byte_offset) pairs, in RAM.
sparse_index = [("apple", 0), ("cherry", 4096), ("mango", 8192), ("peach", 12288)]

# Stand-in for a real file: each block is the run of (key, value) it holds.
blocks = {
    0:     [("apple", "A"), ("banana", "B")],
    4096:  [("cherry", "C"), ("lemon", "L"), ("lime", "Li")],
    8192:  [("mango", "M"), ("orange", "O")],
    12288: [("peach", "P")],
}

def disk_read(offset):       # the ONE disk read
    return blocks[offset]

def sstable_get(key):
    keys = [k for (k, _) in sparse_index]
    # binary search: largest indexed key <= key
    i = bisect_right(keys, key) - 1
    if i < 0:
        return None           # key sorts before the very first block

    _, offset = sparse_index[i]
    block = disk_read(offset)   # one disk read of the candidate block
    for (k, v) in block:        # short in-memory linear scan
        if k == key:
            return v
    return None               # not in this SSTable


# --- tiny demo ---
print(sstable_get("lemon"))   # "L"   (cherry block @4096, then scan)
print(sstable_get("grape"))   # None  (apple block @0 scanned, absent)
// C++
#include <vector>
#include <string>
#include <map>
#include <algorithm>
#include <optional>
#include <iostream>

using Entry = std::pair<std::string, std::string>;   // (key, value)

// Sparse index: sorted (first_key_of_block, byte_offset), in RAM.
std::vector<std::pair<std::string, long>> sparse_index = {
    {"apple", 0}, {"cherry", 4096}, {"mango", 8192}, {"peach", 12288}
};

// Stand-in for a file: each block is the run of entries it holds.
std::map<long, std::vector<Entry>> blocks = {
    {0,     {{"apple", "A"}, {"banana", "B"}}},
    {4096,  {{"cherry", "C"}, {"lemon", "L"}, {"lime", "Li"}}},
    {8192,  {{"mango", "M"}, {"orange", "O"}}},
    {12288, {{"peach", "P"}}}
};

std::optional<std::string> sstable_get(const std::string& key) {
    // binary search: largest indexed key <= key
    auto it = std::upper_bound(
        sparse_index.begin(), sparse_index.end(), key,
        [](const std::string& k, const auto& e){ return k < e.first; });
    if (it == sparse_index.begin()) return std::nullopt;  // before first block
    --it;

    const auto& block = blocks[it->second];   // one disk read of candidate block
    for (const auto& [k, v] : block)        // short in-memory scan
        if (k == key) return v;
    return std::nullopt;                  // not in this SSTable
}

int main() {
    auto a = sstable_get("lemon");
    auto b = sstable_get("grape");
    std::cout << (a ? *a : "NONE") << "\n";   // L
    std::cout << (b ? *b : "NONE") << "\n";   // NONE
    return 0;
}
// Java
import java.util.*;

class SparseIndexLookup {
    record Entry(String key, String value) {}

    // Sparse index: sorted first-key-of-block -> byte offset, in RAM.
    static final String[] indexKeys   = {"apple", "cherry", "mango", "peach"};
    static final long[]   indexOffsets = {0, 4096, 8192, 12288};

    // Stand-in for a file: each block is the run of entries it holds.
    static final Map<Long, List<Entry>> blocks = Map.of(
        0L,     List.of(new Entry("apple", "A"),  new Entry("banana", "B")),
        4096L,  List.of(new Entry("cherry", "C"), new Entry("lemon", "L"), new Entry("lime", "Li")),
        8192L,  List.of(new Entry("mango", "M"),  new Entry("orange", "O")),
        12288L, List.of(new Entry("peach", "P")));

    static String sstableGet(String key) {
        // binary search: largest indexed key <= key
        int i = Arrays.binarySearch(indexKeys, key);
        if (i < 0) i = -(i + 1) - 1;   // insertion point - 1
        if (i < 0) return null;         // before first block

        List<Entry> block = blocks.get(indexOffsets[i]);  // one disk read
        for (Entry e : block)              // short in-memory scan
            if (e.key().equals(key)) return e.value();
        return null;                      // not in this SSTable
    }

    public static void main(String[] args) {
        System.out.println(sstableGet("lemon"));  // L
        System.out.println(sstableGet("grape"));  // null
    }
}
A nice side effect

Because keys are sorted and blocks are contiguous, the sparse index also makes range queries (e.g. "all keys from lemon to orange") efficient: find the starting block, then just read forward through consecutive blocks. This is one reason LSM stores are great for ordered scans.

The trade-off

Sparser index = smaller in-memory index but bigger blocks to scan; denser index = faster scans but more memory. The block size (how far apart the signposts are) is a tuning knob. Either way, a sparse index turns "search a huge file" into "binary-search a tiny table, then read one block."

Recap SSTables store keys sorted, so we can binary search. A sparse index keeps in memory the offset of just the first key of each block. To find a key: binary-search the index for the block that could hold it, do one disk read of that block, then scan it. This turns a whole-file scan into a single block read, and also makes range scans fast.

5 Bloom filters


Explain like I'm 5

Imagine each toy box has a little magic sign on it. Ask the sign, "Is my red car in here?" and it can say two things. It can say "Definitely NOT in this box โ€” don't bother opening it!" or it can say "Maybe! Better look inside." The sign is never wrong when it says NO. It's only sometimes wrong when it says MAYBE (you open the box and the car isn't there after all). That magic sign lets you skip opening most boxes โ€” and that's a bloom filter.

The sparse index made searching inside one SSTable fast. But there's still the problem from Topic 1: a key might be in any of the SSTables, and looking for a non-existent key means touching them all. A bloom filter fixes this. It's a tiny, fast, probabilistic structure that answers one question per SSTable: "Could this key be in here?" If the answer is "no," we skip that SSTable entirely โ€” no disk read at all.

The big idea

A bloom filter gives one-sided answers: "definitely not present" or "possibly present." It can have false positives (says "maybe" when the key is actually absent) but never false negatives (it will never say "no" for a key that is really there). That guarantee is exactly what makes it safe to skip an SSTable when it says "no."

Worked example: the worst-case read โ€” a key that was never inserted

If we call get(key) for a key that was never inserted, we hit the slowest possible read: we check the MemTable (miss), then scan every single SSTable one by one (all misses) before finally giving up. And this case is common. Think of signing up to a website: a user picks the username pragy1234, and we must confirm it doesn't already exist โ€” i.e. a read for a key that (hopefully) was never inserted. A bloom filter turns this worst-case read into a near-instant in-memory "no."

What kind of structure is a bloom filter?

A bloom filter is a probabilistic data structure. It supports only two operations โ€” insert and containment check. It does not support reads, updates, deletes, or iteration. And its containment check can "lie" โ€” but only in one direction:

OutcomePossible?Meaning
False positiveYesA key that was not inserted can still be reported as "exists."
False negativeNeverA key that was inserted will never be reported as "does not exist."

How it's built: a bit array + k hash functions

A bloom filter is just two things:

  • A bit array of m bits, all starting at 0.
  • A set of k independent hash functions. (A hash function turns any key into a number; here we use the result, modulo m, as an index into the bit array.)

To add a key (done when the SSTable is written): run the key through all k hash functions to get k positions, and set those bits to 1.

To test a key (done at read time): hash it the same way to get k positions. If all of those bits are 1 โ†’ answer "maybe." If any bit is 0 โ†’ answer "definitely not" (because if the key had been added, that bit would have been set).

Worked example: m = 10 bits, k = 2 hashes

Start with an empty 10-bit array (indices 0โ€“9):

index:  0 1 2 3 4 5 6 7 8 9
bits:   0 0 0 0 0 0 0 0 0 0

Add "apple": say h1("apple")=2, h2("apple")=7. Set bits 2 and 7:

index:  0 1 2 3 4 5 6 7 8 9
bits:   0 0 1 0 0 0 0 1 0 0

Add "mango": say h1("mango")=4, h2("mango")=7. Set bits 4 and 7 (bit 7 was already 1):

index:  0 1 2 3 4 5 6 7 8 9
bits:   0 0 1 0 1 0 0 1 0 0

Now query keys:

  • Test "grape": say h1=1, h2=5. Check bits 1 and 5 โ†’ both 0. At least one is 0 โ†’ "definitely NOT present." We skip this SSTable. โœ… Correct โ€” "grape" was never added.
  • Test "apple": bits 2 and 7 โ†’ both 1 โ†’ "maybe present." We go look. โœ… Correct โ€” "apple" really is there.
  • Test "cherry": say h1=2, h2=4. Check bits 2 and 4 โ†’ both happen to be 1 (set by "apple" and "mango")! โ†’ "maybe present." But "cherry" was never added โ€” this is a false positive. We waste one lookup, but we never give a wrong final answer because the SSTable read itself confirms "cherry" isn't there.
Key takeaway: why no false negatives

If a key was added, every one of its k bits was set to 1 and bits are never cleared. So when we test that same key, all its bits are still 1 โ†’ it can never be reported as "absent." A "no" is therefore always trustworthy. A "maybe," however, can be a coincidence of bits set by other keys โ€” that's a false positive.

Bloom filter in code

# Python
class BloomFilter:
    def __init__(self, m, k):
        self.m = m                  # number of bits
        self.k = k                  # number of hash functions
        self.bits = [0] * m       # bit array, all zero

    def _positions(self, key):
        # derive k independent indices from one base hash
        h = hash(key)
        h1, h2 = h & 0xFFFFFFFF, (h >> 32) & 0xFFFFFFFF
        for i in range(self.k):
            yield (h1 + i * h2) % self.m   # double hashing

    def add(self, key):
        for pos in self._positions(key):
            self.bits[pos] = 1

    def might_contain(self, key):
        for pos in self._positions(key):
            if self.bits[pos] == 0:
                return False      # a 0 bit => definitely NOT present
        return True               # all 1 => possibly present (maybe false positive)


# --- tiny demo ---
bf = BloomFilter(m=100, k=3)
for word in ("apple", "mango", "cherry"):
    bf.add(word)

print(bf.might_contain("apple"))   # True  (was added)
print(bf.might_contain("grape"))   # False or True; if False it is DEFINITELY absent
# A "False" is always trustworthy: no false negatives.
# A "True" may be a false positive -- confirm with the real lookup.

# Sizing: for n keys at false-positive rate p,
#   m = ceil(-n * ln(p) / (ln 2)^2)   bits
#   k = round((m / n) * ln 2)         hashes
# e.g. p = 1%  ->  ~10 bits per key, k = 7.
// C++
#include <vector>
#include <string>
#include <functional>
#include <iostream>

class BloomFilter {
    size_t m_, k_;
    std::vector<bool> bits_;          // m bits, all false

    // derive k indices from one base hash (double hashing)
    template <class F>
    void positions(const std::string& key, F fn) const {
        size_t h  = std::hash<std::string>{}(key);
        size_t h1 = h & 0xFFFFFFFFu;
        size_t h2 = (h >> 32) & 0xFFFFFFFFu;
        if (h2 == 0) h2 = 1;
        for (size_t i = 0; i < k_; ++i) fn((h1 + i * h2) % m_);
    }

public:
    BloomFilter(size_t m, size_t k) : m_(m), k_(k), bits_(m, false) {}

    void add(const std::string& key) {
        positions(key, [&](size_t pos){ bits_[pos] = true; });
    }

    bool might_contain(const std::string& key) const {
        bool all_set = true;
        positions(key, [&](size_t pos){ if (!bits_[pos]) all_set = false; });
        return all_set;    // false => definitely absent; true => maybe present
    }
};

int main() {
    BloomFilter bf(100, 3);
    for (const std::string& w : {"apple", "mango", "cherry"}) bf.add(w);

    std::cout << bf.might_contain("apple") << "\n";  // 1  (was added)
    std::cout << bf.might_contain("grape") << "\n";  // 0 or 1; if 0 it is DEFINITELY absent
    // No false negatives: a "0" can be trusted. A "1" may be a false positive.

    // Sizing: for n keys at false-positive rate p,
    //   m = ceil(-n * ln(p) / (ln 2)^2)  bits
    //   k = round((m / n) * ln 2)        hashes   (p = 1% -> ~10 bits/key, k = 7)
    return 0;
}
// Java
import java.util.BitSet;

class BloomFilter {
    private final int m, k;
    private final BitSet bits;        // m bits, all clear

    BloomFilter(int m, int k) {
        this.m = m;
        this.k = k;
        this.bits = new BitSet(m);
    }

    // derive k indices from one base hash (double hashing)
    private int[] positions(String key) {
        long h  = key.hashCode() & 0xFFFFFFFFL;
        long h2 = (Long.rotateLeft(h, 17) | 1) & 0xFFFFFFFFL;
        int[] pos = new int[k];
        for (int i = 0; i < k; i++)
            pos[i] = (int) (((h + (long) i * h2) % m + m) % m);
        return pos;
    }

    void add(String key) {
        for (int pos : positions(key)) bits.set(pos);
    }

    boolean mightContain(String key) {
        for (int pos : positions(key))
            if (!bits.get(pos)) return false;   // a clear bit => definitely absent
        return true;                          // all set => maybe present
    }

    public static void main(String[] args) {
        BloomFilter bf = new BloomFilter(100, 3);
        for (String w : new String[]{"apple", "mango", "cherry"}) bf.add(w);

        System.out.println(bf.mightContain("apple")); // true  (was added)
        System.out.println(bf.mightContain("grape")); // false or true; if false, DEFINITELY absent
        // No false negatives: "false" is trustworthy. "true" may be a false positive.

        // Sizing: for n keys at false-positive rate p,
        //   m = ceil(-n * ln(p) / (ln 2)^2)  bits
        //   k = round((m / n) * ln 2)        hashes   (p = 1% -> ~10 bits/key, k = 7)
    }
}

Plugging it into set() and get()

Every write inserts the key into the bloom filter; every read first asks the bloom filter and bails out immediately on a "no":

void set(key, value) {
    bloom_filter.insert(key)
    // โ€ฆ proceed with the DB insertion
}

string get(key) {
    if (! bloom_filter.contains(key))
        raise KeyNotFound!       // guaranteed never inserted
    // if bloom filter says key found, the DB might or might not have the key
    // proceed with normal DB check
}

Plugging it into the read path

Now we can complete the read path from Topic 2. Each SSTable keeps its own small bloom filter in memory. Before doing any disk work on an SSTable, we ask its bloom filter:

    for sstable in sstables_newest_to_oldest:
        if not sstable.bloom.might_contain(key):
            continue              # skip โ€” no disk read at all!
        result = sstable.get(key)    # uses the sparse index (Topic 3)
        if result is not NONE:
            return unwrap(result)
Why this is the key to fast LSM reads

Remember the worst case: reading a key that doesn't exist meant touching every SSTable. With bloom filters, almost all of those SSTables answer "definitely not" instantly from memory and are skipped with zero disk reads. A lookup for a missing key drops from "search N files on disk" to "check N tiny in-memory bit arrays." This is what makes LSM reads โ€” especially for absent keys โ€” fast in practice.

Tuning: false-positive rate

The chance of a false positive depends on three things: the number of bits m, the number of keys n stored, and the number of hash functions k. More bits per key โ†’ fewer collisions โ†’ fewer false positives. A common target is around 1%, which costs only about 10 bits per key โ€” astonishingly cheap.

Bits per key (m/n)Approx. false-positive rate
~5 bits~10%
~10 bits~1%
~15 bits~0.1%
Why deletes need care

A standard bloom filter can't remove a key โ€” clearing bits could break another key that shares them (and reintroduce false negatives). That's fine for LSM trees: each SSTable is immutable and gets a fresh bloom filter built once at flush time, and compaction simply builds new filters for the merged output. No deletion from the filter is ever needed.

Watch out

Bloom filters only answer membership for exact keys. They can't help a range query ("all keys between X and Y"), because a range isn't a single key to hash. Range scans still rely on the sorted SSTables and the sparse index (Topic 3), not on bloom filters.

Recap A bloom filter is a bit array plus k hash functions. Adding a key sets k bits; testing a key checks those bits โ€” any 0 means "definitely not present," all 1 means "maybe." It has false positives but never false negatives, so a "no" is always safe to trust. Giving each SSTable a tiny in-memory bloom filter lets reads skip files that can't contain the key with no disk access โ€” the single biggest win for LSM read performance.

โ˜… Putting it all together


You now understand how LSM-tree databases turn their fast-write design into fast reads too. Here's the one-paragraph story that connects all four topics:

Because an LSM tree only ever appends, a key's versions are scattered across the MemTable and many SSTables, so a naive read might scan everything โ€” especially for a key that doesn't exist. The read path fixes correctness by searching newest-to-oldest and stopping at the first hit, treating a tombstone as "deleted," while compaction quietly merges files and drops stale data to keep the file count low. Within a chosen SSTable, the sparse index binary-searches a tiny in-memory table to jump to the one block that could hold the key, costing a single disk read. And before touching an SSTable at all, its bloom filter answers "definitely not / maybe" from memory โ€” skipping files that can't contain the key with zero disk reads. Together: fewer files (compaction), skip impossible files (bloom filter), and jump straight to the right block in the rest (sparse index).

The final structure & algorithm

Here is the complete picture of how a mature LSM tree handles a write and a read, pulling every piece together.

Writes
  1. Append to the WAL file first (for durability).
  2. Set the value in the MemTable (for fast reads).

Deletions are writes that set the value to TOMBSTONE, and the bloom filter is updated on every write. After any write, three events may trigger:

  • Flush: if the WAL file is full, flush it into a new SSTable.
  • Compaction: if there are too many SSTables on any level, trigger compaction.
  • Eviction: if the MemTable is full, use LRU eviction to remove the oldest entry.
Reads
  1. Check the MemTable (it acts as a cache). If found, the value is guaranteed to be the latest โ€” every change goes write-through to the MemTable + WAL, so MemTable data can never be stale. Most reads are O(1) from RAM and never touch the disk. After reading, update the key's last-accessed timestamp in the MemTable.
  2. If not in the MemTable, we do not need to check the WAL โ€” the MemTable is much larger than the WAL and uses LRU eviction, so anything in the WAL is also in the MemTable. Instead, scan all SSTables newest โ†’ oldest. For each one:
    • Binary-search (lowerbound) the key in that SSTable's sparse index in RAM โ€” no disk reads โ€” to learn which block to read.
    • Read that one block into RAM โ€” a single disk access.
    • Binary-search the block in RAM (RAM is ~1 million times faster than disk, so this is effectively free).
    • If the key is found, it's the latest value (we're scanning recent โ†’ old) โ†’ return it. If not, move to the next older SSTable.
  3. If the key is found nowhere, raise KeyNotFoundError. If the value found is TOMBSTONE, also raise KeyNotFoundError.
  4. Once a value is found on disk, update the MemTable so subsequent reads of this key are fast.
Isn't scanning all SSTables slow?

Yes โ€” which is exactly why we have the optimisations from this session: Compaction keeps the SSTable count small (typically โ‰ค 10); the sparse index turns slow on-disk binary search into an O(1)-ish single block read; and the bloom filter lets us skip SSTables that can't contain the key.

Quick self-check

Why must the read path search SSTables from newest to oldest?

Because newest wins: the most recently written version of a key is its true current value. Searching newest-first and stopping at the first match guarantees you return the correct (latest) value โ€” including a recent delete.

How do you delete a key if SSTables are immutable?

You write a tombstone โ€” a special "deleted" marker โ€” as a new write. Since it's the newest record for that key, the read path finds it first and reports the key as gone. Compaction later removes the tombstone and the dead value it shadows.

What does a sparse index store, and how does it speed up a lookup?

It stores the disk offset of only some keys (e.g. the first key of each block) in memory. A lookup binary-searches it to find the one block that could contain the key, then does a single disk read of that block and scans it โ€” instead of scanning the whole file.

A bloom filter says "maybe present." Could the key actually be absent?

Yes โ€” that's a false positive (other keys happened to set the same bits). You then do the real lookup, which confirms the truth. Bloom filters never produce false negatives, so a "definitely not" is always trustworthy.

Why is the bloom filter the biggest win for reading non-existent keys?

Without it, proving a key is absent means searching every SSTable on disk. With one per SSTable, almost all answer "definitely not" instantly from memory, so those files are skipped with zero disk reads โ€” turning N disk searches into N tiny in-memory checks.

Why can't a bloom filter help with a range query like "keys between X and Y"?

A bloom filter only answers membership for an exact key it can hash. A range isn't a single key, so there's nothing to hash. Range scans instead use the sorted SSTables and the sparse index to find a starting block and read forward.

๐Ÿ“š References & Further Reading


Class material

Papers, docs & deep dives

Interactive tools & visualizations