深入专题:幂等、状态机与故障窗口¶
这篇围绕一个问题展开:
系统不可能保证每段网络、每个进程和每次响应都成功,怎样保证失败和重试之后,业务仍然正确?
“调用成功”有多个含义¶
Agent 调用创建工单:
可能出现:
- 请求未到服务;
- 服务收到但校验失败;
- 数据库写入失败;
- 数据库写入成功;
- 服务保存响应;
- 响应发送;
- Agent 收到响应。
Agent 超时只能知道第 7 步没有发生,不能知道第 4 步是否发生。
这段“不知道发生到哪里”的区域,就是故障窗口。
幂等键必须代表业务操作¶
错误:
服务端会认为每次都是新操作。
正确:
同一业务意图的所有重试复用同一个 operation_id。
不同业务意图必须不同。例如用户明确创建第二张工单,就不能继续复用第一张 ID。
幂等记录的状态¶
记录:
{
"operation_id": "task-12:create-ticket:incident-8",
"status": "succeeded",
"request_hash": "...",
"result": {"ticket_id": "T-1001"},
"updated_at": "..."
}
request_hash 防止同一个 operation_id 被恶意或错误地用于不同参数。
第一个并发陷阱:先查再写¶
应用层检查无法阻止并发竞争。
数据库需要唯一约束:
只有一个请求获得执行权。另一个读取现有执行状态或结果。
第二个陷阱:记录 started 后进程崩溃¶
以后所有重试都看到 started,是否永远等待?
需要:
- owner/lease;
- started_at;
- heartbeat;
- 超时接管;
- fencing token 或版本号。
接管者还要判断外部副作用是否已经发生。
第三个陷阱:外部系统不支持幂等¶
例如第三方短信 API 没有幂等键:
无法从本地数据库完全证明外部结果。
选择:
- 使用支持幂等的供应商;
- 发送前记录并接受“可能重复”;
- 发送后查询外部状态;
- 对高风险操作转人工;
- 设计业务补偿;
- 明确不可能实现严格恰好一次。
成熟工程判断包括承认边界。
状态机保护合法变化¶
维修工单:
如果已 completed,再收到过期的 accepted 事件,必须拒绝。
数据库条件更新:
UPDATE tickets
SET status = 'accepted', version = version + 1
WHERE id = ?
AND status = 'submitted'
AND version = ?;
受影响行数为 0:
- 已被别人修改;
- 事件重复;
- 状态不合法;
- 版本过期。
不能无条件覆盖。
状态机与幂等怎样配合¶
幂等回答:
同一操作再来一次怎么办?
状态机回答:
当前状态是否允许这个操作?
例子:
两者解决不同问题。
状态机与锁怎样配合¶
锁控制一段时间内谁执行;状态机和版本控制决定执行是否仍合法。
锁可能:
- 超时;
- 进程暂停;
- 网络分区;
- 误释放;
- 服务重启。
因此即使拿到锁,写数据库时仍检查状态和版本。锁是优化竞争,不是最终正确性。
人工审批的重放问题¶
LangGraph 等可恢复执行系统可能在恢复时从节点开头重新运行。假设节点:
恢复后创建审计记录可能再次执行。
改法:
每个副作用拥有独立 operation_id。
补偿不是回滚时间¶
分布式业务已经产生的副作用通常无法真正回滚:
- 邮件已被收件人看到;
- 设备已经重启;
- 工单已经通知工程师。
补偿是新的业务动作:
- 发送更正;
- 创建撤销记录;
- 恢复配置;
- 标记工单取消;
- 人工介入。
它本身也需要幂等和状态机。
完整推演:审批后重启设备¶
业务键¶
流程¶
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 保证只执行一次业务意图。
深度自测¶
- 为什么客户端超时无法判断数据库是否写入?
- operation_id 为什么不能每次重试都随机生成?
- request_hash 解决什么问题?
- started 状态为什么需要 lease 或接管?
- 唯一约束、状态条件和锁分别承担什么角色?
- 为什么分布式补偿不等于数据库回滚?
- 人工审批后为什么要重新校验权限?
参考答案要点
- 响应链后半段失败时,业务写入可能已经提交。
- 服务会把重试当作新业务操作。
- 防止相同幂等键被用于不同参数。
- 防止执行者崩溃后操作永久卡住。
- 唯一约束处理并发重复;状态条件保护合法迁移;锁减少同时执行。
- 外部副作用已经发生,只能通过新的业务动作修正。
- 审批和执行之间状态可能变化,授权不能只检查一次。
完成标准¶
- 能画出一次 Tool 调用的故障窗口
- 能设计 operation_id、request_hash 和唯一约束
- 能处理 started 后执行者崩溃
- 能区分幂等、状态机、锁和事务
- 能推演审批、执行、回执和恢复