页面上有一张卡片:标题、一句摘要、生成时间。点开是一份完整的文档。
在传统项目里,这没什么可说的 —— 内容就在数据库的某个字段里,卡片是它的一个视图,一次查询的事。
但这是个 agent 系统。文档是 agent 生成的一个 Markdown 文件,躺在磁盘上。数据库里只有一行元数据:标题、摘要、时间戳,和一个 file_path。
于是这张卡片,是两个存储拼出来的。
为什么非要这么拆
第一反应通常是:那把内容也存进数据库不就行了。
但你很快会发现拆不掉,因为 agent 的工作方式就是读写文件。
它 ls 一个目录看有什么,read 一个文件拿上下文,grep 找线索,write 落产物。这是它的原生接口 —— 你给它一个 ORM 让它写业务表,它会用得很别扭,而且你得为每一种产物预先定义 schema,那正是 agent 场景里最不确定的东西。
反过来,页面的原生接口是表:排序、分页、过滤、权限。你不会让前端去 grep 一个目录。
agent 的原生接口 = 文件系统
页面 的原生接口 = 数据库
两套天生就在。这不是没设计好,是分工的直接后果。
于是你有了两个写入方
这才是问题的根。
agent 改了文件,数据库不知道。用户在页面上改了元数据,文件不知道。
中间没有事务。 你没法把"写一个文件"和"更新一行记录"放进同一个原子操作里 —— 后面会讲为什么连这个念头都得放弃。
这个问题本身不新。Martin Kleppmann 2015 年那篇 Why dual writes are a bad idea 基本固定了 dual write 这个说法,《DDIA》第 11 章有完整讨论。
新的只有一件事:其中一个写入方变成了 LLM。
顺便堵一个常见回答
“那加个重试不就行了?”
不行。dual write 有两种失败模式,重试只能救一种:
- 部分失败:写完 A,写 B 的时候崩了 —— 这个重试有用
- 竞态:两个并发写入者以不同顺序到达两个存储,两边各自都"成功"了 —— 没有任何一方观察到错误,重试无从谈起
第二种是这类系统里最阴的部分,因为它不产生任何异常信号。日志里就是两次正常的写入。
三个漂移方向
前两个是对称的,很好理解:
| 方向 | 谁动的 | 你看到什么 |
|---|---|---|
| 文件变了,DB 没变 | agent | 卡片显示的还是旧摘要;或者指向一个已经不在的文件 |
| DB 变了,文件没变 | 用户在页面上操作 | 改了标题,文件里还是老的 |
第三个才是 agent 系统独有的,而且最狠。
漂移会被 agent 固化
关键前提:agent 读的是文件,不是数据库。
所以用户在页面上做的修改,对 agent 来说根本不存在。
最后那一步是致命的:错误的一侧赢了,并且会被当成事实传播下去。
传统系统里很少出现这种情况,因为没人会拿缓存当输入、算完再写回源。但 agent 天天这么干 —— 读文件、思考、写文件,这就是它的循环。
不一致在这里不只是显示错误,它会被消费、被放大、被固化。
卡片上那几个字段,不是同一种东西
这是我自己踩了几次才分清的。
| 字段 | 本质 | 什么时候坏 |
|---|---|---|
file_path | 指针 | 文件一移动就断 |
title / preview | 文件内容的缓存 | 文件一改就旧 |
created_at / kind | 真·元数据 | 不会 |
中间那类是事故重灾区,因为它长得像元数据 —— 存在数据库的一个列里,和旁边的 created_at 看起来是一家人。
但它不是。它是派生数据。
卡片上的摘要不是元数据,是缓存。而缓存需要失效策略。
大多数项目把它当成"存下来的字段"就完了,从来没给它设计过失效和重建。于是它只在写入的那一刻是对的,之后一路旧下去,没有任何机制会发现。
第一类同理:file_path 是指针,不是身份。位置会变(改名、归类、移动),身份不能变。拿位置当身份,等于每一次移动都是一次数据迁移 —— 而 agent 是会移动文件的。
这个错误有个人人都体验过的版本:你把音乐文件夹挪了个地方,播放器里的列表全变成了灰色失效项。 歌一首没少,播放器就是找不到 —— 因为它记的是路径,不是身份。
经典解法,在这里死了一大半
双写问题有一整套成熟解法。但它们几乎都建立在同一个假设上:两边都有事务或日志。
一旦有一方是文件系统,这个假设就塌了。
| 解法 | 在这里能不能用 |
|---|---|
| 两阶段提交 / XA | ❌ 不是"贵",是不存在。POSIX 文件系统没有 prepare 阶段。业界唯一一次正经尝试是微软的 Transactional NTFS,现在官方文档标题就叫《Alternatives to using Transactional NTFS》 |
| CDC / 变更数据捕获 | ❌ 文件系统没有可 tail 的 WAL。最接近的 inotify 不是变更日志 —— man page 自己列了:非递归、队列溢出会丢事件、看不到网络文件系统上的远程改动 |
| Outbox | ⚠️ 只能单向。它的全部前提是"两次写能进同一个事务"。DB→文件这个方向可以:事务里写 {业务行 + 意图行},再由 relay 去落文件。但 agent 先写文件那个方向搭不上车,因为文件系统没有事务可以依附 |
| Saga / 补偿 | ⚠️ 部分可用。unlink 是"创建文件"的像样补偿;但**“覆盖写"没有补偿** —— 旧字节已经没了 |
把最后一条推到底,会得到一条很实用的铁律:
永不原地覆盖。 文件写成 create-only、内容不可变,补偿就永远只是一个 delete。
剩下能用的只有三样
- 幂等 —— 让重试安全。文件名本身就可以是幂等键(内容寻址,或
{id}/{version}.ext);O_EXCL创建把"写"变成一次 CAS - 不可变 —— 让补偿只剩 delete
- 对账 —— 唯一的兜底。它只需要 LIST 和 STAT,任何存储都有
这三样里,没有一样能防止不一致。它们分别做的是"让重试无害”、“让回滚可行”、“让错误可发现”。
当一方是文件系统,你争取的从来不是"不出错",而是"出错之后能收敛"。
到这里问题讲完了。剩下是怎么做。
第一步:把真相源定下来
不一致的时候听谁的 —— 这个问题必须有一个写进代码的答案,不能每次现场判断。
而要让"文件说了算"成立,有个前提常被忽略:文件得知道自己是谁。
把 ID 只存进数据库是不够的。文件一旦离开数据库,就是匿名的 —— 你拿到一个目录,里面一堆 Markdown,除了看内容猜,没办法知道哪个是哪个。对账程序也一样,它只能从路径反推身份,而路径恰恰是那个会变的东西。
所以身份必须同时写进文件本身。做法和 SKILL.md 是同一个约定:头部一段 YAML frontmatter。
---
id: 01K2QF9X7M3ND8VJ5RTBHW
kind: meeting-minutes
created_at: 2026-08-13T11:20:00+09:00
---
# 正文从这里开始
一个前提要说死:ID 必须在写文件之前生成,不能写完再回头补。补的那一刻就有窗口 —— 崩在中间,你会得到一个没有身份的文件,而它和垃圾文件长得一模一样。
换来四件事:
- 对账变得可能 —— 扫一遍文件系统就知道每个文件是谁,不必从路径反推
- 移动和改名不丢身份 —— 路径退回它本来的角色:当前位置,仅此而已
- 数据库记录可以重建 —— 文件是完备的,索引丢了可以从文件重扫出来
- agent 拿得到这个 ID —— 它读文件时顺手就有,做工具调用可以直接引用,不用回头查库
它到底解决了什么
这里要说准确,因为我一开始也想岔了。
它没有消除双写。 创建一个产物,你还是得先落文件、再写 DB 那一行,中间那步照样会失败。
它消除的是**「不知道该信谁」**:
之前:文件 ←→ DB 两个半权威,冲突时现场判断
之后:文件 → DB 一个真相源,一个可重建的投影
两边都"半权威"才是最难收拾的局面 —— 每次冲突都得人来裁,而这条规则没法写进代码。定了主从之后,裁决规则只有一句:文件赢,DB 重建。
Iceberg 和 Delta Lake 走的就是这条路:不可变的数据文件是事实,上面那层元数据可以重算。真正起作用的规则只有一条 ——
恰好一方是真相源,另一方必须可推导。
⚠️ 推论:既然 DB 可重建,“重建"就该是一条常备通路,而不是出事时现写的脚本。 平时不跑的恢复路径等于没有 —— 真出事那天你会发现它跑不通。
一个直接推论:用户的编辑也得落到文件
前面那个"agent 覆盖用户修改"的循环,到这里就有解了。而且解法不在 agent 那边,在写入路径上。
用户在页面上改标题,不能只 UPDATE 那一行。必须:
1. 先改文件(frontmatter 或正文)
2. 再更新 DB 投影
顺序不能反,理由和下一节的"先数据后指针"是同一条。
这听起来很显然,但实现上极容易走偏 —— 因为页面那侧的自然写法就是 UPDATE ... WHERE id = ?,一行代码的事;而"改文件"要考虑并发、要考虑 agent 是不是正在写同一个文件、要考虑改完文件但 DB 更新失败怎么办。
阻力最小的那条路,恰好是错的那条。 这类 bug 不是因为没想到,是因为想到了但那条路太顺手。
第二步:主动选择你漏出哪一类垃圾
两边不同步,一定会漏出脏东西。但漏出哪一类,是你能选的。
先写文件,后写 DB → 漏出「孤儿文件」 ← 有文件,没记录
先写 DB,后写文件 → 漏出「悬挂引用」 ← 有记录,没文件
这两类的可清理性差了一个量级:
孤儿文件可以安全清。 从 DB 算出可达集合,LIST 存储,删差集 —— 标准的 mark & sweep。这类东西甚至有公认名字,Iceberg 的 API 就叫 remove_orphan_files,Delta Lake 叫 VACUUM。
悬挂引用不能自动清。 因为你分不清"文件还没写完"和"文件永久丢了” —— 两者在存储层看起来一模一样。而 DB 行通常承载着业务含义,静默删掉它是在灭证。
所以:
建的时候先数据后指针,删的时候先指针后数据。
Iceberg 和 Delta 一定先写数据文件、再提交元数据,就是这个原因。
⚠️ 清理必须带 grace period —— 只处理"超过任何一次写入可能耗时"的差异。Iceberg 的 remove_orphan_files 默认阈值是 3 天,文档明确警告:阈值短于一次写入耗时,就会删掉正在写的文件。
这不是保守。是因为对账程序没法区分"在途"和"泄漏",只能靠时间。
顺带说 rename
很多人(包括我)会想:那就写临时文件,再 rename 过去,rename 是原子的。
rename 确实是原子的,但它的原子性比你以为的窄:
- 只保证命名空间,不保证数据。 POSIX 的原话是目标名 “shall remain visible”,说的是并发观察者看到的目录项,对"内容有没有落盘"一个字没提
- 必须同一个目录。 跨挂载点直接
EXDEV;某些工具层会"体贴地"降级成 copy+delete —— 而那不是原子的 - 规范里根本没有"崩溃原子性"这一条。 2009 年 ext4 的 zero-length file 事故就是这么来的:延迟分配下 rename 先进日志、数据块还没落,崩溃后得到一个 0 字节文件,新旧内容一起丢
安全的完整序列是:
写 tmp → fsync(文件) → close → rename(tmp, dst) → fsync(父目录)
少了最后一步,rename 本身可能丢失 —— 即使数据已经持久。
但对我们这个场景,还有个更根本的问题:
rename 只提交了文件系统那一侧。如果 DB 行也得跟着翻,那 rename 就不是提交点 —— 那只是又一次双写。
第三步:对账,和它的报警分级
对账本身很朴素:枚举两边,比集合差。
文件有、DB 无 → 孤儿文件(超过 grace period 才清)
DB 有、文件无 → 悬挂引用(不自动删,标记待查)
两边都有 → 比内容指纹,判断投影是否过期
第三行是最容易漏做的一条 —— 前两行只查存在性,查不出"文件被改了但卡片还是旧的"。要抓它得比内容:记一个 hash,或者退一步记 size + mtime(便宜但会漏,内容变了大小和时间戳都没变的情况是存在的)。
做完对账,新问题马上来:告警太多,没人看。
《DDIA》第 12 章有个区分,我觉得是这类系统最该抄的一条:
| 含义 | 该怎么办 | |
|---|---|---|
| timeliness | 暂时不一致,会自愈 | 等、修,不该报警 |
| integrity | 永久损坏,不会自愈 | 立刻报警 |
对到卡片上:
- 摘要旧了 → timeliness,下次重建就好
- 指向一个不存在的文件 → integrity,它不会自己好
把这两类混在一起,是这类系统告警噪声的主要来源。 全报警等于没报警,全不报等于没兜底。
最后一个坑:解析行为本身是个契约
这个太隐蔽,单独说。
产物是 Markdown,开头带 YAML frontmatter。页面那侧用 JavaScript 读(gray-matter),流水线那侧用 Python 读(PyYAML)。
同一份文件,两边解析出了不同结果。
起因是标题里带了一个中文冒号后跟空格。JS 那侧的解析器抛异常,被上层 catch 成"没有 frontmatter",那条记录就被静默跳过了;Python 那侧照常解析。
结果是一部分内容在页面上不存在,但在流水线里存在。查了很久,因为两边日志都正常。
教训不是"要处理异常",而是:
同一份数据被两种语言解析时,解析行为本身就是一个需要对齐的契约。
不能靠"两边都是 YAML"就假定一致。真解法是:要么只有一侧解析、另一侧走接口拿结果;要么给这个契约写跨语言的一致性测试。
当时的临时修法是让两边都严格 —— 宁可一起失败,也不要一边成功一边失败。
一致的错误比不一致的正确更好排查。 这句话在双写系统里尤其成立。
小结
- Agent 系统有两个写入方:agent 写文件,用户写数据库。这是分工的直接后果,不是设计失误
- 因此 dual write 是默认状态,对齐才是要额外做的事
- 除了两个方向的漂移,agent 系统还有第三种:漂移被 agent 读到、当成事实、写回去固化
- 卡片上的字段分三类:指针 / 缓存 / 真元数据。把缓存当元数据是最常见的事故来源
- 一方是文件系统时,2PC 不存在、CDC 退化、Outbox 只能单向,剩下只有幂等、不可变、对账
- 身份要写进文件本身。它不消除双写,它消除"不知道该信谁"
- 定了真相源之后,用户的编辑也必须落到文件 —— 否则 agent 永远看不见
- 建的时候先数据后指针,删的时候先指针后数据
- 区分 timeliness 和 integrity,否则告警会淹掉真问题
最后一句:这类系统里,我逐渐不再问"怎么保证不出错",而是问 “出错之后,谁会发现”。
第一个问题在有两个写入方的时候没有答案。第二个有。