页面上有一张卡片:标题、一句摘要、生成时间。点开是一份完整的文档。

在传统项目里,这没什么可说的 —— 内容就在数据库的某个字段里,卡片是它的一个视图,一次查询的事。

但这是个 agent 系统。文档是 agent 生成的一个 Markdown 文件,躺在磁盘上。数据库里只有一行元数据:标题、摘要、时间戳,和一个 file_path

于是这张卡片,是两个存储拼出来的。

为什么非要这么拆

第一反应通常是:那把内容也存进数据库不就行了。

但你很快会发现拆不掉,因为 agent 的工作方式就是读写文件

ls 一个目录看有什么,read 一个文件拿上下文,grep 找线索,write 落产物。这是它的原生接口 —— 你给它一个 ORM 让它写业务表,它会用得很别扭,而且你得为每一种产物预先定义 schema,那正是 agent 场景里最不确定的东西。

反过来,页面的原生接口是表:排序、分页、过滤、权限。你不会让前端去 grep 一个目录。

agent  的原生接口 = 文件系统
页面   的原生接口 = 数据库

两套天生就在。这不是没设计好,是分工的直接后果。

于是你有了两个写入方

这才是问题的根。

flowchart LR A["Agent"] -->|"write / mv / rm"| FS["文件系统<br/>内容"] U["用户"] -->|"页面操作"| DB["数据库<br/>元数据 + 路径"] DB -.->|"渲染"| P["卡片"] FS -.->|"点开才读"| P

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 来说根本不存在

flowchart TB U["用户在页面改了标题"] --> D["只落进 DB"] D --> A["agent 下一轮读文件<br/>看到的还是旧标题"] A --> W["产出新内容,沿用旧标题写回文件"] W --> R["用户的修改被覆盖"] R -.->|"没有报错<br/>没有冲突提示"| R2["而且现在文件是'对'的<br/>下次同步会把 DB 也改回去"]

最后那一步是致命的:错误的一侧赢了,并且会被当成事实传播下去。

传统系统里很少出现这种情况,因为没人会拿缓存当输入、算完再写回源。但 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 必须在写文件之前生成,不能写完再回头补。补的那一刻就有窗口 —— 崩在中间,你会得到一个没有身份的文件,而它和垃圾文件长得一模一样。

换来四件事:

  1. 对账变得可能 —— 扫一遍文件系统就知道每个文件是谁,不必从路径反推
  2. 移动和改名不丢身份 —— 路径退回它本来的角色:当前位置,仅此而已
  3. 数据库记录可以重建 —— 文件是完备的,索引丢了可以从文件重扫出来
  4. 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 确实是原子的,但它的原子性比你以为的窄:

  1. 只保证命名空间,不保证数据。 POSIX 的原话是目标名 “shall remain visible”,说的是并发观察者看到的目录项,对"内容有没有落盘"一个字没提
  2. 必须同一个目录。 跨挂载点直接 EXDEV;某些工具层会"体贴地"降级成 copy+delete —— 而那不是原子的
  3. 规范里根本没有"崩溃原子性"这一条。 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"就假定一致。真解法是:要么只有一侧解析、另一侧走接口拿结果;要么给这个契约写跨语言的一致性测试。

当时的临时修法是让两边都严格 —— 宁可一起失败,也不要一边成功一边失败。

一致的错误比不一致的正确更好排查。 这句话在双写系统里尤其成立。

小结

  1. Agent 系统有两个写入方:agent 写文件,用户写数据库。这是分工的直接后果,不是设计失误
  2. 因此 dual write 是默认状态,对齐才是要额外做的事
  3. 除了两个方向的漂移,agent 系统还有第三种:漂移被 agent 读到、当成事实、写回去固化
  4. 卡片上的字段分三类:指针 / 缓存 / 真元数据。把缓存当元数据是最常见的事故来源
  5. 一方是文件系统时,2PC 不存在、CDC 退化、Outbox 只能单向,剩下只有幂等、不可变、对账
  6. 身份要写进文件本身。它不消除双写,它消除"不知道该信谁"
  7. 定了真相源之后,用户的编辑也必须落到文件 —— 否则 agent 永远看不见
  8. 建的时候先数据后指针,删的时候先指针后数据
  9. 区分 timeliness 和 integrity,否则告警会淹掉真问题

最后一句:这类系统里,我逐渐不再问"怎么保证不出错",而是问 “出错之后,谁会发现”

第一个问题在有两个写入方的时候没有答案。第二个有。