从 Go 移植 BadgerDB 到仓颉(四)
四个方法学缺陷、Go 侧三个陷阱,与一套可核验的流程
Cangjie · 基准测试 · 方法论
这是系列最后一篇。第三篇讲了三个被推翻的结论 ,
本篇追问:它们为什么会错,以及怎样才能不再错。
一、四个方法学缺陷
上面三个错误结论不是偶然。复盘下来,它们背后有四个系统性缺陷——不是"精度不够",而是会直接给出错误结论。
缺陷 1:数据目录落在 tmpfs 上,I/O 成绩虚高一个数量级
所有 src/examples/* 基准都把数据目录硬编码成了 /tmp/...:
$ grep -rn '"/tmp' src/examples/ | wc -l
9
而 /tmp 通常是 tmpfs——读写全在内存里,没有磁盘语义:
$ findmnt -no FSTYPE /tmp
tmpfs
实测后果:sstable_write_perf 在 tmpfs 上算出 sync 吞吐 4021 MB/s——这是内存速度,不是磁盘速度。报告里那些毫秒级的 SSTable 写入数字,在 tmpfs 上根本没有意义。
修法:不改被测源码(保持二进制与发布 revision 一致),而是用私有 mount namespace 重定向:
unshare -rm bash -c \
"mount --bind <真实盘目录> /tmp \
&& exec <基准命令>"
⚠️ 这里有个连带坑:
cjvs env给出的CANGJIE_HOME位于/tmp/cjvs_multishells/...
(只是指向~/.config/cjvs/store/<ver>的软链)。一旦把/tmpbind 掉,软链被遮住 →
进程报libcangjie-runtime.so找不到(exit 127)。
所以 bind 场景下必须同时把LD_LIBRARY_PATH指到 store 的真实路径。
缺陷 2:负载太小,单次测量没有统计意义
skiplist_perf 全部 4 项总耗时仅约 64 毫秒(5000 ops / 16 线程)。单次运行的数字在 15 轮复测里的波动:
| 指标 | 15 轮实测范围 | 相对离散度 |
|---|---|---|
| 并发写 | 341 – 468 ns/op | ±32% |
| 并发读 | 288 – 380 ns/op | ±24% |
也就是说,单跑一次得到的"330 ns/op"和"380 ns/op"没有区别,都是噪声。历史报告里那些精确到个位数的 ns/op,精度是虚假的。
修法:每项连跑 N 轮(默认 5),报中位数 + 离散度;离散度 > 15% 的项自动标 ⚠️波动大——这类项不足以支撑结论,只宜作定性描述。
缺陷 3:环境指纹缺失
没有 CPU、内核、编译器版本、commit 的记录,数字既不能复核也不能横向比较。现在报告头固定包含:
测试日期 / 被测版本(commit)
CPU 型号与核数 / 内存 / 内核版本
仓颉编译器版本与优化级别 / 堆大小
数据目录文件系统 / 轮次
缺陷 4:跨语言对照的规模不对齐
就是第三篇 §2.1 讲的那个问题。修法很直接:对照双方必须显式对齐规模、线程数与内存策略,并把不对齐的偏差记录下来(而不是假装没有)。
二、Go 侧还有三个陷阱
重测 Go 对照时,又发现 Go 自己的基准也有坑。这一节可能比结论本身更有用。
陷阱 1:frac_10 读的是一张永久空表
Go 的 skl/skl_test.go 里,NewSkiplist(...) 位于 b.Run 闭包内部——每个 frac_* 子基准各建一张新空表。
而 frac_10 的 readFrac = 1.0,只 Get、从不 Put → 表永远为空 → 命中率 0。同一次运行实测:
| 子基准 | 数据状态 | ns/op |
|---|---|---|
frac_9(90% 读 + 10% 写) |
有真实数据 | 55.6 |
frac_10(100% 读) |
表为空 | 10.5 – 17.7 |
所以原报告里"Go 100% 读 3.7 ns/op"不是一个读基准,而是"在一张空表上走一趟 head 比较"。它不能与仓颉"预写满 5000 条后再并发读"对照。
附带一个坑:单独跑
frac_10且不加-benchtime=<N>x会 OOM 崩溃——
arena 按(b.N+1) * MaxNodeSize分配,纯读时b.N被外推到约 13.5 亿 → 约 130GB arena。
陷阱 2:key 长度不等价
Go 的 randomKey 先造 8 字节,再由 KeyWithTs 追加 8 字节时间戳 → 实际参与比较的是 16 字节。而仓颉 skiplist_perf 用的是纯 8 字节。key 长度翻倍会影响比较分支数与 cache 占用。这个偏差无法消除,只能显式记录。
陷阱 3:Go 侧语义对齐的基准并不存在
| 项目 | Go v4.9.1 | 能否对照 |
|---|---|---|
| 纯并发写 | BenchmarkWrite |
✅ 可用 |
| 并发读 | 只有 frac_10,且是空表 |
❌ 不可用 |
| 并发事务写 | 完全没有 | ❌ 不给数字 |
关于第三行:Go 根包 22 个 *_test.go 里只有 BenchmarkDbGrowth(单线程);badger bank test 是正确性不变式测试,其唯一的统计输出被注释掉了(badger/cmd/bank.go:427-431);badger benchmark write 走 OpenManaged 单线程顺序 Set,语义不同。
所以跨语言对照只在"纯并发写"这一项上是干净的,其余要么明确标注不可比,要么不给数字。
三、现在的成绩(可复现)
把不靠谱的数字清掉之后,这里补上站得住的那部分。
方法与前提:
- 被测版本 v1.6.21(中心仓已发布);
- 跳表这类"小负载"基准连跑 30 轮取中位数,其余项 5 轮;
- 数据目录显式落在真实盘(btrfs,非 tmpfs);
- 同时给出离散度,>15% 的项标注"波动大"。
并发跳表(5000 ops / 16 线程,30 轮)
| 指标 | 中位数 | 离散度 |
|---|---|---|
| 并发写 | 387 ns/op(约 258 万 op/s) | ⚠️ ±40% |
| 并发读 | 333 ns/op(约 300 万 op/s) | ⚠️ ±39% |
| 顺序写 | 555 ns/op | ⚠️ ±39% |
| 顺序读 | 1,227 ns/op | ⚠️ ±27% |
⚠️ 负载只有约 64 毫秒,点值本身不稳定。正确的说法是"亚微秒级",
而不是"330 ns"。
并发事务写(50 并发事务,5 轮)
| 规模 | 吞吐量 | 离散度 |
|---|---|---|
| 50 × 100 = 5,000 keys | 634,365 keys/s | ±13% |
| 50 × 1,000 = 50,000 keys | 730,396 keys/s | ±5% |
| 50 × 2,000 = 100,000 keys | 691,171 keys/s | ±5% |
| 50 × 3,000 = 150,000 keys | 567,458 keys/s | ±2% |
长事务(500 × 3000 = 150 万 key):382,194 keys/s,事务成功率 100%。
批量写对每 key 成本的摊薄(同一写路径,40 万 key / 8 线程)
| 事务粒度 | 每 key 成本 | 相对单键 |
|---|---|---|
| 单键事务 | 6,778–6,857 ns | 1x |
| 8 key/事务 | 2,216–2,494 ns | 快 2.7–3.1x |
| 64 key/事务 | 1,715–1,969 ns | 快 3.5–4.0x |
这组数据背后有个反直觉的结论:写路径的同步成本没法靠"框架内部批量提交"摊薄(唤醒次数 = 请求数,每个 commit 必须被单独唤醒),真正的杠杆在用户侧的事务粒度。
DB 与内存路径微基准
| 基准 | 指标 | 中位数 | 离散度 |
|---|---|---|---|
examples.perf |
DB 写(20 事务 × 200 key) | 1,431 ns/op | ±11% |
examples.perf |
DB 读(单事务内 4000 次读) | 2,681 ns/op | ±2.5% |
cjpm bench |
benchDBGet(单次 get) | 5.54 µs | ⚠️ ±69% |
cjpm bench |
benchPut(跳表单次 put) | 1.04 µs | ⚠️ ±46% |
raw_write_perf |
MemTable 直写 50 万 key | 554,084 keys/s | ±4.4% |
注意
examples.perf与cjpm bench的口径不同(前者批内摊薄、后者单次调用),
数值不可互相比较——引用时必须带上是哪个基准。
另外benchDBGet离散度高达 ±69%,历史报告给它写的精确值
(4.834 / 3.447 µs)其实都落在本次实测范围(3.78–7.57 µs)内,
说明那不是"版本差异",就是噪声。
异步 flusher(同一二进制开关对照)
| 指标 | 中位数 | 离散度 |
|---|---|---|
| 运行期写吞吐 | 97,370 次/秒 | ±4.8% |
close 耗时 |
54 毫秒 | ±9.3% |
四、一套能核验的流程长什么样
复盘下来,现在项目里固定做这几件事:
1. 只产出两类数据
- ① 当前 revision 的绝对值;
- ② 同一二进制内的开关对照(如 sync vs io_uring)。
不做跨版本对比。 原因见本篇第五节。
2. 必须报离散度,不能只报最佳值
离散度 > 15% 的项自动标记,这类数字只用于判断方向,不用于引用。
3. 环境指纹进报告头
CPU / 内存 / 内核 / 编译器版本 / commit / 数据目录文件系统 / 轮次,一个不少。
4. 一条命令跑完全部
python3 scripts/bench/run-bench.py \
--runs 5
这样"重跑一遍"的成本从半天降到几分钟——这才是数字能被核对的前提。
五、一个附带发现:那份版本演进表是错的
原报告的版本演进表长这样:
| 版本 | Skiplist Put | Skiplist Get | … |
|---|---|---|---|
| v1.3.1(优化前) | 1958 ns | 3990 ns | |
| v1.6.1(CAS + findGe) | 1324 ns | 2241 ns | |
| v1.6.1+(优化后) | 1115 ns | 1120 ns | |
| v1.6.12 | 1067 ns | 2130 ns |
看起来是条清晰的优化曲线。但核对 git 历史后发现两个问题:
v1.3.1这个 tag 在仓库里不存在(最早的是v1.2.0);- 表里那些优化提交集中在 2026-03-11 ~ 03-14,而最早的 tag 是 2026-03-21——也就是说,优化发生在第一个 tag 之前。
所以表头那些"vX.Y.Z 优化前 / 优化后"的对照关系,在版本历史上没有依据。它大概是当时按语义手工标注的,不是真实版本对照。
这也是"不做跨版本对比"这条原则的由来:版本对照要成立,前提是每个版本都能真的检出、构建、并在同一环境下跑一遍。做不到,就不该把它写成表格。
结语
复盘下来,这几个错误的共同点是:错的不是测量精度,而是方法。而方法层面的错误有个特点——它们不会表现为数值偏差,会表现为方向性的错误结论。
往上一层:你给出的每一个数字,别人是要拿它做判断的。
“约 330 纳秒"和"亚微秒级、离散度 ±40%“说出口的分量完全不同——前者像是可以依赖的事实,后者才是我真正测到的东西。
这次复核删掉了一批好看的数字,换来一套能重跑、能核验、并且明确说自己不可靠在哪的流程。这比任何一个 “+30%” 都值钱。
相关链接
- 项目地址:atomgit.com/ystyle/badger-cj
- 中心仓包:pkg.cangjie-lang.cn/packages/badgercj
- 参考实现:Go BadgerDB v4
- 本系列:
- 第一篇:没有裸指针怎么做无锁跳表
- 第二篇:十次优化与一次误判
- 第三篇:重跑基准,三个结论翻了
- 第四篇:怎样测才算数