仓颉可嵌入数据库总览

Key 设计就是数据模型

仓颉生态里现在有五个可嵌入数据库:KV、文档、缓存、时序、图。
它们共用同一块 LSM 地基——所以真正不同的地方,只有 key 怎么排。

这篇把五个放在一起看,讲它们各自怎么用 key 表达自己的数据模型,以及可嵌入这件事的代价。

一、五个库,一张表

库 代码 / 测试 数据模型
badger-cj 17271 / 574 LSM KV
storm-cj 4263 / 150 JSON 文档 + ORM
sunku-cj 3008 / 142 Redis 语义 KV
tsdb-cj 4108 / 251 时序
holt-cj 3273 / 103 图

合计约 3.2 万行、1200 个测试,全部在中心仓可装(包名分别是
badgercj / bstorm / sunku / tsdb / holt,其中 holt 在 ystyle 组织下)。

除了 badger-cj 本身,另外四个都依赖它:

storm  sunku  tsdb  holt
  │      │     │     │
  └──────┴─────┴─────┘
            │
       badger-cj(LSM KV)
            │
      mmap-cj(跨平台 mmap)

这不是偶然。下面说为什么。

二、为什么共用一块地基

写数据库最容易犯的错,是从存储引擎开始写。

因为存储引擎的难点和数据库的难点不在同一个地方。存储引擎要解决的是顺序写、compaction、崩溃恢复、空间回收——这些东西写了两年可能还不够稳。而数据库要解决的是数据模型、索引、查询——这才是用户能感知到的部分。

badger-cj 已经把那 1.7 万行的硬活干完了(它是 Go BadgerDB v4 的移植):LSM 树、SSTable 格式、WAL/VLog、事务、压缩。所以另外四个库可以直接从「key 和 value 怎么摆」开始想。

这是分层的实际好处:上层库的代码量都在 3000~4000 行,
因为最贵的那部分被复用了。对比一下 badger-cj 自己 17271 行。

三、差别全在 key

这是本文想说的一件事:在 KV 之上做数据模型,本质上就是设计 key 的排列方式。

同一个 get/put/scan 接口,key 怎么排,就决定了你能高效回答什么问题。

badger-cj:让用户自己定

最底层的 badger-cj 不管 key 长什么样,它只保证字节序:

txn.set(strb("user:1001"), value)
// 前缀扫描:user:1001 开头的全部
let opt = IteratorOptions(
    prefix: strb("user:1001"))
let iter = txn.newIterator(opt: opt)

IteratorOptions(prefix:) 这个能力是后面所有库的基础——它让「按前缀扫一段连续区间」变成 O(区间长度),而不是全表过一遍。

所以每个上层库的第一件事,都是设计一套能靠前缀切分区间的 key 编码。

storm-cj:JSON 文档数据库,带一套 ORM 风格的宏

storm 的定位是「对象直接存进去」。写一个类,标上 @Model 和 @Id,字段上标 @Index,save 就是存文档、query 就是查文档——不用建表,不用写映射代码。

它存的是 JSON 原文,没有固定 Schema;查询走底下的 gjson,按路径解析、只读目标字段,不把整条记录反序列化。

文档数据库要回答两类问题:按主键取一行、按某个字段查一批行。

主键好办——表名:主键 就够了。麻烦的是第二类:phone = 138xxx 这种查询,如果没有索引就得扫全表。

所以 storm 在写入时额外写一份索引 key:

主数据    <表>:<主键>       → 整行 JSON
索引      <表>:idx:<字段>:
          <值>:<主键>       → 空
唯一索引  <表>:uniq:
          <字段>:<值>       → 主键

查 phone = 138xxx 时,扫 user:idx:phone:138xxx: 前缀拿到主键,再逐个回表。

代价是写入放大——一行数据变成三四个 key。这就是为什么索引之后写入从 21k/s 掉到 11.5k/s(这笔记在索引代价那篇 里)。

而它的一个反直觉结论是:范围查询不该走索引。LSM 上,散乱主键回表的随机读比顺序扫还慢。所以 range(30-60) 最终走的是覆盖索引计数 + 流式扫描。

sunku-cj:key 就是 Redis 的 key

缓存库的 API 是 Redis 命令,所以 key 的语义被 Redis 定死了——SET name Alice 里的 name 就是 key。

但 Redis 有五大数据类型,每种的数据结构不同。sunku 的做法是用类型前缀区分:

str:<key>     → 字符串值
hash:<key>    → 整个 hash(字段表序列化)
list:<key>    → 列表
zset:<key>    → 有序集合

同一份 KV 接口撑起五种类型,靠的是序列化整个容器,而不是像 Redis 那样每种类型一套底层结构。

这个取舍的后果很明确:小容器很好,大容器会痛。一个 10 万成员的 hash,每次 hset 都要读出来改完写回去。Redis 用字典结构,改一个字段是 O(1)。

sunku 的定位因此是进程内的小缓存,不是 Redis 的替代品。

tsdb-cj:定长补零,让字节序等于时间序

时序库是五个里面最需要琢磨 key 的。

因为它的核心查询是「某个 series 在某段时间内」——这要求 key 按时间天然有序。而 LSM 只认字节序。

于是时间戳必须定长补零:

点 key     s:<series>:<ts 20 位>
           :<seq 20 位>
倒排索引   __idx:<series>:<tag>
           :<val>:<ts>:<seq>

补零到 20 位之后,1699999999999 这种数字的字典序等于数值序,前缀扫描就自动按时间走了。

这里还有个细节:时间不是主键。点的身份是 (series, ts, seq) 三元组——同一毫秒写多个点,靠 seq 区分。所以 key 里 ts 和 seq 都要补零。

这是 LSM 上做时序的通用手法。代价是 key 变长(每个点多了二十几个字节),
收益是分页 seek 可以直接跳到位置,不用从头过。

后来一次事故也和这套编码有关:v0.2.0 的 seq 是变长的,导致 key 字节序不严格等于逻辑序,同毫秒多点的顺序会错。v0.3.0 改成定长,并且不兼容旧库——存量数据得走一次 migrateToV2。

holt-cj:邻接边带谓词段

图库的关键是「从一个顶点出发,沿某类边跳过去」要快。

最简单的做法是把邻接表序列化成一整个值:v:1001 → { outgoing: [...] }。但这样加一条边要读出整个邻接表、改完写回,度数一高就废了。

holt 的做法是每条边一个 key:

顶点       v:<vid>              → 顶点
出边       eout:<vid>:<label>
           :<eid>               → 边
入边       ein:<vid>:<label>
           :<eid>               → 边
标签索引   vl:<label>:<vid>     → 空

妙处在 eout:<vid>:<label>: 这段——把谓词(边的类型)编进 key 的第二段。

于是「沿 knows 边找人」变成了扫 eout:1001:knows: 前缀,自动只拿到这一类边,不需要读出来再过滤。这就是所谓「带谓词段的邻接索引」。

这个布局是 1.1.0 才有的。之前是 eout:<vid>:<eid>,
查某一类边得先扫全部再筛——1.1.0 重写那篇 里提过这个改动。

放在一起看

五种模型,key 设计各解决一个问题:

库 key 里编进了什么 换来什么
badger 什么都不管 最通用
storm 表 + 字段 + 值 二级索引查询
sunku 类型前缀 五种 Redis 数据类型共用一套存储
tsdb 时间戳(定长) 时间序扫描、分页 seek
holt 谓词 按边类型直接取邻接

共同点是:都把「最主要的查询条件」编进了 key 的位置,让前缀扫描替你做过滤。

这大概是在 KV 上做数据模型的唯一心法。

四、可嵌入意味着什么

五个库都是可嵌入的——它们不是服务,是库。调用方直接 import,数据在本地文件里,没有网络、没有进程间协议。

好处 代价
零网络延迟(调用是函数调用) 只有一个进程能写(文件锁)
部署简单(不用起服务、配端口) 跨进程共享要靠调用方自己想办法
事务和业务代码在同一个进程里 崩了就一起崩,隔离性取决于上层的谨慎
没有 SQL 层,直接调 API 复杂查询得自己组合

最后一条值得展开:没有 SQL 不是缺陷,是定位。

这五个库都不提供 SQL。它们提供的是 fluent API:

// storm:用宏声明索引,用 Query 组合条件
@Index var city: String = ""
let rows = storm.query<User>()
    .filter(city == "杭州")
    .orderBy(age, desc: true)
    .limit(20).run()

要 SQL 就得上解析器、查询计划、优化器——那是另一个数量级的工作,而且跟「可嵌入」的定位冲突(SQL 的价值在跨系统通用,而可嵌入库的价值在零摩擦集成)。

五、怎么选

按数据形态选,不按「哪个更强大」选:

你的数据长这样 用 为什么
就是 key → value,结构随业务变 badger-cj 最底层,零约束
有固定字段、要按字段查 storm-cj @Index 自动维护索引
想要 Redis 那套命令,但不想起服务 sunku-cj 命令兼容,进程内
按时间写、按时间窗读 tsdb-cj 时间窗物化 + 精确定位
实体之间有大量关系 holt-cj 邻接索引,按谓词跳

还有三个组合用法值得提:

sunku 可以当 storm 的缓存层。 读多的场景,把热点行放进 sunku 内存表,miss 了再查 storm。两者都基于同一块 badger,不打架。

sunku 和 Redis 能叠成两级缓存。 命令是同一套,所以代码可以同构:进程内先问 sunku(纳秒级),miss 了再查 Redis(跨进程共享,毫秒级)。位置别放反——sunku 天生是 L1,Redis 才是 L2,反过来就变成用一个网络往返去换一个函数调用。

tsdb 是建在最上层的——它的定位就是给「按时间分层物化」这种场景用的(最开始服务对象是忆时塔的分层记忆)。

六、各自的深水区

这五个库每个都有绕过的坑,已经单独写过:

七、小结

  • 五个可嵌入数据库,共用一块 LSM 地基:badger-cj 负责存储引擎,其余四个只负责数据模型。
  • 数据模型的差别在 key 的排列。分清主次查询条件,把它编进 key 的位置,让前缀扫描替你过滤。
  • 定长与分段是两件常用武器:tsdb 用定长补零让字节序等于时间序;holt 把谓词编进 key 的第二段。
  • 索引不是免费的。storm 写入掉一半就是代价,所以范围查询反而该走扫描。
  • 可嵌入的代价是单写者和没有 SQL。这不是缺陷,是定位——它换来了零网络、零部署、事务与业务同进程。

如果要一句话:在 KV 之上做数据库,你真正要设计的只有 key。

想先看一个跑起来的:cjpm 里加一行依赖就能用,

"badgercj" = "2.3.0"