一次压测揪出两个“测试全绿”的性能 bug:KV 引擎压测复盘

最终数据: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)232p50 2,565μs异步写跑出了 fsync 的价
readrandom4,936~190μs/次22MB 全在 page cache,不该这么慢
readmissing5,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 实例:

1
2
3
4
5
6
// kv_db.h: KVDB 持配置成员
bool wal_sync_on_write_ = true;

// kv_db.cc OpenNewWAL(): 轮换出的新 WAL 继承压测开关
wal_ = std::make_unique<WAL>(wal_path);
wal_->SetSyncOnWrite(wal_sync_on_write_);

教训:配置类开关要追问生命周期——它作用于一个对象,还是作用于系统?对象轮换之后,配置还在吗?

顺带说明: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:

1
2
// 修复前:缓存存的是 weak_ptr
std::unordered_map<uint64_t, std::weak_ptr<SSTable>> cache_;

当初这么写的"理由"是:怕 compaction 删掉旧文件后,缓存还持有 SSTable 占着内存,用 weak_ptr 让它"自动失效"。

但反过来想:全程序没有任何长期强引用持有 SSTable,weak_ptr 几乎永远是 expired 的。 于是每次 Get 的流程变成:查缓存 → expired → 重新 Open 所有命中的文件 → 用完即弃。布隆过滤器?filter 块每次重新解析,省下的磁盘 IO 抵不过 190μs 的 reopen。BlockCache?块缓存确实在,但 open 开销把它的收益全淹了。

一个命中率约等于 0 的缓存,不如没有。

修法:

1
2
// 修复后:shared_ptr 缓存,TableCache::Evict 负责在 compaction 删文件时移除
std::unordered_map<uint64_t, std::shared_ptr<SSTable>> cache_;

compaction 的归并迭代器持 shared_ptr 保活,文件删除时 Evict(file_number) 从缓存 erase——“防泄漏"靠显式 Evict,而不是寄希望于 weak_ptr 自动失效。后者名义上是缓存,实际上是每次重建

四、修复之后:数字回到该有的样子

同样的 200k 条、value=100B:

项目修复前修复后倍数
fillseq (sync=0)23270.9万 (p50 0.86μs / p99 2.75μs)+3058×
readrandom4,93659.1万 (1.69μs)+120×
readmissing5,140321.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::ReadAtfseek + fread 两步读文件,非原子。前台 Get 和后台 compaction 经 TableCache 共享同一个 SSTable 实例,FILE* 游标也是共享的——两个线程的游标交错,就可能读到错误偏移的块,解析出来 key 不匹配,返回 NotFound,断言炸。

为什么 Week 4 才暴露?Week 2/3 的手动 compaction 是测试里单线程调用,没有"前台读 + 后台归并"的并发;引入后台线程后才产生共享实例的并发读写。bug 在 Week 2 就潜伏了。

写了个最小复现:4 线程并发 Get 同一 SSTable 的不同块,40 万次读,170 次错误。改 pread 后:

1
2
3
4
5
6
// 修复前:fseek + fread,游标是共享状态
fseek(file_, offset, SEEK_SET);
fread(buf, 1, n, file_);

// 修复后:pread 原子定位,不碰 FILE* 游标;循环处理 EINTR / short read
ssize_t r = pread(fd, buf + done, n - done, offset + done);

40 万次读,0 错误。 全量 11/11 稳定全绿。

结论:共享 FILE + fseek/fread = 并发陷阱,一律 pread/pwrite。*

七、为什么 11/11 全绿测不出这些

这是整件事最值得复盘的部分。三个 bug 类型各异,但全绿的测试一个都没拦住:

  • sync 开关丢失:数据照样正确——甚至"更持久”。功能测试断言的是值对不对,不是快不快
  • weak_ptr 缓存:读出来的值一字不差,只是每次多花 190μs。正确性测试没有性能维度
  • pread 竞态:单测的时序窗口撞不上,要专门的复现程序 + 并发压力才能稳定触发

单测是 Debug 构建下的功能正确性验证,性能 bug 的暴露面在压测,竞态的暴露面在专门的复现程序——三种验证各管一段,谁也不能替谁

八、给读者的 checklist

如果你也在写存储引擎(或任何有缓存、有配置、有并发的系统),这份清单可以直接抄:

  1. 压测数字违反直觉时,先信直觉——232 ops/s 不是"机器慢",是 bug
  2. 配置开关追问生命周期:作用于对象还是系统?对象轮换后配置还在吗?
  3. 缓存的第一性指标是命中率——weak_ptr 当缓存 ≈ 命中率 0
  4. 对照实验的变量隔离到数据生成层,复用旧数据前先想"文件里固化了什么"
  5. 时序 bug 不接受"运气好绿了一轮",找到机制性解释才算完
  6. 共享 FILE 用 pread/pwrite*,fseek+fread 两步非原子

关于口径(面试也这么答):22MB 数据集全在 page cache,这组数字测的是内存命中下的引擎路径效率,单线程单客户端;生产含网络、更大数据集、多客户端,不能直接外推。持久化口径另有一组:sync=1、10k 条,673.8 ops/s、p99 3.06ms——两组数字在简历里都写,各说明各的事。

相关阅读: