1 The read problem in LSM trees
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:
| Location | Could the key be here? | Why |
|---|---|---|
| The MemTable (in memory) | Yes | It may have been written very recently and not flushed yet. |
| The newest SSTable on disk | Yes | It may have been flushed a moment ago. |
| An older SSTable | Yes | It may have been written long ago and never touched since. |
| Nowhere at all | Yes | The key may simply not exist โ but we don't know that until we've looked everywhere! |
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:
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.
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
| Tool | Job | Topic |
|---|---|---|
| The read path | A correct, ordered way to search MemTable โ newest โ oldest, handling deletes. | Topic 2 |
| Sparse index | Jump straight to the right block inside one SSTable instead of scanning the whole file. | Topic 3 |
| Bloom filter | Skip entire SSTables that definitely don't contain the key. | Topic 4 |
2 The read path & tombstones
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
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.
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 approach | What it does | Does it work? |
|---|---|---|
| 1. Delete from MemTable only | Remove 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 SSTables | Physically 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. |
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 }
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.
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.
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.
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.
3 The cost of disk reads & why we need an index
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:
| Search | Steps 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):
| Level | Max SSTable size | Why |
|---|---|---|
| WAL | 100 MB | Typical write-ahead-log size |
| Level 1 | โค 100 MB | Compacted directly from the WAL |
| Level 2 | โค 200 MB | Compacted from 2ร Level-1 SSTables |
| Level 3 | โค 400 MB | Compacted from 2ร Level-2 SSTables |
| โฆ | โฆ | โฆkeeps doubling |
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.
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.
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:
| Quantity | Value |
|---|---|
| Entries in the SSTable | 1 billion |
| Bytes per index entry | ~20 bytes (key) + 8 bytes (offset) = 28 bytes |
| Full index for 1 SSTable | 1 billion ร 28 bytes = 28 GB |
| Full index for 10 SSTables | 280 GB |
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.
4 Sparse index
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 index | Sparse index | |
|---|---|---|
| Stores | An offset for every key | An offset for some keys (e.g. one per block) |
| Size | Large (often must stay on disk) | Small (fits in memory) |
| Lookup | Find exact key directly | Find the block, then scan a little |
| LSM uses | Rarely | Yes โ 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 key | Offset (byte position in file) |
|---|---|
| apple | 0 |
| cherry | 4096 |
| mango | 8192 |
| peach | 12288 |
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:
| Quantity | Value |
|---|---|
| Typical block size | 4 KB |
Avg entry size (key, value, timestamp) | 100 bytes |
| Keys per block | 4 KB / 100 bytes = 40 keys |
| Shrink factor vs. full index | 40ร (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 |
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
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 } }
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.
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."
5 Bloom filters
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.
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."
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:
| Outcome | Possible? | Meaning |
|---|---|---|
| False positive | Yes | A key that was not inserted can still be reported as "exists." |
| False negative | Never | A 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).
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.
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)
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% |
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.
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.
โ 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.
- Append to the WAL file first (for durability).
- 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.
- 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. - 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.
- If the key is found nowhere, raise
KeyNotFoundError. If the value found isTOMBSTONE, also raiseKeyNotFoundError. - Once a value is found on disk, update the MemTable so subsequent reads of this key are fast.
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
- ๐ Original class notes / handout (Google Doc) โ open the shared class material for this session.
- Class handout: "[SST-2028] NoSQL Internals - LSM Tree + Bloom Filter + Sparse Index".
Papers, docs & deep dives
- ๐ Bloom, B. H. โ "Space/time trade-offs in hash coding with allowable errors" (1970) โ the original bloom filter paper.
- ๐ Bloom Filters by Example (interactive tutorial) โ a visual, hands-on explainer of how bloom filters work.
- ๐ RocksDB โ Bloom Filter docs โ how a production LSM engine uses per-SSTable bloom filters to skip files.
- ๐ Designing Data-Intensive Applications (Kleppmann), Ch. 3 โ SSTables, sparse indexes, and bloom filters in LSM storage.
Interactive tools & visualizations
- ๐ณ LSM Tree Visualizer โ watch writes, flushes, and compaction happen live (source on GitHub).
- ๐งฎ Bloom filter calculator (hur.st) โ compute the optimal bit-array size m and number of hashes k for a target false-positive rate.
- ๐ Bloom filter โ math & code (Brilliant.org) โ the probability math behind false-positive rates.