storm-cj(bstorm)设计笔记 · 上篇
把写吞吐砍掉一半,换 44 倍查询——这笔账怎么算
这是仓颉版 storm 的第一篇。上一篇关于 storm 的文章还是 2019 年写的
Go 版 storm 的使用 ——bstorm 的灵感就来自它,
但实现完全不同:Go 版架在 BoltDB 上,bstorm 架在自研的 LSM 引擎 badger-cj 上。
本文讲索引带来的取舍,不讲 LSM 基础(那个在前四篇里)。
一、先说结论
bstorm 是嵌入式 JSON 文档数据库:直接存 JSON 对象,用 @Index / @Unique 标注就能建索引,写读都走同一套 API。
一次围绕索引的优化做完,benchmark 表格长这样(300k 条数据):
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
eq(age, 50) 查询 |
2015 ms | 46 ms | 44 倍快 |
range(30-60) 查询 |
2191 ms | 334 ms | 6.6 倍快 |
eq(city, "北京") 查询 |
2041 ms | 325 ms | 6.3 倍快 |
| 写入速度 | 21k/s | 11.5k/s | 接近腰斩 |
前三行是收益,最后一行是代价。
很多项目介绍只写前三行。 这篇想把它讲完整:索引到底买来了什么、又拿走了什么、什么时候这笔账划算。
二、索引是怎么工作的
2.1 一次查询的两条路
假设有个 User 类型,age 上有索引。你查 age == 50,引擎有两条路:
路径 A(全表扫描)
遍历所有 key → 逐条 db.get 取 value
→ 解析 JSON → 判断 age 是否 50
代价:O(全表) 次随机读 + 全量 JSON 解析
路径 B(走索引)
在索引区 seek 前缀 → 拿到一批主键
→ 按主键取回对象
代价:一次前缀扫描 + 按命中数取回
46ms vs 2015ms 的差距,就是这两条路的差距。300k 条数据里命中约几百条,路径 B 只需要处理这几百条。
2.2 索引长什么样
bstorm 的索引不是独立的数据结构,而是同一棵 LSM 里的另一批 key:
主数据: u:<id> → JSON
普通索引:
__idx:<type>:<field>
:<value>:<id> → (空)
唯一索引:
__uidx:<type>:<field>
:<value> → <id>
这样做的直接好处:索引和数据在同一个事务里,不需要两套一致性机制。代价是写入时要多写几个 key(见第三节)。
查询时,findByIndex 就是 seek 到 __idx:<type>:<field>:<value>: 前缀,然后顺着扫:
iter.seek(prefix)
while (iter.validForPrefix(prefix)) {
let key = iter.item().keyCopy()
let pid = extractIdFromIdxKey(key)
keys.add(buildPrimaryKeyFromId(
typeName, pid))
iter.next()
}
(这是简化版,实际代码还带着事务参数与错误处理。)
2.3 标注即索引
建索引不需要写 DDL,在模型字段上加标注就行:
@Index["age"] // 普通索引
@Index["city"] // 普通索引
@Index["email", unique: true] // 唯一索引
save() / delete() 时框架自动维护索引:先算新值、再算旧值(更新场景),然后增删对应的索引 key。
增量维护的关键在「旧值」:更新一条记录时,如果 age 从 50 改成 51,必须删掉旧的 __idx:...:50:<id>,否则索引会指向一个不再满足条件的对象——这类问题查起来极费劲,因为查询结果是「多了几条不该有的」。
三、代价从哪来
3.1 三条开销
写入从 21k/s 掉到 9k/s(后来优化到 11.5k/s),开销来自三处:
| 开销 | 说明 |
|---|---|
| JSON 序列化 | 要把被索引字段取出来,序列化成索引 key 的一部分 |
| 索引 key 构建 | 拼接 __idx:type:field:value:id 这样的字节串(还要考虑转义) |
| 事务提交 | 主数据 + N 个索引 = N+1 次写,如果不合并事务就是 N+1 次提交 |
第三条是最大头。优化前每条记录要提交 1 + 索引数 次事务,LSM 里每次提交都有固定成本。
3.2 一次关键优化:索引事务合并
把「主数据写」和「索引写」放进同一个事务:
优化前:
txn.set(主数据); txn.commit()
txn.set(索引1); txn.commit()
txn.set(索引2); txn.commit()
← 3 次提交
优化后:
txn.set(主数据); txn.set(索引1)
txn.set(索引2); txn.commit()
← 1 次提交
就这一处,写入从 9k/s 回到 11.5k/s——报告里明写「9k/s → 11.5k/s 的提升来自索引事务合并」。
顺带一个数字感:无索引时 21k/s 是纯 badger 写入速度。也就是说 21k → 11.5k 这个缺口,就是「有索引」这件事的总成本。
3.3 一个反直觉的失败尝试
benchmark 报告的待办里有这么一条,我觉得比成功的部分更值得说:
范围查询 (Gte/Lt) 接入索引查找→ 试过,LSM 回表比流式慢,无效
在 B 树数据库里,范围查询走索引是常识。但在 LSM 上,这条路可能更慢:
走索引:
先扫索引区拿到一批主键(散乱)
→ 逐个 db.get 回表
→ 在 LSM 上,散乱主键的随机读代价很高
流式扫:
直接顺序扫主数据区,边扫边判断
→ LSM 的顺序读非常快
→ 还能享受同一 block 的局部性
所以 range(30-60) 最终是 334ms(6.6 倍提速),但它的提速来自覆盖索引计数 + 流式扫描,不是「走索引查范围」。
这条记在报告的待办里,而不是删掉——因为它是一个可能被反复尝试的坑。
四、这笔账怎么算
4.1 什么时候值得
| 你的场景 | 判断 |
|---|---|
写多读少,且查询很少用 eq |
别建索引,21k/s 比 11.5k/s 快一倍 |
| 读多写少,经常按某字段精确查 | 建,44 倍是数量级差异 |
| 查询形态混杂 | 只给真正高频的 eq 字段建索引 |
| 需要唯一性约束 | 必须建唯一索引,这不是性能问题而是正确性 |
一句话:索引的收益是「查询次数 × 每次省下的时间」,成本是「写入次数 × 每次多花的时间」。把两边的次数估出来,账就清楚了。
4.2 一个容易忽略的点
索引的写入成本是按索引个数线性叠加的——建 1 个索引掉 9k/s,建 5 个不会还是 9k/s。
但收益不是线性叠加的:只有真正被查询用到的索引才有收益。所以原则是:
加索引前先确认它会被查。 一个从不被
eq命中的索引,是纯成本。
五、它被谁用着
除了它自己的基准套件,bstorm 还有 5 个真实下游项目:
| 项目 | 用途 |
|---|---|
cjreg |
仓颉私有中心仓与多仓工具:发布计划、包元数据、多仓解析 |
jisi-pavilion |
集思阁:多 Agent 群聊协作与记忆继承的 MCP 服务 |
atelier-starline |
人机共享的常驻状态看板 |
novel-timeline |
小说时间线约束引擎(冲突检测) |
storm-bench |
它自己的性能基准套件(path 依赖) |
这个列表对设计有实际影响:这些是长期跑的应用,不是一次性脚本。它们会积累数据、会升级依赖、会在生产里出问题。所以 bstorm 的迭代重点不只是「快」,还有:
- 持久性与重开:删除后重开数据库,数据必须真的没了(有专门回归用例钉住)
- 优雅退出:进程收到 SIGTERM 要先把数据落盘再退(从 badger-cj 逐层转发上来)
- 删索引的跨重开一致性:索引 key 和数据 key 必须在同一事务里删干净
这些不在 benchmark 表格里,但对上面那 6 个项目是生死线。
六、小结
- 索引让
eq查询快 44 倍,代价是写入从 21k/s 掉到 11.5k/s - 成本主要来自「事务提交次数」,合并事务能挽回一大半
- 范围查询走索引在 LSM 上可能更慢——这条是试过才知道的
- 加索引前先确认它会被查;索引成本按个数线性叠加,收益不叠加
本文只讲索引。 关于 LSM 本体、WAL 崩溃恢复、Value Log GC,
见从 Go 移植 BadgerDB 到仓颉四篇 ;
关于这些底座之上怎么把「参数被悄悄丢掉」这类 bug 一个个挖出来,
见时间分层记忆 系列的排查部分。