从 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 在高优化级别下出现了行为异常。为了定位,我做了三重实验:

  1. 把依赖换成本地源码编译 → 现象依旧
  2. 关掉优化(-O0)→ 现象依旧
  3. 检查 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,反倒是在平静的对照实验里被钉死的。

下一篇换个方向:写完之后我回头重跑了全部基准,结果删掉了自己写过的一半数字。


相关链接