从 Go 移植 BadgerDB 到仓颉(二)
十次优化,和一次「我以为是编译器 bug」的乌龙
Cangjie · 性能优化 · badger-cj
这是系列第二篇。第一篇讲无锁跳表的实现 ,
本篇讲怎么把它调快,以及一个把我引向错误方向的排查。
一、十次优化:把「能用」榨成「够快」
功能跑通只是开始。真正的工程量在性能上——下面是历次优化中效果最显著的十项。
一个前提:下表里多数条目是"改造前 vs 改造后"的对照,而改造早已进入当前代码,
旧实现无法重跑。所以它们只保留定性描述——任何"+X%“都必须能在同一台机器上
重跑出来才算数。当前版本可复现的实测值见本系列第三篇。
| 优化项 | 效果(定性) | 说明 |
|---|---|---|
| CRC32C 全局查找表 | 从剖析热点降到可忽略 | 原本每次调用重建 256 项查找表 |
| LogEntry::encode 批量拷贝 | WAL 写入明显提速 | 逐字节写改为 copyTo |
| Condition 替代轮询 | 大事务等待从轮询周期量级降到即时唤醒 | 轮询等待改为条件通知 |
| 移除 updateDiscardStats | 写入热路径少一次统计开销 | 从写入热路径移除 |
| WriteRequest 对象池化 | 小事务减少分配 | 减少 GC 压力 |
| Arena 批量拷贝 | — | putBytes/getBytes 用 copyTo |
| Node.compareKey 免建对象 | — | 直接访问 Arena,避免临时对象 |
| Skiplist.add 内联 | Node 对象创建减半 | — |
| AtomicStorage 分段扩展 | 初始化不再一次性预分配 | 避免大数组预分配 |
| 让 flush 离开写锁 | 运行期写吞吐约翻倍 | 详见 §1.3 |
下面挑三个最有代表性的展开。
1.1 CRC32C:把查找表从每次重建改成进程内只建一次
优化前的写法很自然,代价却极高——每次校验都重建一次查找表:
// ❌ 每次调用都建表
let table = makeCRCTable()
CRC32C 的表是 256 项,每次重建意味着几百次运算只为算一个校验和。改成全局只建一次:
// ✅ 全局查找表,进程内只建一次
let globalTable = Array<UInt32>(
256, { i => computeCRCEntry(i) })
这一改,CRC 校验从剖析里的主要热点降到可忽略。(原始记录是"占 17% → 0.04%",属当时 workload 下的剖析占比,不易复现,故只保留定性表述。)
1.2 Condition:把轮询等待变成条件通知
优化前,等待写入完成靠轮询:
// ❌ 轮询等待
while (!ready) {
sleep(1ms)
}
这不是"慢一点"的问题——它是延迟量级的问题。轮询周期是 1 毫秒,大事务需要多轮检查,延迟会按"轮数 × 1 毫秒"累积。换成条件变量后,等待方被精确唤醒:
// ✅ 条件通知,零延迟
let cond = Condition()
cond.wait(lock) // 等待方挂起
cond.signalAll() // 完成后立刻通知
大事务等待从"轮询周期量级"降到"即时唤醒”。(原始记录写"约 100 毫秒 → 约 1 毫秒",同属改造前后对照,旧实现无法重跑,故不再引用具体值。)
1.3 让 flush 离开写锁
前几项都在优化「算得更快」,这一项优化的是「不要再等」。
原本 memtable 写满后,SSTable 的构建与落盘是在全局写锁内同步执行的——整批写入被每一轮 flush(约 1MB 表的构建 + 磁盘 I/O + MANIFEST 更新)整段阻塞。改成专职 flusher 协程并行处理后,运行期写吞吐约翻倍。
这一组数字里,“改造后"那一半是可复现的。 2026-10 在 v1.6.21 上同机复测(5 轮取中位数):
| 指标 | 实测中位数 | 离散度 |
|---|---|---|
| 运行期写吞吐 | 97,370 次/秒 | ±4.8% |
close 耗时 |
54 毫秒 | ±9.3% |
代价是 close 变慢(要等在途的表落盘完毕)。但这是正确的权衡:运行期吞吐是所有读写的常态成本,close 一辈子只发生一次。把成本从"每次操作"摊到"关机一次”,显然划算。
二、一个「我以为发现了编译器 bug」的乌龙
这一节没有优化技巧,只有一个教训。但它可能是全文最值得看的部分。
起因:CRC 校验在 -O2 下"不稳定"
某次排查中,crc-cj 的 CRC32C 在高优化级别下出现了行为异常。为了定位,我做了三重实验:
- 把依赖换成本地源码编译 → 现象依旧
- 关掉优化(
-O0)→ 现象依旧 - 检查
update函数 → 全是 XOR 和移位,没有溢出运算,也排除了溢出策略
优化级别和溢出都被排除了。最后压到最小复现:静态函数里,用闭包构造一个大数组,末元素的值等于期望值的按位取反。
private static func content(
L: Int64
): Array<Byte> {
Array<Byte>(L, {j =>
UInt8((j * 7 + 3) % 256)})
}
// 现象:L >= 约 30000 时,
// 末元素 got == want ^ 0xFF,其余全对
现象非常确定,-O2 和 -O0 都能复现。我当时判断这是编译器的代码生成缺陷,整理了单文件最小复现上报到官方 issue。
结论:是我自己写错了
官方回复:误报。
根因不在编译器,而在我那份复现程序里——它违反了仓颉的一条语义:
Array的切片是共享底层存储的视图,不是拷贝。
我的复现代码是这样的(简化后):
let direct = content(L)
// ① 切片 —— 其实是视图!
let copy = direct[0..direct.size]
// ② 显式取反,写回了 direct
let last = copy.size - 1
copy[last] = UInt8(copy[last] ^ 0xFF)
// ③ 未取反的期望值
let wantLast = UInt8(
((L - 1) * 7 + 3) % 256)
// ④ 取反值 ≠ 原值 → 恒真
if (copy[last] != wantLast) {
// ⑤ 打印出来当然差一个 ^0xFF
let d = direct[direct.size - 1]
println("got=${d}")
}
逐行看就明白了:第 ① 行我以为拿到了一个独立副本,实际是指向同一块内存的视图;第 ② 行"显式取反"直接改掉了原数组;第 ③④ 行拿取反后的值去比未取反的期望——这个断言在任何正确的编译器上都必然失败。
那个看起来极其可信的 got == want ^ 0xFF,是程序自己制造出来的。
沉淀成三条
1. 仓颉的 Array 切片是视图,不是拷贝。
let copy = direct[1..4]
// direct[1] 也变成了 -1
copy[0] = -1
写切片元素会直接落到原数组上。用切片做「写回探测」之前,必须明确这一点。需要真正的副本就显式复制。
2. 怀疑编译器之前,先怀疑自己代码里的别名与共享语义。
“编译布局敏感"“同函数同参数不同调用点一翻一不翻"这类观察,听起来特别像编译器缺陷——但它们同样可以被一个自身逻辑写错的测试完美解释。越是形态诡异的现象,越要先排除自己代码的别名问题。
3. 上报被驳回不是白费。
那次排查留下了一份完整的单文件最小复现、一份三重实验记录,以及这条"切片是视图"的确认。**代价是把怀疑对准了自己,收益是搞清了一条真实语义。**顺带说明:那次同期怀疑的另一个问题(并发 close 导致整批数据丢失)是真的 bug,已通过「rotate + 入队原子化」独立修复——真问题和假问题混在一起时,更要一个一个地钉死。
结语
这一篇讲了两件事:把性能从"能用"榨到"够快”(十次优化叠加,最大的一次让写吞吐翻倍),以及分辨真假问题。
那次"编译器 bug"让我学到的东西,比任何一个成功的优化都多:当现象诡异到不像自己的错时,往往更该回头看看自己。 而同期那个真正的并发 bug,反倒是在平静的对照实验里被钉死的。
下一篇换个方向:写完之后我回头重跑了全部基准,结果删掉了自己写过的一半数字。
相关链接
- 项目地址:atomgit.com/ystyle/badger-cj
- 中心仓包:pkg.cangjie-lang.cn/packages/badgercj
- 参考实现:Go BadgerDB v4
- 本系列:
- 第一篇:没有裸指针怎么做无锁跳表
- 第二篇:十次优化与一次误判
- 第三篇:重跑基准,三个结论翻了
- 第四篇:怎样测才算数