minidb:自研嵌入式数据库
从通用 KV 库到搜索索引与会话索引的底座——WAL + 快照、独占写锁、持久化索引代与大于内存的全文层。
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.ts | App 级 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 三档 fsync | wal.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 |
多进程分片 ClusterDb | cluster/ |
值编码 buffer / string / json | value-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()),目标都是不卡住宿主的事件循环。
值得学的地方
DESIGN_NOTES.md记录了每个参考实现教了什么——Redis、SQLite 的 WAL 文档、NeDB、Bitcask、一个迷你 SQLite 克隆,各自学到的东西列成一张表。- 约束驱动的技术选型。「零原生插件」直接排除了现成方案,也决定了纯 JS 实现是唯一出路。
- 每个新机制都留了回滚开关。
OpenOptions里的indexGenerations: false、textBuildWorker: false、deferOpenTextBuilds: false都能退回旧路径——给存储引擎加复杂优化时,这比什么都重要。
下一篇:transcript 四层模型。