事务、WAL 与补偿(Transaction, WAL & Saga)
所属:01-fundamentals / 07-distributed-systems
面试权重:★★★
来源:《深入理解 AI Infra:量化分析与系统设计》第 11 章「工具失败后的恢复」(write()≠ 已持久化、持久性语义、Agent 事务 / ACID 方向)与第 9 章「部分故障与流式生成恢复」(token 级预写日志,DeepSeek V4 生成服务)。ACID、隔离级别、两阶段提交、Saga / 补偿、WAL 通用原理(DDIA 第 7 / 9 章、System Design 101、MIT 6.5840 等公开资料)未做一手考证 —— 这一部分两本参考书基本不覆盖,按渠道记录,不当作一手基线
相关知识点:../06-agent-infra/10-agent-runtime.md§6(分工:那篇是 Agent 工具执行层的失败恢复与持久性语义;本篇是通用机制层 —— 事务 / WAL / 补偿本身,与 Agent 无关也成立)、timeout-retry-idempotency.md(同批:超时后结果是「未知」,幂等是重试与补偿的共同前提)、state-machine.md(状态机与持久化执行,是 Saga 的落地形态)、queue-pubsub.md(投递语义:至少一次 / 恰好一次)、../04-agent-architecture/human-in-the-loop.md(中断前后副作用的幂等)、../../02-interview/design/08-agent-platform.md(控制面 / 执行面 / 状态存储三分)、../../02-interview/projects/quewi-chatbot.md
一句话#
事务要回答的是「一组改动怎么才叫一起发生」:单机靠 ACID + WAL 把「一条日志先落盘」换成「数据页随便什么时候刷」,跨服务则没有免费的午餐 —— 2PC 用阻塞换原子性,Saga 用补偿换可用性。落到 Agent 上,就是把「一轮 = 模型决策 + 工具执行 + 保存结果」当整体提交;而外部副作用恰好执行一次这件事,本地事务保证不了。
为什么重要(面试角度)#
- 它是「一致性」这个词的落点。 泛泛说「保证一致性」没有信息量;能说清原子性 / 隔离性 / 持久性各自靠什么机制保证、而一致性是应用层的责任,层级立刻不同。
- WAL 是理解一切存储系统的主线。 「先写日志再改数据」这一个决定,同时解释了崩溃恢复、复制、以及为什么顺序写不慢 —— 顺着它能把 redo / undo / checkpoint / 复制串起来。
- 跨服务事务是设计题的高频分界。 说得出 2PC 的阻塞问题和补偿不总是可行的人,和只会说「用分布式事务」的人,是两个层次。
fsync那条口径是书里的原话。write()返回 ≠ 已持久化 —— 这既是通用 OS 知识,也直接对应 Agent 运行时的「持久性语义」。- 和你的项目接得上:QueWI 网关没有事务场景(它只做模型调用,副作用是多花钱而不是改数据);要在面试里主动划这个界,不要硬把网关说成「分布式事务系统」。
⚠️ 边界声明:你没有在生产里设计或运维过事务系统 —— 没配过 2PC 协调者、没写过 Saga 编排、没调过隔离级别。本篇是通用机制的梳理,面试用「这套账我会这么算」的口径讲,不要说成「我们线上就是这么做的」。
核心内容#
1. ACID 逐条说人话#
| 性质 | 一句话 | 谁在保证 |
|---|---|---|
| 原子性 A | 一组操作要么全做、要么全不做 | 数据库:靠 undo(回滚已做的部分) |
| 一致性 C | 事务前后,应用定义的不变式都成立(如「账户总额守恒」) | 应用自己 —— 数据库不定义什么是一致 |
| 隔离性 I | 并发事务互不干扰到什么程度 | 数据库:靠锁 / MVCC,程度由隔离级别决定 |
| 持久性 D | 提交成功后,结果不因崩溃而丢 | 数据库:靠 redo + fsync |
最容易被说错的是 C。 ACID 里的「一致性」不是数据库给的——数据库给的是 A / I / D。一致性是应用层的责任:事务写法本身有没有破坏业务约束(比如把余额扣成负数),数据库管不了。所以「用事务就保证一致性」是错的;事务保证的是「要么你写的都对,要么都没写」。
最容易被含糊带过的是 I。 它不是「有 / 没有」,而是一个光谱(第 2 节)。说「我们开了事务所以隔离住了」而不说级别,等于没说。
2. 隔离级别与对应的异常#
隔离越强,并发度越低 —— 这是一条单调的取舍,不是「越高越好」。
| 隔离级别 | 允许的异常 | 异常长什么样 |
|---|---|---|
| 读未提交 RU | 脏读 | 读到了别的事务未提交的数据,对方一回滚,你读到的是从没存在过的值 |
| 读已提交 RC | 不可重复读 / 读偏斜 | 同一事务里两次读同一行,结果不同(中间被别人改了);或同一快照内读到不一致的组合 |
| 可重复读 RR | 幻读、写偏斜 | 同一范围的两次查询多出行来;或两个事务各自读到相同前提、各写不同行,两个不变式都被破坏 |
| 串行化 Serializable | —— | 无上述异常,代价是并发度(加锁等待 / 冲突重试) |
几个必须点破的坑:
- 「读未提交」现实中几乎没人用。 标准列了四级,但真实默认值基本只有两种:MySQL / InnoDB 默认 RR,PostgreSQL、Oracle 默认 RC。所以「你们跑在什么隔离级别」是个真问题,不是学术问题。
- 标准 RR ≠ 各家的 RR。 SQL 标准里 RR 允许幻读;MySQL 的 RR 用「间隙锁 / next-key lock」额外规避了大部分幻读(这是实现层的行为,不是标准要求,且有边界情况)。而 PostgreSQL 的 RR 实际是快照隔离(snapshot isolation),它不幻读,但允许写偏斜。实现差异一律以各数据库当期文档为准,别把某家的行为当标准背。
- 写偏斜是最容易被忽略的异常。 它不违反任何单行约束:两个医生同时看「还有两个医生值班」都去请假的经典例子里,每人各自扣掉自己那行,两条 UPDATE 都合法,合起来就没人值班了。它在快照隔离下就会发生 —— 提醒你「没幻读」不等于「可串行化」。
- 串行化的代价要能说清:加锁等待与冲突重试会把吞吐压下来;要不要上串行化,取决于业务能不能容忍偶发的异常读,而不是「严格总更好」。
3. WAL:为什么先写日志再改数据#
数据库改一行数据,落盘时有两种做法:直接把数据页写回磁盘,或先把「我要改什么」写进日志、再慢慢刷数据页。所有主流存储系统选后者,原因只有一个:
顺序写(append-only)比随机写便宜得多。 日志是一条不断往后追加的流,写入是顺序的;数据页则散落在磁盘各处,按页随机写。把「随机写」换成「顺序追加 + 后台批量刷」,单次提交的延迟就降下来,吞吐上去。
崩溃恢复靠日志的两件事:
- redo(重做) —— 事务已提交,但改动可能还在内存 / 只写到日志、没落到数据页;崩溃重启后按日志重放,把已提交的改动补上 → 保证持久性 D。
- undo(回滚) —— 事务没提交就崩了,改动可能已经部分写进数据页;重启后按日志撤销这些改动 → 保证原子性 A。
同一份日志还顺手解决了复制:把 WAL 发给副本,副本重放,就是物理复制 —— 所以 WAL 不只是「崩溃恢复机制」,它是「把状态变更表述成一条有序流」这个思路的实例。这也解释了为什么很多系统(包括一些 Agent 记忆系统)会借鉴「只增日志 + 定期检查点」:日志保原始证据,检查点让恢复不用从头重放。
代价要写清:每提交一次都等日志落盘,吞吐会被 fsync 的延迟限制。所以有「组提交」把多个事务的日志一起刷(见第 4 节),也有把 synchronous_commit 调成异步的 —— 那是拿持久性换吞吐,要明说。
4. write() 返回 ≠ 已持久化#
这是《深入理解 AI Infra》第 11 章的原话口径(该书引 Linux write(2) / fsync(2) 手册页):write() 返回时,数据可能仍在内核的页缓存里 —— 程序可以继续算,由操作系统后台写盘。要确保数据已经存好,必须调用 fsync(),并检查它的返回值。 还有三个容易漏的细节:
- 检查返回值 ——
fsync()可能失败(磁盘满、I/O 错误),不检查就等于假设成功的。 - 目录也要
fsync()—— 新建或重命名文件时,文件内容持久化不等于文件名 / 目录项持久化,目录项可能需要单独对目录做fsync。 - 文件
fsync不提供多文件事务 —— 你把 A、B 两个文件都写好了,中间崩溃仍可能只留下一个。
由此带出「持久性语义」这个概念(../06-agent-infra/10-agent-runtime.md §6):「保存成功」其实有不同等级 —— 写入本地页缓存 / 完成本地 fsync() / 收到云端持久化确认,能扛的故障完全不同。本地 fsync() 能扛进程重启,扛不住整台机器加磁盘不可用。所以「我们保存了」这句话必须说清保存到哪一级。
5. 单机事务 → 跨服务:2PC 的阻塞问题#
单机的 ACID 靠数据库内部的日志和锁实现;一旦改动横跨两个服务(各自的数据库),就没有一个共同的日志和锁了。经典解法是两阶段提交(2PC):
- 准备阶段 —— 协调者让每个参与者先做好准备(执行但不提交、锁住资源),各参与者回报「可以提交 / 不行」;
- 提交阶段 —— 协调者看全体投票:全 yes 就发 commit,否则发 abort。
2PC 的致命问题是「阻塞」:参与者投票「可以」之后进入 prepared 状态,一直持有锁,等协调者的最终决定。如果协调者此时崩溃,参与者既不敢自己 commit(可能别人投了 no),也不敢自己 abort(可能别人都 commit 了)—— 它卡在中间,锁一直不放,这就是「in-doubt 事务」。协调者成了单点,而它的故障会直接冻结参与者持有的资源。
所以:微服务里很少用 2PC。它不是「过时」,而是「用可用性换原子性」的取舍 —— 长事务持锁、协调者单点、跨服务锁的延迟叠加,在多数业务里不划算。补充一点:有人会想到三阶段提交(3PC),它缓解了协调者崩溃的阻塞,但在网络分区下仍无法同时保证安全与可用 —— 这不是工程疏忽,是理论边界。
6. Saga / 补偿事务:用补偿换可用性#
Saga 的思路:不要一个横跨所有服务的大事务,而是拆成一条本地事务链 ;每一步都是自己服务的本地事务(因此每一步都有本地 ACID)。如果第 步失败,就逆序执行补偿操作 ,把前面已做的抵消掉。
- 补偿不是回滚。 回滚是「当作没发生」,补偿是「再做一件事去抵消它」。区别是真实的:退款能让钱回来,但流水、时间戳、用户体验和「从没扣过款」不一样。
- 补偿不总是可行 —— 这是设计阶段必须回答的问题。 发出去的邮件收不回、已推送的通知、已拨出的电话、已经对第三方生效的调用 —— 这些操作没有补偿。所以 Saga 不是「事后补救」的机制,它逼你在设计阶段就让副作用可补偿:
| 让副作用可补偿的手段 | 例子 |
|---|---|
| 延迟发送 / 两段式 | 先「预检 + 暂存」,等整链确认后再真正发出(「预检—确认」两段式见 ../../02-interview/design/08-agent-platform.md) |
| 状态可回退 | 软删除 / 标记取消,而不是物理删除;版本化,允许指回旧版本 |
| 外部系统支持撤销 | 要求对方提供幂等接口 / 反向操作 / 操作标识(如支付网关的撤销接口) |
| 把不可逆操作放到最后 | 让它成为流程里「一旦执行就不再有后续可失败步骤」的末尾 |
- Saga 没有隔离性。 链跑的过程中,中间状态对外是可见的(钱已扣、货未发)。别的请求可能读到这个中间态。缓解靠语义锁(用一个「处理中」标记挡住对同一业务对象的并发操作)或让下游容忍中间态。说得出「Saga 换掉的不只是原子性的实现方式,还有隔离性」是加分项 —— 它本质上给的是最终一致,不是 ACID 事务。
7. 轻量替代:幂等键 + 去重表 + 状态机#
多数场景里,比 2PC 更实用的不是 Saga 本身,而是更小的一套组合:
- 幂等键 + 去重表(见
timeout-retry-idempotency.md)—— 让重复调用安全,于是「重试」这件最普通的事就不需要事务兜底; - 状态机 + 持久化状态 —— 每个业务对象有显式的状态(待处理 / 处理中 / 已完成 / 已取消),重试时先读状态再决定:已完成就返回结果,处理中就等,未开始才执行。状态本身就是「要不要重做」的判据,见
state-machine.md; - 定时对账 / 补偿任务 —— 兜住漏网的中间态(每天扫一遍「扣了款没发货」的单子)。
为什么这套常常更划算:它不要求所有服务同时可用且长事务持锁,只要操作可标识、状态可查询。判据一句话:能用「幂等 + 状态」表达的,就别上 2PC。
8. 分布式共识:一句话带过#
Raft 用「多数派日志 + 状态机重放」解决一致性 —— 把每个操作作为日志条目复制给多数节点,各节点按同一顺序重放,就得到同一份状态。它和本篇的联系是:它也是一条 WAL,只是这条日志要「多数派同意」才算提交。细节(选举、日志匹配、成员变更)属于 MIT 6.5840 的内容,本篇不展开 —— 面试里点到「多数派日志 + 状态机重放」这个结构就够了,往深处问再另开。
9. 一个有出处的数字:token 级 WAL(书第 9 章)#
这是本篇唯一一个来自参考书的具体机制,值得记住,因为它把「WAL」从存储系统搬到了生成服务:
《深入理解 AI Infra》第 9 章 §9.6.3:DeepSeek V4 的生成服务采用预写日志 —— 在对外确认之前,可靠保存已经确定的输出序列。书里把两样东西分得很清:
- token 级 WAL 保存已经确定的输出序列,决定恢复后应该继续哪条输出;
- KV 保存的是这段序列已经完成的计算,决定还要重做多少工作。
书里还点出:恢复时要保持权重版本、token 位置索引和 decode 状态一致。出处:《深入理解 AI Infra》第 9 章「部分故障与流式生成恢复」(§9.6.3)。这不是本仓库的测量,是书里的机制描述。
10. 接回 Agent:把一轮做成一个事务#
《深入理解 AI Infra》第 11 章给的方向是:借用数据库事务的做法,把一轮「模型决策 — 工具执行 — 保存结果」作为一个整体提交 —— 这一轮的模型消息、工具结果、相关文件版本都保存成功后,才记「已提交」;重启后从最后一次完整提交接着跑。这正是 **Agent 事务(agent transaction)**研究的方向,即在 Agent 执行中实现数据库的 ACID。仓库里 ../06-agent-infra/10-agent-runtime.md §6 已经把这套账铺开(保存频率、异步上传的缺口、连续保存位置),本篇不重复。
但有一条硬边界必须一起说(书里原话):
如果一轮操作还调用了外部服务,仅在本地记下「已提交」,不能保证外部操作也恰好完成一次。 那一侧需要对方支持操作标识、幂等接口或补偿处理。
也就是说:本地事务能保证「我这边」的一致,管不到「对面」。这是一条信任边界,跨过去就只能靠协议(幂等 / 补偿),不能靠本地 fsync。
常见面试问法#
Q:ACID 分别靠什么实现?「一致性」是谁保证的?
A:原子性靠 undo(回滚已做的部分)、持久性靠 redo + fsync(把已提交的补上)、隔离性靠锁 / MVCC(程度由隔离级别决定)。「一致性」是应用层的责任 —— 数据库不定义什么是一致,它保证的是「要么你写的都对,要么都没写」。所以「用了事务就保证一致性」是错的:事务写法本身破坏了业务不变式(比如把余额扣成负数),数据库管不了。
Q:隔离级别有哪几级?各自的异常是什么? A:读未提交允许脏读(读到别人未提交、可能回滚的数据);读已提交允许不可重复读 / 读偏斜;可重复读禁止上面两种,但仍允许幻读与写偏斜(MySQL 的 RR 用间隙锁额外规避了大部分幻读,实现层行为);串行化无这些异常,代价是并发度。要补两句:真实默认值基本只有两种(MySQL 默认 RR、PostgreSQL / Oracle 默认 RC);别把某家的行为当标准背,以当期文档为准。「没幻读」不等于「可串行化」(快照隔离下仍有写偏斜)。
Q:WAL 是什么?为什么先写日志再改数据? A:WAL = 先写日志、后改数据页。理由:顺序追加写比随机写数据页便宜得多,把「随机写」换成「顺序追加 + 后台批量刷」,提交延迟降下来。崩溃恢复靠两件事:redo 重放已提交但没落到数据页的改动 → 持久性;undo 撤销未提交事务的部分改动 → 原子性。顺带一提:把 WAL 发给副本就是物理复制 —— 所以 WAL 是「把状态变更表述成有序流」这个思路的实例。
Q:write() 成功了,数据就存好了吗?
A:没有。 write() 返回时数据可能还在内核页缓存里(这是《深入理解 AI Infra》第 11 章引 Linux 手册的口径)。要确保持久化得调 fsync() 并检查返回值。三个容易漏的细节:fsync 可能失败要检查、新建 / 重命名文件时目录项要单独对目录 fsync、文件 fsync 不提供多文件事务。由此带出持久性语义:本地页缓存 / 本地 fsync / 云端持久化确认,能扛的故障完全不同。
Q:跨服务怎么保证原子性?为什么不用 2PC? A:2PC 的致命问题是阻塞。 参与者投票「可以」后进入 prepared 状态、一直持锁,等协调者的最终决定;协调者此时崩溃,参与者既不敢单方面 commit(别人可能投了 no)也不敢 abort(别人可能都 commit 了),卡在中间、锁不放(in-doubt 事务)。协调者是单点,它的故障会冻结参与者的资源。 所以微服务里少用 —— 不是过时,是用可用性换原子性。3PC 缓解了协调者崩溃的阻塞,但网络分区下仍无法同时保证安全与可用,这是理论边界不是工程疏忽。
Q:什么是 Saga?补偿和回滚有什么区别? A:Saga = 本地事务链 + 逆向补偿链;第 步失败就逆序执行 。补偿不是回滚:回滚是「当作没发生」,补偿是「再做一件事抵消它」—— 退款能让钱回来,但流水和时间戳与「从没扣过款」不一样。关键点:补偿不总是可行(发出去的邮件收不回)。所以 Saga 逼你在设计阶段就让副作用可补偿(延迟发送 / 两段式、软删除可回退、要求外部系统提供撤销接口、把不可逆操作放最后)。还要主动说:Saga 没有隔离性,中间状态对外可见,需要语义锁,它给的是最终一致。
Q:那跨服务到底怎么做才对? A:多数场景用「幂等键 + 去重表 + 状态机」这套轻量组合,胜过去上 2PC。 幂等键让重试安全;状态机让每个业务对象有显式状态(待处理 / 处理中 / 已完成),重试时先读状态再决定(已完成返回结果、处理中等、未开始才执行);再配定时对账兜住漏网的中间态。判据一句话:能用「幂等 + 状态」表达的,就别上 2PC。
Q:把「一轮 Agent = 模型决策 + 工具执行 + 保存结果」做成事务,能保证什么、不能保证什么? A:能保证本地一致:这一轮的消息、工具结果、文件版本都保存成功才记「已提交」,重启后从最后一次完整提交接着跑 —— 这是 Agent 事务(ACID 方向)。不能保证外部副作用恰好执行一次 —— 书里原话:「仅在本地记下『已提交』,不能保证外部操作也恰好完成一次」。跨过信任边界那一侧,只能靠对方的操作标识 / 幂等接口 / 补偿处理。本地事务管得住「我这边」,管不住「对面」。
易错点#
| 易错说法 | 问题 | 应该说 |
|---|---|---|
| 「用事务就保证一致性」 | 把 C 当成数据库给的 | 数据库给的是 A / I / D;一致性是应用层的不变式,数据库不定义它 |
| 「隔离性有 / 没有」 | 它是光谱 | 隔离是级别问题;不说级别等于没说 |
| 「可重复读就不会有异常了」 | 漏了幻读 / 写偏斜 | 标准 RR 允许幻读;MySQL RR 用间隙锁部分规避;快照隔离下仍有写偏斜,「没幻读」≠「可串行化」 |
| 「串行化肯定最好」 | 忽略并发度代价 | 串行化用加锁等待 / 冲突重试换正确性;要不要上取决于业务能否容忍偶发异常读 |
「write() 成功就是存好了」 |
混淆写入与持久化 | write() 返回时可能还在内核页缓存;要 fsync() 并检查返回值(书引 Linux 手册口径) |
「对文件 fsync 就万无一失」 |
漏了目录与多文件 | 新建 / 重命名要对目录 fsync;文件 fsync 不提供多文件事务 |
| 「2PC 是老技术,淘汰了」 | 说法不准 | 它是用可用性换原子性的取舍,核心是协调者崩溃 → 参与者持锁阻塞(in-doubt) |
| 「Saga 就是分布式回滚」 | 混淆补偿与回滚 | 补偿是「再做一件事抵消」,不是「当作没发生」;退款 ≠ 没扣过款 |
| 「失败了补偿一下就行」 | 补偿不总是可行 | 发出去的邮件收不回;要在设计阶段让副作用可补偿,不能事后补救 |
| 「Saga 也是事务,有 ACID」 | 漏了隔离性 | Saga 没有隔离性,中间态对外可见;给的是最终一致 |
| 「本地记了已提交,外部操作就只执行一次」 | 越过信任边界 | 本地提交保证不了外部副作用恰好一次;要靠操作标识 / 幂等接口 / 补偿 |
| 把 QueWI 网关说成「分布式事务实践」 | 触碰仓库硬规矩 | 网关没有事务场景(副作用是多花钱,不是改数据);要主动划界 |
自测#
1. ACID 里的「一致性」到底谁保证?为什么说「用了事务就保证一致性」是错的?
答案
数据库保证的是原子性 A、隔离性 I、持久性 D,不是 C。 ACID 的「一致性」指的是应用定义的不变式在事务前后都成立(比如「转账前后两个账户总额守恒」)—— 不变式长什么样,是应用说了算,数据库不知道。
所以「用事务就保证一致性」是错的:如果事务写法本身破坏了业务约束(把余额扣成负数、把订单标成已完成却没扣库存),数据库照样提交 —— 它只保证「要么你写的都对,要么都没写」,保证不了「你写的是否合理」。
面试里更值钱的说法:把 C 拆出来,说清「A/I/D 是数据库的机制(undo / 锁 / MVCC / redo + fsync),C 是应用的契约」。
2. WAL 是什么?为什么「先写日志再改数据」比「直接改数据」好?redo 和 undo 各保证什么?
答案
WAL(预写日志)= 先把「要改什么」追加到日志、再慢慢把数据页刷盘。
好在顺序写:日志是不断往后追加的流,写入是顺序的;数据页散落在磁盘各处、按页随机写。 把「随机写」换成「顺序追加 + 后台批量刷」,单次提交延迟降下来、吞吐上去。
崩溃恢复靠两件事:
- redo(重做) —— 事务已提交,但改动可能只在日志里、没落到数据页;重启后按日志重放补上 → 保证持久性 D;
- undo(回滚) —— 事务没提交就崩,改动可能已部分写进数据页;重启后按日志撤销 → 保证原子性 A。
顺带一提:把 WAL 发给副本、副本重放,就是物理复制 —— 所以 WAL 是「把状态变更表述成一条有序流」这个思路的实例,这也是为什么很多系统会借鉴「只增日志 + 定期检查点」。
3. 一个参与者说「我准备好了」,然后协调者崩了。会发生什么?为什么这就让微服务不愿用 2PC?
答案
参与者进入 prepared 状态、持有锁,卡在「等最终决定」上。 它不敢自己 commit(别的参与者可能投了 no,自己提交就会不一致),也不敢自己 abort(别人可能都 commit 了)—— 这就是 in-doubt 事务,锁一直不放,占着的资源无法释放。
所以 2PC 的致命问题是「阻塞」,而不是「慢」:协调者是这个协议的单一决策点,它一崩,参与者持有的资源就被冻结。
微服务里少用的原因:长事务持锁、协调者单点、跨服务锁的延迟叠加 —— 对多数业务来说用可用性换原子性不划算。补一句:3PC 缓解了协调者崩溃的阻塞,但在网络分区下仍无法同时保证安全与可用 —— 这是理论边界(CAP 那类结论),不是工程疏忽。
4. 什么是 Saga?补偿和回滚有什么本质区别?为什么说「设计阶段就要让副作用可补偿」?
答案
Saga = 本地事务链 ,第 步失败就逆序执行补偿 。 每一步都是自己服务的本地事务(因此有本地 ACID)。
补偿 ≠ 回滚:回滚是「当作没发生」,补偿是「再做一件事去抵消它」。真实差别:退款能让钱回来,但流水、时间戳、用户看到的东西,和「从没扣过款」不一样。
为什么要在设计阶段就让副作用可补偿:因为补偿不总是可行 —— 发出去的邮件收不回、已推送的通知、已对第三方生效的调用都没有补偿。所以手段必须前置:
- 延迟发送 / 两段式 —— 先预检暂存,整链确认后再真正发出;
- 状态可回退 —— 软删除 / 版本化,而不是物理删除;
- 要求外部系统支持撤销 —— 幂等接口 / 反向操作 / 操作标识;
- 把不可逆操作放到流程最后 —— 一旦执行就不再有任何后续可失败的步骤。
必须主动补充的一条:Saga 没有隔离性 —— 链跑的过程中「钱已扣、货未发」这样的中间状态对外可见,别的请求可能读到;缓解靠语义锁。它给的是最终一致,不是 ACID 事务。
5. 把「一轮 Agent = 模型决策 + 工具执行 + 保存结果」做成事务,能保证什么?不能保证什么?
答案
能保证的是「本地一致」。 借数据库事务的做法:这一轮的模型消息、工具结果、相关文件版本都保存成功,才记「已提交」;重启后从最后一次完整提交接着跑。这就是《深入理解 AI Infra》第 11 章说的 Agent 事务(ACID 方向),具体账(保存频率、异步上传的缺口、连续保存位置)见 ../06-agent-infra/10-agent-runtime.md §6。
不能保证的是「外部副作用恰好执行一次」。 书里的硬边界原话:「仅在本地记下『已提交』,不能保证外部操作也恰好完成一次。」 如果这一轮调了外部服务(付款、发消息、创建资源),本地事务管得住「我这边」,管不住「对面」。
跨过这条信任边界只能靠协议:目标系统的操作标识、幂等接口或补偿处理。所以真实系统里,「一轮 = 事务」和「外部调用带幂等键」是配套使用的两件事,缺一个就会在崩溃恢复时要么漏、要么重。