B 树会在行所在的那一页上就地更新,于是「键随机到达」的写入模式就变成了「页随机写」的模式。LSM 树拒绝这笔交易:它只追加,把重写留到后面成批去做。这套设计里的其他部分——内存表、布隆过滤器、压实策略——全都从这一个拒绝里推出来。
支撑它的测量很容易复现。在同一张 20 万行的 sqlite 表上做同样 20 万次就地更新,配 2 MB 页缓存和一次持久提交:键按顺序到达时耗时 0.17 秒,打乱顺序到达时 2.60 秒——行完全一样、页数完全一样,差了 15.25 倍。多出来的时间花在散落的页重写,以及由此引发的缓存淘汰上。
先缓冲,再刷盘,再合并
写入先进预写日志保证持久性,再进内存表保证速度。内存表满了就作为一段不可变的有序段刷到磁盘;在 RocksDB 里这一段落在 level 0,而它下面的每一层只保存一段有序数据,且尺寸目标按指数增长。于是键层面随机的负载,落到磁盘上就变成了完全顺序的负载,无论应用在做什么,写路径都是一次大块的有序追加。
账单在读取路径上付。一次点查要考虑每一层,所以引擎给每段配了布隆过滤器,用来廉价地回答「不在这里」。在一次 40 万次写入、6 万个不同键的模拟里,2 万次点查平均探了 8.9 个段,但真正去读的只有 0.6 个:布隆过滤器在 177937 次探测里否掉了 166622 次,占 93.6%。查不存在的键时仍然要探 11.0 个段才能返回空——布隆过滤器让「未命中」变便宜,对「命中」则毫无帮助。
压实就是代价
写路径之所以便宜,是因为从来不做就地更新;代价是每个逻辑值都会被写不止一次。同一份模拟里,56000000 字节的逻辑数据变成了 149031040 字节的物理写入,来自 54 次刷盘和 4 次压实——写放大 2.66 倍,而且这还是在刻意放宽了压实预算的情况下。
level 0 是特殊的:它里面那些段直接来自内存表刷盘,键区间可以互相重叠,所以一次点查必须把 L0 的每一段都问一遍。更深的层不是这样——每一层整体是一段有序数据,内部按区间切成多个 SST 文件,查找先二分定位到可能包含该键的那一个文件,再在文件内二分。读放大因此主要被 L0 的段数牵着走,而 L0 的段数正是压实跟不跟得上的直接读数。
RocksDB 把这个旋钮明确写了出来。Level 风格压实把 Ln 里的一个文件与 Ln+1 中所有重叠文件合并,最小化了参与的文件数,因而优化的是空间放大。Universal 风格压实一次合并多个文件与多层,降低了写放大,却抬高了空间放大和读放大。没有任何一组配置能同时压低这三个;你只能选一个,看你这类负载付得起哪一个。
坏掉的样子
第一种失败是压实跟不上写入速率。压实吞吐是一道硬下限:按 64 MiB/s 的写入速率和 2.66 倍的写放大算,后台必须搬动 170 MiB/s 才能原地不动。如果实际只有 145 MiB/s,缺口就是 25.5 MiB/s,每小时堆积 91973 个内存表大小的段,11.4 小时就能把 1 TiB 的盘填满。拿 2000 个 level 0 段实测,一次点查平均要探 775 个段,而不是 8.9 个。先是读变慢,再是盘写满,两个症状指向同一个数字。
第二种失败是墓碑过重的负载。删除不是抹掉,而是写入一个墓碑,它必须活得比它遮住的那个值更久。插入 4 万个键再把它们全部删掉,为 5720000 字节的活跃数据写下了 12160000 字节,并留下 4 万个墓碑占着 5600000 字节的已删负载——光是这些已经不存在的行,就带来相当于活跃数据 0.98 倍的空间放大。查询这些已删键时,仍然要探段才能确认它不在。只有某次压实真的碰到那个墓碑,空间才会回来;所以删得和写得一样快的负载,两头的账都得付。
快速验证
先量出你想避开的那笔 B 树开销,再读压实的计数器:
sqlite3 kv.db "PRAGMA cache_size=-2000; PRAGMA synchronous=FULL; PRAGMA journal_mode=WAL;"
sqlite3 kv.db "SELECT count(*) FROM kv; PRAGMA page_count;"
ldb --db=./db stats | sed -n '/Compaction Stats/,/^$/p'
ldb --db=./db stats | grep -i stall
python3 -c "import rocksdb; d=rocksdb.DB('./db'); print(d.get_property(b'rocksdb.num-files-at-level0'))"
把同一批键插两遍,一遍有序一遍打乱,前两行就给出基线差距:行数相同、页数相同,墙上时间差很远。Compaction Stats 区块按层报出 W-Amp 与 R-Amp——写放大随数据量增长而上升,说明 fanout 设小了;读放大上升,说明压实已经落后。stall 计数只要大于零就是欠账信号,而 level 0 的文件数长期贴着压实触发阈值,说明后台只是勉强跟得上。
来源
- RocksDB wiki:RocksDB 概览,内存表、层结构与写/读/空间放大的取舍,已核验 2026-09-16。
- RocksDB wiki:分层压实,level 0 刷盘、按指数增长的层尺寸目标与逐层查找成本,已核验 2026-09-16。