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 一个个挖出来,
见时间分层记忆 系列的排查部分。