storm-cj 的索引代价:44 倍查询换来的写入腰斩

💡 原文中文,约3900字,阅读约需10分钟。
📝

内容提要

bstorm 是仓颉版嵌入式 JSON 文档数据库,通过 @Index 标注建立索引。索引使等值查询提速约 44 倍(2015ms 降至 46ms),但写入性能从 21k/s 降至 11.5k/s,成本主要来自事务提交次数,合并主数据与索引写入可挽回部分损失。范围查询在 LSM 上走索引反而更慢,因此采用流式扫描。建议只为高频等值查询字段建索引,因为成本随索引数量线性叠加,而收益并不叠加。

🔎

延伸解读

索引成本的关键在事务提交次数

文章指出,写入性能下降的主要开销并非来自 JSON 序列化或索引 key 构建,而是事务提交次数。优化前每条记录要提交 1 加索引数次事务,LSM 每次提交都有固定成本。将主数据与索引写入合并到同一事务后,写入从 9k/s 回到 11.5k/s。这说明在 LSM 上设计索引时,减少事务提交次数比优化单次 key 构建更有效。

范围查询走索引在 LSM 上可能更慢

在 B 树数据库中,范围查询走索引是常识,但 bstorm 的尝试表明 LSM 上未必如此。走索引会先扫索引区拿到散乱主键,再逐个回表,随机读代价很高;而直接顺序扫描主数据区能利用 LSM 的顺序读和 block 局部性。因此 range 查询的提速来自覆盖索引计数加流式扫描,而非走索引查范围。这是一个容易被反复尝试的坑。

索引收益不随数量线性叠加

文章强调,索引的写入成本按索引个数线性叠加,建一个索引写入掉到 9k/s,建五个不会还是 9k/s;但收益不是线性叠加的,只有真正被查询用到的索引才有收益。因此加索引前应先确认它会被等值查询命中,一个从不被 eq 命中的索引是纯成本。对于写多读少且很少用等值查询的场景,不建索引反而更快。

真实下游项目决定设计优先级

bstorm 有五个长期运行的下游项目,包括私有中心仓、多 Agent 协作服务、状态看板、小说时间线约束引擎和性能基准套件。这些项目会积累数据、升级依赖并在生产环境出问题,因此迭代重点不只是快,还包括删除后重开数据库数据必须真的没了、收到 SIGTERM 要落盘再退出、删索引的跨重开一致性。这些不在 benchmark 表格里,但对下游项目是生死线。

❓

Q&A

bstorm 建索引后查询和写入性能分别有什么变化?

等值查询(如 age=50)从 2015ms 降到 46ms,提速约 44 倍;范围查询(如 age 30-60)从 2191ms 降到 334ms,提速 6.6 倍;但写入速度从 21k/s 降到 11.5k/s,接近腰斩。

bstorm 的索引在底层是如何存储和查询的?

索引不是独立数据结构,而是同一 LSM 引擎中的另一批 key:普通索引格式为 __idx:<type>:<field>:<value>:<id>,唯一索引为 __uidx:<type>:<field>:<value> → <id>。查询时 seek 到前缀并扫描,拿到主键后回表取对象。

为什么 bstorm 写入速度会因索引而下降?主要开销在哪?

写入开销来自三处:JSON 序列化、索引 key 构建、事务提交。其中事务提交是最大头,优化前每条记录要提交 1+索引数 次事务,LSM 每次提交都有固定成本。

bstorm 做了哪些优化来减少索引带来的写入损失?效果如何?

关键优化是索引事务合并:将主数据写和索引写放入同一个事务,把多次提交合并为一次。写入速度从 9k/s 回到 11.5k/s,挽回了部分损失。

在 bstorm 中,范围查询适合走索引吗?为什么?

不适合。在 LSM 上走索引需要先扫索引区拿到散乱主键,再逐个回表,随机读代价高;而流式顺序扫描主数据区更快,还能利用局部性。因此范围查询采用流式扫描,提速来自覆盖索引计数和流式扫描,而非走索引。

什么情况下应该为字段建索引?什么情况下不该建?

读多写少且经常按某字段精确查询时应该建,因为 44 倍是数量级差异;写多读少且很少用等值查询时不该建,因为写入会慢一倍;需要唯一性约束时必须建唯一索引。原则是只为真正高频的等值查询字段建索引,因为成本随索引数量线性叠加,而收益不叠加。

🏷️

标签

➡️

继续阅读