# M07 持久执行、幂等与恢复

> 本章产出：明确的崩溃窗口分析、稳定操作标识、事务内去重与外部结果核对。你应能说明系统保证了什么，以及保证不成立的边界。

## 1. 最重要的故障：成功了，但不知道成功了

Worker提交退款请求。退款服务已入账，响应却在网络中丢失。Worker只看到超时。此时存在两个与本地观测相容的世界：请求根本没到；请求已成功但响应丢了。

仅凭这次超时，客户端不能区分两者。盲目重试可能重复退款；不重试可能永远不完成。解决路径需要外部接口配合：稳定幂等键、按操作ID查询、业务唯一约束，或者进入人工核对。换一个更复杂的Agent框架不能消除信息缺失。

<!-- interaction:I14 -->

## 2. 把一项操作拆成可检查的阶段

课程退款意图具有稳定`operation_id`与参数摘要。执行状态为`PREPARED`、`SUBMITTED`、`UNKNOWN`、`CONFIRMED`或`REJECTED`。这些描述操作结果，不等于run的状态。

| 崩溃点 | 已知事实 | 恢复动作 |
|---|---|---|
| 保存意图前 | 没有可恢复操作 | 从任务决策重新开始，尚无授权提交 |
| 意图已保存、请求未发出 | 有稳定键与有效提案 | 检查授权后使用同一键提交 |
| 请求已发出、未获确定结果 | 外部可能成功 | 查询同一操作；必要时以同一键重试 |
| 外部已确认、本地结果未保存 | 外部有事实 | 通过原键取回结果并保存 |
| 本地已确认、Checkpoint未推进 | 操作已完成 | 复用保存结果，不新建业务意图 |

如果供应商没有幂等或查询能力，只能更保守地阻止自动重试并标记`UNKNOWN`。课程不伪造一种本地数据库魔法来保证任意外部API恰好执行一次。

## 3. 幂等键代表意图，不代表一次网络尝试

同一业务意图的每次重试使用同一键。用户明确发起另一笔退款，必须经过新资格计算和审批，生成新意图。不要用“参数相同”自动认定重复：用户可能真的想订两件相同商品。

键的作用域至少包含租户/调用者与操作类型。保存参数摘要；同一键搭配不同参数应返回冲突，不能返回旧结果掩盖调用错误。去重记录保留多久，也属于接口契约：过早清理会让迟到重试产生第二次效果。Amazon Builders Library对意图标识、原子记录和参数不一致有直接讨论。[S29](../../references/SOURCES.md#s29)

课程使用服务端生成的operation_id。attempt_id和工具call_id不能替代它：模型重新规划可能产生新的call_id，Worker重启也会产生新attempt，但业务意图不应因此改变。

## 4. 同一数据库内可以做出的强保证

如果订单余额、审批、操作记录和退款账本都在同一个支持事务的数据库，我们可以把检查、扣减余额、写账本、写操作结果放在一个事务里。唯一约束阻止同一operation写入多次；参数摘要阻止错用键；条件更新阻止余额超限。

参考实现用SQLite的写事务演示这个不变量。它证明的是教学范围内的原子性，不是PostgreSQL多节点服务或真实支付网络的吞吐/可用性。生产系统还要处理备份、故障转移、事务重试和幂等记录保留。

错误示例是先在Redis写“已处理”，再在数据库退款。两步之间崩溃会使操作永远被跳过；反过来先退款再写Redis会重复。若两个系统没有共享事务，就需要定义协议，而不是声称“用了锁所以一致”。

## 5. Checkpoint、事件重放与业务去重

Checkpoint保存执行进度快照；事件重放根据持久历史恢复状态；幂等性保证重复尝试不重复产生业务效果。三个概念相关，但不能互相替代。

只保存messages，无法知道某次写操作是否已成功。只保存“当前节点=退款”，恢复时仍可能再次退款。只使用外部幂等键，则执行状态和用户审批仍可能丢失。

对模型调用同样要考虑重复：模型已产生响应但结果没有可靠保存，恢复可能再次调用，增加费用且得到不同答案。若框架能复用已记录结果，可以减少重复；但结果记录之前的故障窗口仍需按照实际平台语义分析，不能承诺零重复计费。

## 6. Temporal应该怎样学习

Temporal把可重放的工作流控制逻辑与执行I/O的Activity区分开。学习重点是历史、重放、重试和外部副作用之间的边界，而不是把所有Python函数套上装饰器。[S22](../../references/SOURCES.md#s22)

把模型请求或退款提交放进Activity后，故障时Activity仍可能再次尝试，因此外部操作要有幂等保护。审批可以成为持久工作流接收的事件；它不需要一个进程持续占用线程等用户。[S23](../../references/SOURCES.md#s23)

本课程要求你用同一组故障情景比较手写执行器与Temporal或LangGraph中的一种，不要求三套完整重复实现。对照报告必须写清：恢复点在哪里、哪些代码重跑、哪些结果可复用、哪些副作用由业务层去重。框架升级也要重跑这组测试。

## 7. 租约过期后，旧Worker可能仍在运行

假设A拿到租约和fence=7，发生长时间暂停；租约过期后B拿到fence=8。A恢复后仍可能继续写。如果只检查“我曾经有锁”，A会覆盖B。

fencing token是单调递增的所有权代次。**受保护的资源必须检查token**，拒绝低于当前代次的写入。只在日志里记录fence毫无作用。本地状态提交可以用`WHERE fence=8`；外部系统若不支持fencing，不能因此宣布外部副作用已受保护，仍要靠稳定意图键和业务约束。

租约检查、授权和业务写入之间也可能有竞态。对同库资源，检查与写入必须处在合适的事务或条件写里；对远端资源，需要其接口参与。交互卡展示这个边界，而不是把token画成万能盾牌。

<!-- interaction:I15 -->

## 8. 补偿不是时间倒流

订票后酒店失败，可以尝试取消机票，但取消可能有费用、时限或失败。这是新的业务动作，不是把世界回滚到过去。记录补偿状态、权限与结果，并准备人工处理。

退款本身通常不能通过“负数退款”直接撤回。课程不允许这种工具；若业务需要追回款项，那属于另一套明确授权的流程。

## 9. 必做故障矩阵

L06覆盖提交前崩溃、提交后响应丢失、本地结果保存前崩溃、重复审批、两个Worker竞争、旧Worker迟到、参数变化复用键、审批过期、订单版本变化。

一个合格实现必须让账本事实成为判据：同一意图最多一笔退款；合法重试返回同一结果；不同参数被拒绝；无法判断的外部结果进入核对而非谎报完成。参考实现与模拟器都在包内，真实支付系统不在实验范围内。

<!-- knowledge:K16 -->
