<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ajoy Deb Nath</title>
    <description>The latest articles on DEV Community by Ajoy Deb Nath (@ajoy_deb).</description>
    <link>https://dev.to/ajoy_deb</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4078752%2Fbfa1a117-f104-4605-bc9f-cb08e6ac9dbe.jpg</url>
      <title>DEV Community: Ajoy Deb Nath</title>
      <link>https://dev.to/ajoy_deb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ajoy_deb"/>
    <language>en</language>
    <item>
      <title>KVLab: Investigating the Trade-offs Inside an LSM Storage Engine</title>
      <dc:creator>Ajoy Deb Nath</dc:creator>
      <pubDate>Sun, 16 Aug 2026 16:41:03 +0000</pubDate>
      <link>https://dev.to/ajoy_deb/kvlab-investigating-the-trade-offs-inside-an-lsm-storage-engine-4364</link>
      <guid>https://dev.to/ajoy_deb/kvlab-investigating-the-trade-offs-inside-an-lsm-storage-engine-4364</guid>
      <description>&lt;p&gt;&lt;em&gt;What actually happens between &lt;code&gt;put()&lt;/code&gt; and durable storage?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I’ve used databases for years without thinking much about what happens after calling &lt;code&gt;put()&lt;/code&gt;. The application sends a write, gets an acknowledgement, and moves on. But what exactly happened before that acknowledgement? Something was written to a WAL, something was sitting in memory, eventually something was flushed to an SSTable, and somewhere in the middle there was an &lt;code&gt;fsync()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I knew what all of those terms meant individually. I wasn't sure I understood how they fit together. That was what I wanted to figure out.&lt;/p&gt;

&lt;p&gt;I started with a few basic questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How does the database recover after a crash?&lt;/li&gt;
&lt;li&gt;If data eventually goes into SSTables, why write it to a WAL first?&lt;/li&gt;
&lt;li&gt;Why are SSTables immutable?&lt;/li&gt;
&lt;li&gt;What problem does compaction actually solve?&lt;/li&gt;
&lt;li&gt;How much work does a Bloom filter really save?&lt;/li&gt;
&lt;li&gt;Where does read amplification come from?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of answering those questions only by reading LevelDB or RocksDB, I decided to build a small storage engine myself.&lt;/p&gt;

&lt;p&gt;That became &lt;strong&gt;KVLab&lt;/strong&gt;, an LSM-based key-value store written in Java. The LSM implementation ended up being roughly 1,300 lines, along with a benchmark harness that records the results as CSV files.&lt;/p&gt;

&lt;p&gt;I wasn't trying to build a production database, or even a particularly fast one. I wanted to implement the pieces myself and see what actually changed when I added them.&lt;/p&gt;

&lt;p&gt;And some of the results were not what I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the engine
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Write path:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu57u18b3q8vi2clqqqhw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu57u18b3q8vi2clqqqhw.png" alt="Write path" width="800" height="2256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read path:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fltfvakpgggetzzjqnapz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fltfvakpgggetzzjqnapz.png" alt="Read path" width="800" height="2868"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each record is stored as &lt;code&gt;[4-byte key length][4-byte value length][key][value]&lt;/code&gt;. At the end of the file, there’s a Bloom block, an index block, and a 32-byte footer containing their offsets and the AKV2 magic number.&lt;br&gt;
Deletes are handled with tombstones. Since SSTables are immutable, you can't just go back and remove a record. Instead, a delete writes a record with a sentinel value length, which tells the reader that the key has been deleted.&lt;br&gt;
There’s also no block cache or background compaction yet. Compaction runs directly on the writer thread. That turned out to matter more than I expected later.&lt;/p&gt;
&lt;h2&gt;
  
  
  WAL, MemTable, SSTable — and why that order
&lt;/h2&gt;

&lt;p&gt;The write ordering in KVLab is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;WAL append → MemTable update → return to caller&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The order matters. Before telling the caller that a write succeeded, the WAL needs to have the write on disk. The MemTable is then updated so the write can be served from memory instead of touching an SSTable for every key.&lt;/p&gt;

&lt;p&gt;SSTables are immutable, which also means readers never have to deal with a file being modified underneath them.&lt;/p&gt;

&lt;p&gt;Recovery is straightforward because of this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ucp4pmyuypm51jalvpj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ucp4pmyuypm51jalvpj.png" alt="crash recovery" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;KVLab's WAL is deliberately simple. Each record is stored as a text line, with values Base64-encoded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PUT user1 QWpveQ==
DELETE user1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I kept it this way because I wanted to be able to &lt;code&gt;cat&lt;/code&gt; the WAL while debugging. It's easy to inspect and easy to reason about. It's also not particularly efficient, which shows up later in the storage benchmarks.&lt;/p&gt;

&lt;p&gt;The same immutability that makes SSTables easier to read also means deletes need special handling. If key &lt;code&gt;A&lt;/code&gt; exists in an older SSTable, we can't go back and remove it. Instead, the newer layer gets a tombstone.&lt;/p&gt;

&lt;p&gt;The read path checks newer data first, so it sees the tombstone before reaching the older value and treats the key as deleted. The old record stays on disk until compaction removes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem immutability creates
&lt;/h2&gt;

&lt;p&gt;Immutable files make writes simpler because nothing has to rewrite an existing file. The downside is that the files start to pile up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SSTable 1  SSTable 2  SSTable 3  ...  SSTable 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a hit, we might get lucky and find the key in a recent file. A miss is different. To know that a key doesn't exist, we may have to check every SSTable. With twenty files, that's twenty files to check and potentially twenty unnecessary reads.&lt;/p&gt;

&lt;p&gt;So the first problem is simple: &lt;strong&gt;how do we make searching each SSTable cheaper?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to make one file cheaper to search
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The index&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of scanning &lt;code&gt;key1&lt;/code&gt;, &lt;code&gt;key2&lt;/code&gt;, &lt;code&gt;key3&lt;/code&gt;, … &lt;code&gt;key10000&lt;/code&gt; looking for a match, we keep a sorted key-to-offset table at the end of the file and binary-search it.&lt;/p&gt;

&lt;p&gt;KVLab's index is &lt;em&gt;dense&lt;/em&gt;: there is one entry for every key, stored as &lt;code&gt;[4-byte key length][8-byte offset][key bytes]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Bloom filter&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;Before searching the index, we can ask a cheaper question: &lt;em&gt;could this key be in the file?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If the filter says no, we can skip the file completely. If it says yes, the key might be there, so we continue with the index lookup.&lt;/p&gt;

&lt;p&gt;A Bloom filter never gives a false negative, but it can give a false positive. KVLab uses a plain &lt;code&gt;BitSet&lt;/code&gt;, with 10 bits per key by default and 3 hash functions derived from a 31-polynomial hash using double hashing (&lt;code&gt;h1 + i·h2&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Both the index and Bloom filter are stored at the end of the SSTable. That makes them easy to find from the footer, but they also add to the size of every file.&lt;/p&gt;

&lt;p&gt;That's where the next set of measurements gets interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 1 — does the index actually help?
&lt;/h2&gt;

&lt;p&gt;I built the same key set two ways: v1 with records only, and v2 with the Bloom filter, index, and footer. I tested 100, 1K, and 10K keys, with 2,000 probe keys per run — half hits and half misses, using a deterministic seed.&lt;br&gt;
One detail turned out to matter a lot: I created a new SSTableReader for every probe, so each lookup opened the file from scratch, matching what KVLab's real lookup path does.&lt;br&gt;
That wasn't how the benchmark started. My first version opened one reader and reused it for all 2,000 probes. The v2 miss cost came out to roughly zero bytes read from the SSTable, which delighted me for about ten minutes. Then I realized I was measuring a reused reader, while the actual lookup path opens a fresh reader each time.&lt;br&gt;
The whole benchmark was flattering itself.&lt;br&gt;
It's a good reminder that a number without a proper baseline isn't really a measurement. It's just a result you haven't investigated yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results, 2,000 probes each:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Keys&lt;/th&gt;
&lt;th&gt;Reader&lt;/th&gt;
&lt;th&gt;File size&lt;/th&gt;
&lt;th&gt;Bytes/miss&lt;/th&gt;
&lt;th&gt;Read amp (miss)&lt;/th&gt;
&lt;th&gt;Miss p50&lt;/th&gt;
&lt;th&gt;Miss p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;v1 scan&lt;/td&gt;
&lt;td&gt;2,180&lt;/td&gt;
&lt;td&gt;2,180&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;0.22 ms&lt;/td&gt;
&lt;td&gt;0.38 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;v2 index&lt;/td&gt;
&lt;td&gt;4,150&lt;/td&gt;
&lt;td&gt;1,938&lt;/td&gt;
&lt;td&gt;0.47&lt;/td&gt;
&lt;td&gt;0.019 ms&lt;/td&gt;
&lt;td&gt;0.054 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;v1 scan&lt;/td&gt;
&lt;td&gt;23,780&lt;/td&gt;
&lt;td&gt;23,780&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;2.15 ms&lt;/td&gt;
&lt;td&gt;2.81 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;v2 index&lt;/td&gt;
&lt;td&gt;43,976&lt;/td&gt;
&lt;td&gt;20,164&lt;/td&gt;
&lt;td&gt;0.46&lt;/td&gt;
&lt;td&gt;0.055 ms&lt;/td&gt;
&lt;td&gt;0.146 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;v1 scan&lt;/td&gt;
&lt;td&gt;257,780&lt;/td&gt;
&lt;td&gt;257,780&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;21.5 ms&lt;/td&gt;
&lt;td&gt;26.4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;v2 index&lt;/td&gt;
&lt;td&gt;469,226&lt;/td&gt;
&lt;td&gt;211,414&lt;/td&gt;
&lt;td&gt;0.45&lt;/td&gt;
&lt;td&gt;0.44 ms&lt;/td&gt;
&lt;td&gt;0.83 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;v1's miss amplification is exactly 1.00 at every size, which is what a full scan means: to prove absence you read the whole thing.&lt;/p&gt;

&lt;p&gt;The headline is the latency column. At 10K keys, a miss goes from 21.5 ms to 0.44 ms — about fifty times faster at the median, thirty at p99.&lt;/p&gt;

&lt;p&gt;Now look at the bytes column, because that's where I stopped being pleased with myself. v2 read 211 KB per miss. v1 read 258 KB. That's an 18% improvement in bytes for a 50× improvement in time. Those two numbers don't line up, and the gap is the actual finding: v1's scan walks the file record by record — two channel reads per record, 10,000 records, ~20,000 syscalls. v2 does four reads: the footer, the Bloom block, the index block, and then (if Bloom says maybe) one record. Same order of magnitude of bytes, three orders of magnitude fewer operations.&lt;/p&gt;

&lt;p&gt;I had been thinking about read amplification as a byte ratio, because that's how it's usually defined. For this workload the byte ratio was almost the least interesting thing about it.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why v2 still reads 211 KB
&lt;/h3&gt;

&lt;p&gt;The v2 file is larger than the v1 file — 469 KB versus 258 KB for the same 10,000 records. Where did 211 KB come from?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Bloom block (10 bits/key)      12,516 B    2.7%
Index block (dense, 1/key)    198,894 B   42.4%
Block headers + footer             40 B
                              --------
                               211,450 B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;211,450 is, to the byte, the difference between the two files. It's also — again to the byte — what v2 reads on every cold open, because &lt;code&gt;SSTableReader&lt;/code&gt;'s constructor eagerly loads the footer, then the Bloom block, then the index block, before anyone has asked it a question.&lt;/p&gt;

&lt;p&gt;So the honest statement of the result is that my index is nearly as large as my data, and I load all of it before I look at anything. The Bloom filter is 2.7% of the file and does real work. The index is 42% of the file and I pay for all of it to look up one key.&lt;/p&gt;

&lt;p&gt;The obvious next step is to make the index sparse: one entry per block of records instead of one entry per record. You binary-search to the right block, then scan the records inside it. LevelDB uses this general approach with block indexes and restart points.&lt;br&gt;
I'd read about sparse indexes before and filed the idea under "optimization." After this experiment, I don't think of it that way anymore. The index worked. I just hadn't considered what it cost to load before it could actually help a lookup.&lt;/p&gt;
&lt;h2&gt;
  
  
  Experiment 2 — how much does the Bloom filter help?
&lt;/h2&gt;

&lt;p&gt;At 8, 10, 12, and 16 bits per key, I ran 2,000 probes per key-count — 100, 1,000, 10,000 — using keys that are definitely absent, and counted how many the filter rejected outright versus how many slipped through. The internal check passed first: &lt;code&gt;bloom_rejected + false_positives = 2000&lt;/code&gt; in every single row. Whatever else turned out to be wrong, the counting was right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At 10,000 keys:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bits/key&lt;/th&gt;
&lt;th&gt;Bloom block&lt;/th&gt;
&lt;th&gt;Rejected&lt;/th&gt;
&lt;th&gt;False positives&lt;/th&gt;
&lt;th&gt;Actual FP&lt;/th&gt;
&lt;th&gt;Theoretical FP (k=3)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;10,016 B&lt;/td&gt;
&lt;td&gt;1,946&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;2.70%&lt;/td&gt;
&lt;td&gt;3.06%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;12,516 B&lt;/td&gt;
&lt;td&gt;1,969&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;1.55%&lt;/td&gt;
&lt;td&gt;1.74%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;15,016 B&lt;/td&gt;
&lt;td&gt;1,980&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;1.00%&lt;/td&gt;
&lt;td&gt;1.08%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;20,015 B&lt;/td&gt;
&lt;td&gt;1,985&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;0.75%&lt;/td&gt;
&lt;td&gt;0.50%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trend is right — more space, fewer false positives and 8→12 bits halves the FP rate for 5 KB more disk, tracking theory closely. At 16 bits it doesn't: measured 0.75% against a predicted 0.50%, half again worse than it should be. At 100 keys the numbers are noise (10 bits/key scored worse than 8, 2.95% vs 2.55%) with FP counts in the teens out of 2,000 probes there isn't enough signal to say anything. But 10K keys with 15 false positives isn't noise, and 16 bits is the one row that misses.&lt;/p&gt;

&lt;p&gt;My first guess was the hash. &lt;code&gt;hash()&lt;/code&gt; is a 31-polynomial over the key bytes, and the second hash is seeded from the first, so h1 and h2 aren't independent. At 8 bits per key the filter is saturated enough that mediocre hashing hides inside the collision rate; at 16 bits there's room for the hash quality to show.&lt;/p&gt;

&lt;p&gt;But there's a bigger problem sitting in plain sight, and I only noticed it writing this up: &lt;code&gt;NUM_HASHES&lt;/code&gt; is hardcoded to 3. The optimal number of hash functions for a Bloom filter is (m/n)·ln2 — about 7 for 10 bits per key, about 11 for 16. At k=3 and 16 bits/key I'm paying double the space of the 8-bit filter for a 3.6× improvement, when properly tuned it should be closer to 60×:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bits/key&lt;/th&gt;
&lt;th&gt;FP at k=3&lt;/th&gt;
&lt;th&gt;FP at optimal k&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;1.7%&lt;/td&gt;
&lt;td&gt;0.8% (k=7)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;0.5%&lt;/td&gt;
&lt;td&gt;0.05% (k=11)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I bought more space and left most of the return on the table, because I tuned one parameter and never touched the one it depends on. My "theoretical" column agreed with my measurements, which is exactly why I never questioned it, the formula was parameterized with the same wrong k. A theory that only ever predicts what you already built isn't a check on anything.&lt;/p&gt;
&lt;h3&gt;
  
  
  What the numbers don't show
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;bytes_saved_per_miss&lt;/code&gt; column in my Bloom CSV is negative. Not zero — negative, by a few bytes, growing with file size.&lt;/p&gt;

&lt;p&gt;That's not a bug in the filter. It's the cold-open problem from Experiment 1 again. Bloom rejection prevents a data-block read, which is a genuine win over a v1 scan. It does not prevent the metadata read, because the reader loads the Bloom block and the index block before &lt;code&gt;mightContain()&lt;/code&gt; gets a chance to say no. The with-Bloom configuration reads slightly more than the without-Bloom one, because the Bloom block itself has to be loaded.&lt;/p&gt;

&lt;p&gt;So on a cold open, the filter I built to avoid I/O is, strictly in bytes, a small net cost. It saves time (no scan), and it would save real I/O in a reader that loaded the filter first and the index only on a maybe. Mine doesn't — a filter can only skip the work that happens after it runs, and in KVLab, most of the expensive work happens before.&lt;/p&gt;
&lt;h2&gt;
  
  
  Experiment 3 — why compaction matters
&lt;/h2&gt;

&lt;p&gt;This one is the cleanest result in the project, and I didn't have to argue with it at all.&lt;/p&gt;

&lt;p&gt;I built 20 L0 SSTables, 500 keys each, same key set in every file with different values, simulating twenty flushes that all overwrote the same range, then ran 500 probes for keys that exist in none of them, before and after an L0→L1 merge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before:  L0 L0 L0 L0 L0 ... L0   (20 files)
after:   L1                       (1 file)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Files probed per miss&lt;/td&gt;
&lt;td&gt;20.0&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bytes read per miss&lt;/td&gt;
&lt;td&gt;200,780&lt;/td&gt;
&lt;td&gt;10,039&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50 latency&lt;/td&gt;
&lt;td&gt;0.67 ms&lt;/td&gt;
&lt;td&gt;0.034 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;0.85 ms&lt;/td&gt;
&lt;td&gt;0.044 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;1.03 ms&lt;/td&gt;
&lt;td&gt;0.054 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twenty times fewer bytes, twenty times faster, and the ratio is exactly 20 because the cost is exactly linear in file count: 200,780 ÷ 20 = 10,039, which is precisely one file's Bloom block plus index block. Every one of those twenty opens loaded metadata, asked the filter, got told no, and closed. Nineteen of them were pure overhead.&lt;/p&gt;

&lt;p&gt;One caveat worth stating plainly: all twenty files held the same 500 keys, so this is compaction's best case. Twenty files with disjoint key ranges wouldn't shrink to one file's worth of data. But the file-count effect — one metadata load per file per miss — doesn't depend on overlap at all. That part generalizes.&lt;/p&gt;

&lt;p&gt;What I took from this is that I'd been filing compaction under housekeeping — reclaim space, drop tombstones, tidy up. That's the side effect. The main effect is that it's the only thing standing between you and a read path that gets linearly slower every time you flush. I'd built it as garbage collection and it measured out as a read-path mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  What all of this cost
&lt;/h2&gt;

&lt;p&gt;I spent three experiments optimizing reads. The fourth asks what the bill was: 1,000 puts of 100-byte values, into the LSM store and into a Bitcask-style single-append-file store, same API, same data.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Store&lt;/th&gt;
&lt;th&gt;Logical bytes&lt;/th&gt;
&lt;th&gt;WAL&lt;/th&gt;
&lt;th&gt;SSTables&lt;/th&gt;
&lt;th&gt;Total on disk&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bitcask (binary)&lt;/td&gt;
&lt;td&gt;106,890&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;115,890&lt;/td&gt;
&lt;td&gt;115,890&lt;/td&gt;
&lt;td&gt;1.08×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LSM&lt;/td&gt;
&lt;td&gt;106,890&lt;/td&gt;
&lt;td&gt;148,890&lt;/td&gt;
&lt;td&gt;135,307&lt;/td&gt;
&lt;td&gt;284,197&lt;/td&gt;
&lt;td&gt;2.66×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The LSM took 2.5× the disk of the append-only store for identical data. The WAL alone is larger than the user's data. I worked out where every byte went, and it's all explicable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WAL, per record: "PUT " (4) + key (6.89 avg) + " " (1)
  + Base64(100-byte value) (136) + "\n" (1)
  = 148.89 B  &amp;lt;- 1.49x the logical bytes, from Base64 alone

SSTable, per key: header (8) + key (6.89) + value (100) = 114.89
  + index entry (4 + 8 + 6.89) = 18.89
  + bloom (10 bits) = 1.25
  = 135.03 B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Base64 is a 4/3 expansion, so my decision to keep the WAL human-readable cost 36 bytes per record — 36 KB across the run — before counting the PUT prefixes. And the WAL is never truncated after a flush, so at the end of the workload the engine is storing a complete second copy of everything it already wrote to SSTables. That's not an inherent LSM cost. That's a feature I didn't build.&lt;/p&gt;

&lt;p&gt;The last row of that table needs a warning label. &lt;code&gt;lsm_after_puts&lt;/code&gt; and &lt;code&gt;lsm_after_compaction&lt;/code&gt; show identical totals, which looks like compaction did nothing. It's the opposite: the L0 threshold is 4, so compaction had already been running throughout the inserts. By the time the workload finished, there were zero L0 files and five L1 files — there was nothing left to merge.&lt;/p&gt;

&lt;p&gt;One more caveat this table needs: it measures final footprint, not write amplification. Every byte that compaction wrote and then deleted is invisible here. Real write amplification counts cumulative bytes written — WAL appends, flushes, every rewrite — and I have no counters for that. Calling this "write amplification" would have been the most flattering possible misreading of my own data. It was labelled exactly that in an earlier version of the repo, which is how I know.&lt;/p&gt;

&lt;p&gt;It's also the cleanest statement of a pattern that showed up everywhere in this project: every optimization moves the cost somewhere else. The index made lookups fast and made the file 82% bigger. The Bloom filter added 12 KB and 2.7% to every cold open. The WAL bought crash recovery and 1.4× the disk. There was no step where something got better and nothing got worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What KVLab isn't
&lt;/h2&gt;

&lt;p&gt;It's an educational storage engine, not a database. Specifically missing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WAL truncation or rotation after flush — the log grows without bound&lt;/li&gt;
&lt;li&gt;Real write-amplification counters (bytes written, not bytes remaining)&lt;/li&gt;
&lt;li&gt;Background compaction — it runs inline, on the writer's thread&lt;/li&gt;
&lt;li&gt;Any concurrency story worth the name&lt;/li&gt;
&lt;li&gt;A block cache, or any cache&lt;/li&gt;
&lt;li&gt;A read path that loads Bloom before index&lt;/li&gt;
&lt;li&gt;Crash testing beyond reopen-and-replay; no torn-write or partial-fsync injection&lt;/li&gt;
&lt;li&gt;Production durability semantics — fsync happens every 10 records, so a crash can lose the last nine&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any one of those would disqualify it from real use. I'm listing them because the numbers above are only worth something if you know exactly which conditions produced them.&lt;/p&gt;

&lt;h2&gt;
  
  
  So What's Next?
&lt;/h2&gt;

&lt;p&gt;A few things are still bothering me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. If the index were sparse, how much of that 42% goes away?&lt;/strong&gt; One index entry per block instead of per key. I can predict the file gets smaller; I can't predict what it does to hit latency, because now every lookup scans within a block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. What is the actual write amplification when I count every byte written to disk?&lt;/strong&gt; The 2.66× I measured doesn't tell me that yet. I need to separate WAL writes, memtable flushes, and compaction rewrites to see how much physical I/O each layer contributes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. How much of the cold-open cost is the open itself?&lt;/strong&gt; If the reader kept the Bloom filter resident and loaded the index lazily, would misses stop touching the index at all? That's the experiment that would tell me whether Bloom filters are worth what everyone says they're worth — in my engine, I still don't know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Before building KVLab, I could explain what a WAL, Bloom filter, and LSM tree were. What I didn't really understand was what they cost.&lt;/p&gt;

&lt;p&gt;Building the thing changed that.&lt;/p&gt;

&lt;p&gt;Adding an index made lookups cheaper, but made the SSTable much bigger and more expensive to open. The Bloom filter could save a lot of unnecessary work, but only if I actually let the reader use it before loading everything else. Compaction cleaned up old files, but also meant rewriting data that had already been written.&lt;/p&gt;

&lt;p&gt;That ended up being the useful part of the project for me. Not implementing the individual components, but measuring what each one actually changed and then dealing with the result when it didn't match what I expected.&lt;/p&gt;

&lt;p&gt;Three of my four experiments contradicted something I believed when I started.&lt;/p&gt;

&lt;p&gt;That ratio feels about right.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Code, benchmark harness, and raw CSV results: &lt;a href="https://github.com/Ajoy-1704001/kvlab/" rel="noopener noreferrer"&gt;KVLab on Github&lt;/a&gt;. Everything above reproduces with &lt;code&gt;mvn test &amp;amp;&amp;amp; mvn -q -Pbenchmark test-compile exec:java&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>lsm</category>
      <category>systemdesign</category>
      <category>java</category>
    </item>
  </channel>
</rss>
