跳转至

深入专题:幂等、状态机与故障窗口

这篇围绕一个问题展开:

系统不可能保证每段网络、每个进程和每次响应都成功,怎样保证失败和重试之后,业务仍然正确?

“调用成功”有多个含义

Agent 调用创建工单:

Agent
→ Tool Service
→ Database

可能出现:

  1. 请求未到服务;
  2. 服务收到但校验失败;
  3. 数据库写入失败;
  4. 数据库写入成功;
  5. 服务保存响应;
  6. 响应发送;
  7. Agent 收到响应。

Agent 超时只能知道第 7 步没有发生,不能知道第 4 步是否发生。

这段“不知道发生到哪里”的区域,就是故障窗口。

幂等键必须代表业务操作

错误:

每次重试生成新的 UUID

服务端会认为每次都是新操作。

正确:

operation_id =
task_id + step_name + target_resource + business_version

同一业务意图的所有重试复用同一个 operation_id。

不同业务意图必须不同。例如用户明确创建第二张工单,就不能继续复用第一张 ID。

幂等记录的状态

started → succeeded
       ↘ failed_retryable
       ↘ failed_final

记录:

{
  "operation_id": "task-12:create-ticket:incident-8",
  "status": "succeeded",
  "request_hash": "...",
  "result": {"ticket_id": "T-1001"},
  "updated_at": "..."
}

request_hash 防止同一个 operation_id 被恶意或错误地用于不同参数。

第一个并发陷阱:先查再写

请求 A:查询不存在
请求 B:查询不存在
请求 A:创建
请求 B:创建

应用层检查无法阻止并发竞争。

数据库需要唯一约束:

UNIQUE(operation_id)

只有一个请求获得执行权。另一个读取现有执行状态或结果。

第二个陷阱:记录 started 后进程崩溃

插入 operation=started
→ 进程崩溃

以后所有重试都看到 started,是否永远等待?

需要:

  • owner/lease;
  • started_at;
  • heartbeat;
  • 超时接管;
  • fencing token 或版本号。

接管者还要判断外部副作用是否已经发生。

第三个陷阱:外部系统不支持幂等

例如第三方短信 API 没有幂等键:

短信发送成功
→ 响应丢失
→ 重试可能再发一条

无法从本地数据库完全证明外部结果。

选择:

  • 使用支持幂等的供应商;
  • 发送前记录并接受“可能重复”;
  • 发送后查询外部状态;
  • 对高风险操作转人工;
  • 设计业务补偿;
  • 明确不可能实现严格恰好一次。

成熟工程判断包括承认边界。

状态机保护合法变化

维修工单:

draft → submitted → accepted → completed
                    ↘ rejected

如果已 completed,再收到过期的 accepted 事件,必须拒绝。

数据库条件更新:

UPDATE tickets
SET status = 'accepted', version = version + 1
WHERE id = ?
  AND status = 'submitted'
  AND version = ?;

受影响行数为 0:

  • 已被别人修改;
  • 事件重复;
  • 状态不合法;
  • 版本过期。

不能无条件覆盖。

状态机与幂等怎样配合

幂等回答:

同一操作再来一次怎么办?

状态机回答:

当前状态是否允许这个操作?

例子:

operation_id 相同 + 已 succeeded
→ 返回已有结果

operation_id 新 + 当前状态 completed
→ 拒绝非法操作

两者解决不同问题。

状态机与锁怎样配合

锁控制一段时间内谁执行;状态机和版本控制决定执行是否仍合法。

锁可能:

  • 超时;
  • 进程暂停;
  • 网络分区;
  • 误释放;
  • 服务重启。

因此即使拿到锁,写数据库时仍检查状态和版本。锁是优化竞争,不是最终正确性。

人工审批的重放问题

LangGraph 等可恢复执行系统可能在恢复时从节点开头重新运行。假设节点:

创建审计记录
→ interrupt 等待审批
→ 执行操作

恢复后创建审计记录可能再次执行。

改法:

节点 A:幂等保存审批请求
节点 B:等待审批
节点 C:幂等执行副作用

每个副作用拥有独立 operation_id。

补偿不是回滚时间

分布式业务已经产生的副作用通常无法真正回滚:

  • 邮件已被收件人看到;
  • 设备已经重启;
  • 工单已经通知工程师。

补偿是新的业务动作:

  • 发送更正;
  • 创建撤销记录;
  • 恢复配置;
  • 标记工单取消;
  • 人工介入。

它本身也需要幂等和状态机。

完整推演:审批后重启设备

业务键

operation_id =
task-88:restart-device:device-1001:approval-v3

流程

1. Agent 生成计划
2. 保存计划 hash 和版本
3. 状态 → waiting_approval
4. 用户批准 version=3
5. 重新检查权限、设备和计划 hash
6. 插入 operation started(唯一约束)
7. 下发 command_id
8. 设备回执 received/executing/succeeded
9. operation → succeeded,保存结果
10. task 继续

故障

批准后权限被撤销:

第 5 步重新校验,拒绝执行。

命令下发后响应丢失:

用 command_id 查询命令状态,不生成新 command_id。

Worker 重启:

根据 task checkpoint 和 operation 结果继续。

设备离线:

根据命令策略进入 waiting_device、timeout 或 failed,不无限重试。

用户重复点击批准:

approval version 与 operation_id 保证只执行一次业务意图。

深度自测

  1. 为什么客户端超时无法判断数据库是否写入?
  2. operation_id 为什么不能每次重试都随机生成?
  3. request_hash 解决什么问题?
  4. started 状态为什么需要 lease 或接管?
  5. 唯一约束、状态条件和锁分别承担什么角色?
  6. 为什么分布式补偿不等于数据库回滚?
  7. 人工审批后为什么要重新校验权限?
参考答案要点
  1. 响应链后半段失败时,业务写入可能已经提交。
  2. 服务会把重试当作新业务操作。
  3. 防止相同幂等键被用于不同参数。
  4. 防止执行者崩溃后操作永久卡住。
  5. 唯一约束处理并发重复;状态条件保护合法迁移;锁减少同时执行。
  6. 外部副作用已经发生,只能通过新的业务动作修正。
  7. 审批和执行之间状态可能变化,授权不能只检查一次。

完成标准

  • 能画出一次 Tool 调用的故障窗口
  • 能设计 operation_id、request_hash 和唯一约束
  • 能处理 started 后执行者崩溃
  • 能区分幂等、状态机、锁和事务
  • 能推演审批、执行、回执和恢复