仓库还有十套设备。Agent 为一场活动预留三套,接着去订安装时段。时段没订上,它决定把第一步撤回来。
“撤回来”看着只有三个字,写成代码却有两种完全不同的意思:把自己占的三套放回去,或者把库存改回之前看到的十套。中间如果另一个订单已经拿走两套,后一种写法就凭空变出了两套设备。
这是合成业务场景,没有对应真实客户或作者亲历事故。第三篇处理单次工具调用结果未知,第九篇处理费用预留与对账。这一篇把两步串起来:第二步失败后,如何持久地安排第一步的补偿;补偿自己又崩溃时,如何恢复而不多退、不重占。
资料核对日期为 2026 年 10 月 18 日。本文实际运行 Python 标准库、三个本地独立 SQLite 文件、并发进程和进程突然退出;库存、时段和故障输入均为合成样例。没有模型调用、网络服务、真实订单或跨机器实验,也没有把这些代码称为生产 Saga 框架。
1. 补偿是下一笔业务动作,不是时间倒流#
一次数据库事务回滚,撤销的是尚未提交的本地改动。这里第一步已经提交给库存系统,第二步才去访问另一个系统。它们没有共同事务;再给协调器套一层 try/except,也不能让已经提交的库存自动退回。
Azure 的补偿事务模式文档强调,直接恢复旧状态可能覆盖其他并发工作,补偿需要遵守具体业务规则,而且补偿本身也可能失败。本文只借用这几个设计原则,下面的状态机、故障点与数值全部来自自建实验,没有使用 Azure 服务。
我们的业务规则刻意收得很窄:设备和安装时段都只是可释放的预留,没有出库、发货、付款或已完成安装。每个预留用一个稳定的业务 ID 标识;释放只能改变这个 ID 对应的记录,不能覆盖整个资源池。已经交付的设备、已经发送的邮件、不能退的手续费,都不属于这个“释放预留”模型。
在合成算例里,初始可用量 10,A 预留 3,B 再预留 2,可用量变成 5。A 失败后释放自己的 3,可用量应是 8。错误的快照恢复会写成 10。run_experiment 中 unsafe_snapshot_restore 是确定性算术对照,不是两笔外部订单实测;正确方案则真的在 SQLite 中写入 A、B 的预留,再释放 A,检查 B 仍占 2。
2. 三个数据库,承认三次提交之间有缝#
代码把系统拆成 stock.db、slots.db 和 flow.db。前两个是参与者,各自维护容量、预留表和事件表;最后一个是协调器,只保存业务 ID、不可变数量和当前阶段。三个文件没有 ATTACH 联合事务,协调器也不在自己的写锁里等待参与者调用。
每个参与者用 BEGIN IMMEDIATE 把“查现有预留、核可用容量、写结果”放在同一事务。SQLite 的事务文档说明 IMMEDIATE 会在事务开始时尝试取得写事务。这解决的是单个文件内的并发写入,不是跨文件原子性。锁超时仍会抛错,代码不会把数据库错误当成资源不足。
参与者返回的结果必须有明确语义。reserved 表示这个 ID 已经占住资源;rejected 表示这个 ID 被持久拒绝;released 表示已经释放,或者在首次预留到达前就被关闭。数量与已有 ID 不一致返回 conflict。相同数量的重放只能读回已经确定的结果,不能再次占用。
这里有一个重要取舍:资源暂时不足也会写成永久 rejected。稍后其他订单释放资源,旧 ID 仍然得到 rejected。这样,协调器收到拒绝并开始补偿之后,不会因为一个迟到重试突然给第二步订上时段。若业务要重新争取资源,需要一次新的、明确的业务决定和新 ID;不能把旧失败悄悄改写为成功。新 ID 同样不提供跨 ID 的业务去重,生产系统必须定义订单层唯一性。
这个契约不是所有 API 都提供的。普通 HTTP 503、连接重置、超时,甚至查询暂时查不到,都不能替代持久的业务拒绝。只有双方真的支持上述终态语义,本文的后续推理才成立。
3. 状态机里,只有一条路通向补偿#
协调器从 stock_pending 开始。库存 reserved 后进入 slot_pending;库存 rejected 则直接 aborted,因为第一步没有占用。时段 reserved 后进入 done;时段 rejected 后进入 release_pending。最后只有库存释放确认成功,才能从 release_pending 进入 aborted。
stock_pending → slot_pending → done
│ │
│ └→ release_pending → aborted
└──────────────────────────────────→ aborted
箭头都不是“因为异常就随便选一条”。slot_pending 收不到回答时,仍然是 slot_pending,第一步仍占着三套设备。release_pending 收不到回答时,仍然是 release_pending,不能向用户报告“已撤销”。保守的代价是资源可能长时间被占用;系统需要恢复调度、告警和人工处置,而不是把超时解释成已经恢复。
代码中的 unavailable 是一个合成缺回音输入,抛出 TimeoutError,不会偷偷走业务拒绝分支。它发生在调用前,因此只验证协调器如何处理缺回音,不能冒充真实网络延迟或远端请求在途;提交后回音丢失由下面的进程退出实验单独覆盖。
每次推进都先读取阶段,再调用参与者,最后用阶段条件更新协调器。核心写法是:
c.execute(
'UPDATE saga SET phase=? WHERE key=? AND phase=?',
(next_phase, key, phase),
)
多个恢复进程可能同时读取同一阶段,也可能重复调用参与者。参与者必须用稳定 ID 去重,协调器的条件更新负责不让旧结果倒退当前阶段。本文的阶段图没有回边,同一个阶段不会退出后再次进入,因此旧阶段相等检查足够用于这个受限状态机;不能原样推广到允许重开、版本迁移或循环执行的工作流。
4. 补偿提交了,确认却没写下来#
最值得盯住的故障点,是库存已经把 A 改成 released,协调器仍停在 release_pending。恢复进程只看后者,会再次调用 release。
如果 release 的含义是“每来一次就把可用量加三”,第二次恢复就会多退。样例不这样做。它先查 A 的记录;已经 released 就返回原结果,不再改容量、不再写第二条释放事件。资源使用量由仍为 reserved 的记录计算,释放 A 不需要重写 B,更不需要保存旧的总量快照。
释放后也不能把 A 的记录删除。否则一个迟到的 reserve(A,3) 看见“从没来过”,又会占三套。released 记录是阻止旧请求复活的墓碑。这个边界与第六篇的保留策略相接:清理状态不是普通磁盘整理,删除终态会改变重放语义。本篇没有实现保留期限,测试记录持续保留。
release 还支持在预留尚未出现时写入 released:迟到的正向请求也会看到关闭结果,而不会新建占用。正常协调器并不依赖乱序释放,因为它只在第一步成功后补偿;这个参与者能力用于防住迟到命令,并不意味着任何调用者都可以取消任意预留。release 是可信内部接口,样例没有认证、租户隔离或真实审批,不能开放给模型直接调用。
测试专门让一个真实子进程读到 stock_pending,然后在调用库存前停住。另一条执行路径把整件事推进到 aborted、完成释放,再放行旧进程。它拿到 released 后重新读协调器阶段,返回 aborted,没有再占资源。若阶段没变却出现不符合契约的结果,代码会报错,留下需要调查的状态,不会猜一个成功结论。
5. 用崩溃把每条接缝撬开#
对库存预留、时段预留和库存释放三个阶段,分别在四个位置让子进程执行 os._exit(73):参与者提交前、参与者提交后、协调器提交前、协调器提交后,共十二个真实退出点。没有用正常异常捕获来假装进程已经死掉。
参与者提交前退出时,本地事务恢复后没有那次写入;提交后退出时,协调器仍可能保留旧阶段,恢复靠参与者回执重放。协调器提交前退出时,参与者的独立提交不会一起回滚;协调器提交后退出则直接从下一阶段继续。十二组恢复最后都到达 aborted,库存占用 0,A 的库存历史保留一次 reserved 和一次 released。
这也解释了为什么不能只测试“最后库存是零”:把历史全部删干净同样能得到零,却丢掉了迟到请求的防线。本次检查同时对照协调器阶段、参与者记录和事件,确认不是靠擦掉证据让结果好看。
另外两类测试分别启动六个进程:一类恢复同一个业务 ID,检查补偿只记一次;另一类推进不同业务 ID,让它们争抢同一个库存池,检查各自终态不会破坏别人记录。并发赢家取决于进程调度,所以输出不固定哪个订单最先拿到资源。另有上述握手控制的迟到进程测试,不靠 sleep 猜测竞态发生了没有。
实验使用 WAL 与 synchronous=FULL,但测到的是单机进程退出和 SQLite 恢复,不是断电、文件系统故障、跨区灾备或分布式一致性。snapshot 在单个资源库内使用显式读事务;跨库 report 仍仅在测试操作结束、子进程已退出后采样,不是实时跨数据库一致快照。看门狗超时只防测试挂死,不用于声称处理性能。
6. 哪些保证需要留在门外#
在本例约束下,容量不会因为同一个 ID 的重试被重复占用,补偿不会释放别人的预留,协调器不会因为缺回音就进入终态。系统也允许出现“库存已占、时段尚未决定”的中间状态。Saga 在这里提供的是可恢复的业务过程,不能把三次独立提交包装成一次原子事务。
最终能结束还依赖恢复工作会被继续执行、参与者最终可访问、持久数据不丢失。本例 finish 只是有界测试驱动,错误直接抛给调用者;没有后台队列、重试退避、运维面板或自动告警。无法联系库存时,补偿可以一直待办。这是未完成,不是最终一致已经得到证明。
也没有“取消已完成订单”接口。done 后如果开始出库,释放预留就可能错误;需要另一套经过授权的售后状态机。本文的合成规则事先允许时段明确拒绝后释放库存,生产中的退款、撤销权限或用户通知必须分别获得业务授权。前文的精确意图审批可以作为设计参考,但本实验没有集成它,更没有自动继承一次批准来做所有补偿。
若两个资源都能放进同一可靠数据库事务,而且业务不允许中间占用被外部看见,首先考虑本地原子事务。为了使用 Saga 而拆库,可能只是主动给自己增加恢复工作。
7. 把三个库放在桌上,再看恢复结果#
将五个文件下载到同一空目录:saga.py、test_saga.py、run_experiment.py、results.json、README.md。Python 3.10 或更新版本即可,无第三方库、模型密钥或付费服务。
python3 test_saga.py
python3 run_experiment.py --check
python3 run_experiment.py --output reproduced.json
本次验证环境为 Linux、Python 3.12.14、SQLite 3.53.1。37 项 unittest 通过,19 组具名场景与完整 results.json 重放一致。输入检查、同 ID 参数冲突、持久拒绝、先释放后预留、重复释放、迟到进程、同业务与跨业务并发,以及十二个崩溃切点都有覆盖。未验证 Windows;不同机器上不承诺相同进程次序或耗时。
建议先看 crash_release_after_commit:协调器仍说 release_pending,库存却已经释放。再看恢复后的同一条记录,就能看到这篇真正想保住的东西:不是一串永远显示成功的步骤,而是当两边说法暂时不一致时,仍有一条不会越退越多的回去的路。
系列讨论可查看知识星球,或通过公众号入口关注后续。本文、原创图解与示例代码由 AI 辅助制作,并经过执行与内容核验;合成资源预留不代表真实订单、支付或生产权限系统。

