我们的桌面 AI 应用支持把本机数据导出成 tar.gz 备份(归档格式见备份归档格式:为什么选 tar.gz 不选 zip,四轨 JSON 与 manifest 版本探测)。恢复这份备份有两种哲学:
- 覆盖式:清空现有数据,把归档完整灌进去。实现简单,语义清晰——「回到备份那一刻」。
- 合并式:归档数据与现有数据做并集,已存在的跳过,不存在的新增。
我们选了合并式。覆盖式有三个无法接受的问题:
- 误操作不可撤销。用户在换机场景下想恢复,在多场景下也可能是「想找回某个删掉的知识库」。覆盖式会把当前库先清空——一旦选错归档文件,现有数据先没了。破坏性操作必须给用户留退路。
- 重复恢复产生双胞胎。同一个归档恢复两次(手抖点了两次、网络重试),覆盖式每次先清再灌没问题,但实现里如果清空与写入不是一个事务,中途断电就是半个库。合并式用幂等跳过天然免疫重复执行。
- 部分轨道归档会抹掉其余数据。归档是四轨可选的(见 备份归档格式:为什么选 tar.gz 不选 zip,四轨 JSON 与 manifest 版本探测),用户导出一个只有 settings 轨的归档,恢复时若按覆盖式语义,会话和知识库全没了——这显然不是用户的意图。
合并式的代价是实现复杂度:每类数据要回答「什么算已存在」。这篇讲三个机制:幂等跳过的键怎么选、外键安全的写入顺序、以及恢复后向量怎么重建。
幂等跳过:每类数据的「身份」是什么
合并式恢复的核心是定义每类数据的身份键。四条轨道用了三种不同的判重策略:
conversations:按主键 ID 跳过
会话和消息的 ID 是应用生成的 UUID,全库唯一。恢复逻辑是 INSERT OR IGNORE:
const r = insertConversation.run(/* id, ... */);
if (r.changes > 0) convImported++; else convSkipped++;
changes === 0 即「这个 ID 已存在」,计数为 skipped。同一归档恢复两次,第二次全部 skipped,结果集完全一致——幂等性由数据库唯一约束保证,而不是靠应用层先 SELECT 再 INSERT(先查再写有 TOCTOU 竞态,而且多一次往返)。
knowledge:两层判重
知识库的判重分两层:
- knowledge_bases:同样按主键 ID
INSERT OR IGNORE。 - documents:按
content_hash(文件内容的 sha256)判重。同一个文件在两个知识库里各存一份是合法的(不同 ID),但同一个文件重复恢复到同一个库应该跳过。
文档恢复时有一个关键细节——恢复进来的文档 status 一律设为 'pending',而不是归档里记录的 'indexed':
const docRow = insertDoc.run(doc.id, ..., 'pending', ...);
原因很简单:归档里不存向量(备份归档格式:为什么选 tar.gz 不选 zip,四轨 JSON 与 manifest 版本探测 讲过,向量是可再生产物)。分片原文写回来了,但 sqlite-vec 里的 embedding 没有——文档必须重新走一遍嵌入管线才能被 RAG 检索到。pending 状态就是触发重建的标记,索引器会像对待新上传文档一样处理它。如果照抄归档里的 indexed,恢复出来的知识库会处于一种「看起来有数据、检索永远零命中」的僵尸状态——这比直接失败恶劣得多。
settings:upsert 而不是 ignore
设置轨的语义不同:它不是「累积型」数据,而是「当前值」数据。INSERT ON CONFLICT DO UPDATE——归档里的值覆盖现有值:
INSERT INTO settings_kv(key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
模型配置、供应商列表,恢复语义就是「回到归档时的配置」。注意密钥字段在导出端就已脱敏(密文不出库),恢复回来的是脱敏占位符,用户需要重新填 key——这在恢复确认框里有明确提示。
单事务与 FK 安全:写坏了就全部不写
整个恢复写入包在一个 SQLite 事务里:任何一条失败,整体回滚,库里不会留下半个恢复结果。这个选择配合 better-sqlite3 的同步事务几乎零成本:
const tx = db.transaction(() => {
for (const entry of entries) { /* 按拓扑序写入 */ }
});
tx();
但单事务有一个隐藏约束:外键依赖必须在事务内按拓扑序写入。knowledge 轨的依赖链是:
knowledge_bases → documents → document_chunks
先写库、再写文档、最后写分片。如果顺序反了(比如先写 chunks),外键约束立刻让事务回滚。顺序硬编码在恢复函数里,配合既有的表结构,测试覆盖了「打乱归档内部顺序也能正确恢复」的用例——防御的是未来某个版本导出端改了序列化顺序。
另一类 FK 问题更微妙:归档里的数据引用了当前库不存在的东西。比如会话消息里 assistant_id 指向的助手,在当前库里已被删除。策略是:不安全的关联跳过该条并计入 warnings,而不是让整事务失败。恢复结果对象里有 warnings: string[],UI 上会如实展示「3 条消息因所属助手不存在被跳过」。整体成功与局部诚实,两者都要。
precheck:先报告将要发生什么
恢复 API 分两步,这是刻意的设计:
- precheck:上传归档,后端只做只读检查——manifest 校验、版本兼容、统计「将导入 N 个会话 / M 篇文档 / K 条设置 / P 个附件」、磁盘剩余空间。前端弹确认框展示这份报告。
- restore:用户确认后才执行真正的事务写入。
为什么必须这样?因为恢复是改变本地数据库的操作,而用户对归档内容的预期经常是错误的(拿错了文件、忘了里面有什么、不记得是哪天导的)。precheck 把「我以为我恢复的是什么」和「实际会恢复什么」之间的鸿沟显性化了。破坏性操作的原则:先让用户看见后果,再让后果发生。
一个真实的边界 bug
恢复模块 14 个单测之外,真机 round-trip 还抓到一个:description 字段在老版本会话里可能为 NULL,导出时序列化成 JSON null,恢复时 Zod schema 写的是 z.string()——校验失败,整轨恢复被拒。修复办法是把 schema 改成 z.string().nullable().optional() 并在写入时兜底空串。
这个 bug 的教训不是「加 nullable」这么简单,而是:备份/恢复链路的测试必须用真实历史数据做 round-trip。手工构造的夹具永远是你以为的数据形状,生产库里躺着三代 schema 变迁留下的各种 NULL 组合。后来备份测试夹具里固定包含「早期版本字段缺失」的样本行。
小结
合并式恢复的三个支柱:
- 幂等由数据库约束保证(
INSERT OR IGNORE/ON CONFLICT DO UPDATE),不在应用层做先查后写; - 原子性由单事务保证,外键按拓扑序写入,不安全的关联降级为 warning 而非整体失败;
- 可再生产物不恢复原文状态——文档恢复为
pending触发向量重建,杜绝「僵尸已索引」状态。
加上前置的 precheck 报告,整个恢复链路做到了:重复执行安全、中途失败安全、选错文件有最后一次反悔机会。
下一篇 B04 换个战场:对话分享模块的脱敏纯函数——四种密钥形态的正则、为什么脱敏只有一份实现、以及「默认外发」威胁模型。