最终数据:200k 条 (value=100B) 单线程本机压测 → 写 70.9万 / 随机读 59.1万 / 布隆负查询 321.6万 ops/s 修复前:fillseq 232 ops/s、readrandom 4,936——两个 bug 把所有数字按在地上摩擦 口径:22MB 数据全在 page cache,测的是内存命中下的引擎路径效率,不能直接外推生产
KV 引擎做到 Week 4,布隆过滤器和 BlockCache 都上线了,11/11 测试全绿。我跑了一轮 200k 条压测,准备把数字写进简历。
结果 fillseq 跑出来 232 ops/s。
什么概念?平均每次写 4.3ms,p50 2,565μs——这是每次写都 fsync 的价位,而我跑的明明是 sync=0(异步刷盘)。更邪门的是读:readrandom、readmissing 三项全部稳定在 ~190μs/次,布隆过滤器开关与否毫无区别。
测试全绿,性能烂了两个数量级。这篇文章记录我是怎么把这两个 bug 挖出来的——功能正确和性能正确是两件事,前者保不了后者。
一、案发现场:第一轮数字有多离谱
200k 条、value=100B、单线程本机:
| 项目 | ops/s | 换算延迟 | 直觉判断 |
|---|---|---|---|
| fillseq (sync=0) | 232 | p50 2,565μs | 异步写跑出了 fsync 的价 |
| readrandom | 4,936 | ~190μs/次 | 22MB 全在 page cache,不该这么慢 |
| readmissing | 5,140 | ~190μs/次 | 布隆开了跟没开一样 |
三个反常点,各指向一个方向:
- sync=0 跑出了 sync=1 的速度 → 刷盘开关没生效?
- 所有读都是同一个价位 ~190μs → 每次读都在付一笔固定开销,什么开销这么整齐?
- 布隆开关无效 → filter 根本没被用到?
二、Bug A:WAL sync 开关,轮换后就丢了
先看写路径。sync=0 的设置入口是 SetWALSyncOnWrite(false),我检查调用点——只设置在第一个 WAL 对象上。
问题出在 memtable 轮换。LSM 的写路径是:memtable 写满 → 冻结 → OpenNewWAL() 建新的 WAL 文件 → 继续写。我的 memtable 大约在 3.5 万条时轮换,于是:
- 前 ~3.5 万条:第一个 WAL,sync=0 生效,微秒级
- 后 16.5 万条:新 WAL,
sync_on_write_是构造函数默认值 true,每条写都 fsync
200k 条里 82% 都在 fsync,p50 自然落在毫秒档。修法很直接——配置属于 DB 级,不属于某个 WAL 实例:
| |
教训:配置类开关要追问生命周期——它作用于一个对象,还是作用于系统?对象轮换之后,配置还在吗?
顺带说明:sync=1 组(10k 条,673.8 ops/s,p99 3.06ms)不受这个 bug 影响——sync=1 本来就每条 fsync,且 10k 没触发轮换。这组数字有效,不用重跑。
三、Bug B:weak_ptr 当缓存,等于没有缓存
读路径的 ~190μs 更有意思。这个数我越看越眼熟——正好是 SSTable::Open 的开销:fopen + 读 footer + 解析 filter 块 + 解析 index 块。
难道每次 Get 都在 reopen 文件?去看 TableCache:
| |
当初这么写的"理由"是:怕 compaction 删掉旧文件后,缓存还持有 SSTable 占着内存,用 weak_ptr 让它"自动失效"。
但反过来想:全程序没有任何长期强引用持有 SSTable,weak_ptr 几乎永远是 expired 的。 于是每次 Get 的流程变成:查缓存 → expired → 重新 Open 所有命中的文件 → 用完即弃。布隆过滤器?filter 块每次重新解析,省下的磁盘 IO 抵不过 190μs 的 reopen。BlockCache?块缓存确实在,但 open 开销把它的收益全淹了。
一个命中率约等于 0 的缓存,不如没有。
修法:
| |
compaction 的归并迭代器持 shared_ptr 保活,文件删除时 Evict(file_number) 从缓存 erase——“防泄漏"靠显式 Evict,而不是寄希望于 weak_ptr 自动失效。后者名义上是缓存,实际上是每次重建。
四、修复之后:数字回到该有的样子
同样的 200k 条、value=100B:
| 项目 | 修复前 | 修复后 | 倍数 |
|---|---|---|---|
| fillseq (sync=0) | 232 | 70.9万 (p50 0.86μs / p99 2.75μs) | +3058× |
| readrandom | 4,936 | 59.1万 (1.69μs) | +120× |
| readmissing | 5,140 | 321.6万 (0.31μs / p99 1.78μs) | +626× |
readmissing 比 readrandom 快 5 倍,布隆过滤器的价值终于显形了——负查询在 filter 层就被拦下,连 data block 都不用碰。
五、方法论翻车:对照实验没重灌数据
修完 bug 跑布隆对照:--bloom_bits=10 vs --bloom_bits=0,两组几乎一样快。
又挖一层,这次是实验设计本身错了:filter 块是 fillseq 写文件时固化进 SSTable 的,bloom_bits=0 的对照臂只是打开引擎时改了配置,用的还是上一轮带 filter 的数据文件——Open 按 footer 读到 filter 元信息,照用不误。对照组实际上一直开着布隆。
重新灌数据再跑:
| 臂 | readmissing |
|---|---|
| 无布隆 (重灌) | 80.1万 ops/s |
| 有布隆 10 bits (重灌) | 321.6万 ops/s |
4.0×,干净的结论。
教训:对照实验的变量必须隔离到数据生成层。改配置不换数据 = 没做对照。复用旧数据前先问一句:这份文件里固化了什么?
六、番外:一个藏了两周的 pread 竞态
压测排障期间还附带收了一个旧账。ctest 全量跑时 async_flush_test 偶发断言失败,单独跑永远过;清一次 /tmp 绿了一轮——但"绿了一轮"不等于修好了,时序类 bug 必须找到机制性解释。
机制:SSTable::ReadAt 用 fseek + fread 两步读文件,非原子。前台 Get 和后台 compaction 经 TableCache 共享同一个 SSTable 实例,FILE* 游标也是共享的——两个线程的游标交错,就可能读到错误偏移的块,解析出来 key 不匹配,返回 NotFound,断言炸。
为什么 Week 4 才暴露?Week 2/3 的手动 compaction 是测试里单线程调用,没有"前台读 + 后台归并"的并发;引入后台线程后才产生共享实例的并发读写。bug 在 Week 2 就潜伏了。
写了个最小复现:4 线程并发 Get 同一 SSTable 的不同块,40 万次读,170 次错误。改 pread 后:
| |
40 万次读,0 错误。 全量 11/11 稳定全绿。
结论:共享 FILE + fseek/fread = 并发陷阱,一律 pread/pwrite。*
七、为什么 11/11 全绿测不出这些
这是整件事最值得复盘的部分。三个 bug 类型各异,但全绿的测试一个都没拦住:
- sync 开关丢失:数据照样正确——甚至"更持久”。功能测试断言的是值对不对,不是快不快
- weak_ptr 缓存:读出来的值一字不差,只是每次多花 190μs。正确性测试没有性能维度
- pread 竞态:单测的时序窗口撞不上,要专门的复现程序 + 并发压力才能稳定触发
单测是 Debug 构建下的功能正确性验证,性能 bug 的暴露面在压测,竞态的暴露面在专门的复现程序——三种验证各管一段,谁也不能替谁。
八、给读者的 checklist
如果你也在写存储引擎(或任何有缓存、有配置、有并发的系统),这份清单可以直接抄:
- 压测数字违反直觉时,先信直觉——232 ops/s 不是"机器慢",是 bug
- 配置开关追问生命周期:作用于对象还是系统?对象轮换后配置还在吗?
- 缓存的第一性指标是命中率——weak_ptr 当缓存 ≈ 命中率 0
- 对照实验的变量隔离到数据生成层,复用旧数据前先想"文件里固化了什么"
- 时序 bug 不接受"运气好绿了一轮",找到机制性解释才算完
- 共享 FILE 用 pread/pwrite*,fseek+fread 两步非原子
关于口径(面试也这么答):22MB 数据集全在 page cache,这组数字测的是内存命中下的引擎路径效率,单线程单客户端;生产含网络、更大数据集、多客户端,不能直接外推。持久化口径另有一组:sync=1、10k 条,673.8 ops/s、p99 3.06ms——两组数字在简历里都写,各说明各的事。
相关阅读: