Three ratios describe an engine: write amplification (bytes written to storage per byte written to the database), read amplification (disk reads per query) and space amplification (size on disk per byte of live data). A B-tree updates pages in place: a point read is one root-to-leaf path, but every change rewrites a page and also goes to the WAL. An LSM tree appends to a WAL and a memtable, flushes immutable sorted files and merges them in the background: writes are sequential and cheap up front, while a read may consult several sorted runs, and overwritten or deleted data occupies disk until compaction reaches it. No engine minimizes all three; choosing one without measuring them on your access pattern is choosing blind.
// Each knob moves cost between the three ratios; none of them is free.
var table = new BlockBasedTableConfig()
.setFilterPolicy(new BloomFilter(10)); // ~10 bits/key, ~1% false positives:
// fewer SST reads for point lookups (read amp),
// paid in memory; no help for plain range scans
var options = new Options()
.setCreateIfMissing(true)
.setCompactionStyle(CompactionStyle.LEVEL) // leveled: low space and read amp,
// higher write amp than UNIVERSAL (tiered)
.setLevelCompactionDynamicLevelBytes(true) // level targets follow the real data size,
// which bounds space amp
.setMaxBytesForLevelMultiplier(10) // fanout: larger = fewer levels (reads),
// more rewriting per level (writes)
.setTableFormatConfig(table);
// Switching to UNIVERSAL cuts write amp, but a full compaction keeps inputs and output
// on disk at once: provision free space for roughly twice the data.The bills arrive late. An LSM engine that accepts writes faster than compaction can merge them accumulates debt, and RocksDB repays it with write stalls: too many immutable memtables, too many L0 files or too many pending compaction bytes slow writes down and then stop them for the whole database. Delete-heavy patterns (a queue, a TTL table) leave tombstones that range scans must step over until compaction removes them; bound the iterator and track the live range instead of scanning from the start. A B-tree pays differently: PostgreSQL writes a full page image to the WAL on the first change of each page after a checkpoint, so checkpoint frequency multiplies WAL volume, and MVCC leaves dead tuples that only vacuum turns back into usable space.
Decision rule. Write down the workload before the engine: point reads vs range scans, update and delete rate, value size, how much free disk and memory the node has, and which latency percentile matters (26). Then measure all three ratios on that workload with the engine’s own statistics rather than benchmark headlines (37). “LSM for writes, B-tree for reads” is a first guess, not an answer: compaction style, fanout, filters and fill factor move an engine far along the curve. Streaming state lives on the same engines, so the same bills show up as checkpoint size and recovery time (82).