minidb:自研嵌入式数据库

从通用 KV 库到搜索索引与会话索引的底座——WAL + 快照、独占写锁、持久化索引代与大于内存的全文层。

作者 David更新于 第 26 篇(共 33 篇)

packages/minidb 仍是最出人意料的一个包:一个纯 Node.js 实现的嵌入式数据库,约 2.3 万行,零运行时依赖。README 的自我定位没变:

A pure-Node.js (zero native addons) embedded key-value database that mixes ideas from Redis (in-memory KV speed, data structures, AOF-style rewrite) and SQLite (durable single-file persistence, WAL, indexed queries).

变的是它在项目里的角色。包里新增的 AGENTS.md 第一句就是:

The embedded JSON document store (MiniDb) behind kap-server's search index

与 v0.29.1 相比,src/ 从 27 个文件涨到 61 个,新增的几乎都是为「当索引用」服务的:索引代、维护调度、worker 构建、异步读路径。

谁在用它

v0.29.1 时本文写的是「minidb 是为新一代引擎准备的存储层」。现在 v2 已是唯一引擎,实际用到它的是两处:

使用方位置用法
kap-server 全局搜索search/indexCore.ts单库 MiniDb,目录 search-index,建词索引与 ngram 子串索引
agent-core-v2 会话索引persistence/backends/minidb/miniDbQueryStore.tsApp 级 IQueryStore 的唯一实现,底下是 16 个分片的 ClusterDb

会话记录本身并不进 minidb:每个 agent 的 wire.jsonl 仍是追加日志(IAppendLogStore,见记录与回放)。minidb 管的是可查询的派生数据——跨会话的消息全文、按工作区与时间排序的会话列表。

搜索库的打开参数(search/indexCore.ts 第 348 行起):

    const opts = {
      dir: this.indexDir,
      valueCodec: 'json',
      valueMode: 'disk',
      fsyncPolicy: 'everysec',
      onLockFail: 'readonly',
      onLockAcquired: (info: { readonly token: string }) => {
        this.lockToken = info.token;
        this.options.onLockToken?.(info.token);
      },
    } as const;

值落盘、每秒 fsync、抢不到写锁就只读打开。若打开时判定库已损坏、需要重建,就先 wipeStoreDir 再重开——搜索索引是派生数据,丢了可以重算。[database] search 缺省开启(databaseSearchEnabled() 缺省返回 true),此时由 SearchWorkerHost 把索引放到 worker 线程里维护。

会话索引那边的两个常量:

const STORE_SUBDIR = 'query-store';
const SHARD_COUNT = 16;

不变的底子

v0.29.1 时介绍的核心设计都还在:

能力位置
内存索引 + 值指针(valueMode 可取 memory / disk / auto)store.ts、value-reader.ts
追加写 WAL + group commit + always / everysec / no 三档 fsyncwal.ts
CRC 分帧(魔数 MD),崩溃留下的半截尾部自动截断codec.ts、recovery.ts
非阻塞压实(仿 Redis BGREWRITEAOF 与 Bitcask merge)与快照compaction.ts、snapshot.ts
Windows 上 rename 覆盖仍被打开文件的兼容rename-replace.ts
跳表二级索引、复合索引、日期列索引skiplist.ts、compound-index.ts、dt-index.ts
TTL(惰性 + 主动)store.ts
RESP 服务端(redis-cli 可直连)server.ts
多进程分片 ClusterDbcluster/
值编码 buffer / string / jsonvalue-codec.ts

「零原生插件」这条约束仍写在 DESIGN_NOTES.md 第一节:Kimi Code 要做单文件二进制分发,纯 JS 实现打包即可用。CLI 的 SEA 构建甚至把 minidb 的全文构建 worker 作为内嵌资源一起带上(apps/kimi-code/src/native/minidb-worker.ts)。

为当索引而加的四件事

1. 独占写锁 + 只读追赶。 lockfile.ts 用 O_EXCL 建锁,归属按实例令牌(${pid}:${uuid})判断,持有者 PID 已死才算过期。抢锁失败的一方只读打开,用 catchUpFromWal() 从 WAL 增量追赶。

2. 大于内存的全文层。 text-index/ 在内存里只放词典与增量,倒排表落盘在 text-postings.ts。默认分词保留 ASCII 单词与 CJK 单字 / 双字;trigram.ts 另提供 2/3-gram 哈希分词,让 C++、公式、emoji 这类符号密集的文本也能按子串精确命中——索引只出候选,下游再逐条确认,保证零误报。

3. 持久化索引代。 派生状态(存储镜像、各类索引、全文词典与倒排)写成 generations/g-NNNNNN/:先写临时目录,再原子发布(rename + 切换 CURRENT)。打开时加载最新一代再重放 WAL 增量,不必重新解码全部值、重新分词;代缺失或损坏就自动退回完整恢复。

4. 维护调度与 worker 构建。 maintenance.ts 统一调度压实与建代:每库同时只跑一个重任务,带队列背压、磁盘余量预检、截止时间与取消。语料较大时,全文构建放到 worker 线程(src/worker/),主线程只做校验与切换。

外加一套异步读接口(getAsync / searchAsync / queryAsync)和打开过程的状态遥测(lifecycleStatus()),目标都是不卡住宿主的事件循环。

值得学的地方

  1. DESIGN_NOTES.md 记录了每个参考实现教了什么——Redis、SQLite 的 WAL 文档、NeDB、Bitcask、一个迷你 SQLite 克隆,各自学到的东西列成一张表。
  2. 约束驱动的技术选型。「零原生插件」直接排除了现成方案,也决定了纯 JS 实现是唯一出路。
  3. 每个新机制都留了回滚开关。OpenOptions 里的 indexGenerations: false、textBuildWorker: false、deferOpenTextBuilds: false 都能退回旧路径——给存储引擎加复杂优化时,这比什么都重要。

下一篇:transcript 四层模型。

本页目录