一块 LSM 地基,五个可嵌入数据库
内容提要
仓颉生态包含五个可嵌入数据库(KV、文档、缓存、时序、图),它们共用 badger-cj 这一 LSM 存储基础,其余四个只负责数据模型。核心观点是:在 KV 之上构建数据库,差异主要体现在 key 的排列方式——storm 将表、字段、值编入 key 以实现二级索引;sunku 用类型前缀兼容 Redis 五种类型;tsdb 用定长补零使字节序等同于时间序;holt 将谓词编入 key 实现邻接索引。可嵌入的代价是单写者和无 SQL,但换来零网络、零部署。
延伸解读
共用 LSM 地基的分层逻辑
五个库共用 badger-cj 并非偶然。文章指出,存储引擎的难点(顺序写、compaction、崩溃恢复)与数据库的难点(数据模型、索引、查询)不在同一处。badger-cj 已用 1.7 万行完成硬活,上层四个库因此只需 3000~4000 行,从 key 和 value 的摆放开始设计。这种分层让最贵的部分被复用,也解释了为何数据模型的差异最终都落在 key 的排列上。
索引的写入代价与范围查询反直觉
storm 为支持按字段查询,写入时额外生成索引 key,一行数据变成三四个 key,写入速度从 21k/s 降到 11.5k/s。文章还给出一个反直觉结论:在 LSM 上,范围查询不该走索引,因为散乱主键回表的随机读比顺序扫更慢。因此 range(30-60) 最终采用覆盖索引计数加流式扫描。这说明索引并非免费,需按查询模式权衡。
定长补零与谓词分段:两个 key 设计武器
tsdb 用定长补零让时间戳的字节序等于数值序,前缀扫描自动按时间走,分页 seek 可直接跳转;代价是 key 变长。holt 把边类型(谓词)编进 key 的第二段,使“沿 knows 边找人”变成前缀扫描,无需读出再过滤。文章还提到 tsdb v0.2.0 因 seq 变长导致同毫秒多点顺序错乱,v0.3.0 改为定长且不兼容旧库,需 migrateToV2。
可嵌入的边界与选型原则
可嵌入意味着零网络、零部署、事务与业务同进程,但代价是单写者(文件锁)、跨进程共享需调用方自己解决、崩溃隔离性依赖上层谨慎,且没有 SQL 层。文章强调没有 SQL 是定位而非缺陷,复杂查询需用 fluent API 组合。选型应按数据形态而非“哪个更强大”:KV 用 badger,固定字段查询用 storm,Redis 命令用 sunku,时间窗用 tsdb,实体关系用 holt。
Q&A
仓颉生态里的五个可嵌入数据库分别是什么?它们之间是什么关系?
五个可嵌入数据库是:badger-cj(LSM KV)、storm-cj(JSON 文档 + ORM)、sunku-cj(Redis 语义 KV)、tsdb-cj(时序)、holt-cj(图)。它们共用 badger-cj 这一 LSM 存储基础,其余四个只负责数据模型,且都依赖 badger-cj 和 mmap-cj。
为什么这些数据库要共用 badger-cj 作为存储引擎,而不是各自从头写?
因为存储引擎的难点(顺序写、compaction、崩溃恢复、空间回收)和数据库的难点(数据模型、索引、查询)不在同一个地方。badger-cj 已经完成了 1.7 万行的硬活(LSM 树、SSTable、WAL/VLog、事务、压缩),上层库可以直接从 key 和 value 怎么摆开始设计,代码量仅 3000~4000 行。
在 KV 存储之上构建不同数据模型,核心差别体现在哪里?
核心差别全在 key 的排列方式。同一个 get/put/scan 接口,key 怎么排决定了能高效回答什么问题。各库都把最主要的查询条件编进 key 的位置,让前缀扫描替你做过滤,这是共同心法。
storm-cj 如何实现按字段查询?代价是什么?
storm 在写入时额外写索引 key:主数据为 <表>:<主键> → 整行 JSON,索引为 <表>:idx:<字段>:<值>:<主键> → 空,唯一索引为 <表>:uniq:<字段>:<值> → 主键。查询时扫索引前缀拿到主键再回表。代价是写入放大(一行变三四个 key),写入性能从 21k/s 降到 11.5k/s;且范围查询不该走索引,因为散乱主键回表的随机读比顺序扫还慢。
tsdb-cj 为什么要把时间戳定长补零?有什么坑?
因为 LSM 只认字节序,而时序查询要求 key 按时间有序。将时间戳和序列号补零到 20 位后,字典序等于数值序,前缀扫描自动按时间走,分页 seek 可直接跳转。坑在于 v0.2.0 的 seq 是变长的,导致同毫秒多点的顺序会错;v0.3.0 改为定长且不兼容旧库,存量数据需走 migrateToV2。
holt-cj 的图存储如何做到按边类型快速跳转?
holt 为每条边单独一个 key,出边为 eout:<vid>:<label>:<eid> → 边,把谓词(边类型)编进 key 的第二段。于是“沿 knows 边找人”变成扫 eout:1001:knows: 前缀,自动只拿到这一类边,无需读出再过滤。该布局是 1.1.0 才有的,之前查某类边需先扫全部再筛。
可嵌入数据库有哪些代价?为什么没有 SQL 不算缺陷?
代价包括:只有一个进程能写(文件锁)、跨进程共享需调用方自己解决、崩溃时事务和业务一起崩、没有 SQL 层需直接调 API 组合复杂查询。没有 SQL 是定位而非缺陷:SQL 需要解析器、查询计划、优化器,与可嵌入的零摩擦集成定位冲突;这些库提供 fluent API,换来零网络延迟和零部署。
这五个数据库分别适合什么场景?如何组合使用?
按数据形态选:简单 key-value 用 badger-cj;固定字段且需按字段查用 storm-cj;想要 Redis 命令但不想起服务用 sunku-cj;按时间写读用 tsdb-cj;实体间大量关系用 holt-cj。组合用法:sunku 可作 storm 的缓存层;sunku 和 Redis 可叠成两级缓存(sunku 为 L1,Redis 为 L2,位置别反);tsdb 建在最上层用于按时间分层物化。