持久状态、恢复与幂等

本节目标

理解持久执行为何需要状态历史、租约和幂等记录,并能分析“副作用已提交但状态尚未提交”的崩溃窗口。

持久执行解决什么

普通函数在进程崩溃后丢失局部变量。持久工作流把足够的执行事实写入可靠存储,使新 worker 能恢复:

  • 工作流定义与输入版本;
  • 每步状态、attempt、结果引用和错误分类;
  • 定时器、待审批与 deadline;
  • 已注册补偿动作;
  • 状态版本、租约和运行事件;
  • 与外部副作用对应的幂等记录。

产品实现可以不同。Temporal 官方文档描述其以事件历史和重放恢复 Workflow Execution,并要求工作流代码满足确定性;这是Temporal 的产品合同。其他系统可能保存显式状态快照或采用数据库状态机,不能把“可重放代码”当成所有编排器的规范要求。

状态机而不是布尔值

步骤至少要区分:

pending -> running -> succeeded

还可能进入 waiting_approval、retry_scheduled、failed、compensating、compensated、cancelled 和 manual_intervention。单个 done: true/false 无法表达“已提交但结果未知”或“主动作成功、补偿失败”。

状态转换要用事务或 compare-and-set:只有持有有效租约且看到预期 state_version 的 worker 才能提交。租约过期允许接管,但旧 worker 的迟到结果必须被拒绝或按幂等记录合并。

Lease 接管后的提交栅栏

租约只说明“此刻谁可以尝试工作”,不能让已经发出的旧请求自动消失。沿用 Agent 核心 的命名,持久记录至少有 owner_worker、lease_version、expires_at 和 state_version。每次状态提交都以 (expected_state_version, expected_lease_version) 做原子 compare-and-set;旧 worker 的迟到写入必须失败,而不是覆盖新 owner。

对外部写操作还要有提交栅栏(常被称为 fencing token):若下游支持条件更新、资源 revision 或单调 operation token,就把当前 lease_version/目标 revision 一同交给下游,并拒绝过期版本;若下游没有这一能力,不能把本地 lease 当作跨系统互斥锁,必须以稳定幂等键查询权威 receipt 后再决定重放。这样区分了运行时所有权、状态提交和外部副作用三道门。

本库的离线项目只在单进程内模拟状态机与幂等记录,未实现 durable lease 或原子 compare-and-set;其成功测试不能证明两个恢复 worker 的竞态安全。Temporal 的事件历史/重放与版本化是某一持久运行时的产品机制,也不应被误读为任意外部系统天然支持 fencing。Temporal Workflow Execution;Temporal Python Workflow Versioning(访问于 2026-07-22)

最危险的崩溃窗口

考虑库存预留:

  1. worker 调用库存系统;
  2. 库存系统成功提交;
  3. worker 尚未写 succeeded 就崩溃。

恢复后只看工作流状态会以为步骤未完成。解决方法不是声称分布式 exactly-once,而是:

  • 调用前生成稳定幂等键;
  • 下游以该键记录意图指纹和结果;
  • 恢复时先查询或重放相同键;
  • 同键同意图返回原结果,同键不同意图报冲突;
  • 工作流随后提交步骤状态。

这实现的是“在共享、可靠幂等存储范围内不重复同一逻辑副作用”。若记录只在 worker 内存中,进程重启后保证立即消失。

幂等键如何设计

一个实用键通常绑定:

业务实例 + 步骤 + 资源 + 意图版本

例如:order-42:charge:payment-v1。同时保存规范化参数哈希;若同一键后来要求不同金额,应拒绝为冲突,不可返回旧结果伪装成功。

不同动作使用不同域:

  • 触发去重:source + event_id;
  • 步骤副作用:实例 + 步骤 + 资源 + 版本;
  • 补偿:实例 + 原步骤 + compensation 版本;
  • 通知:实例 + 通知类型 + 收件目标版本。

不要用随机 request ID 作为每次 retry 的键,否则每次都被下游视为新意图。

恢复流程

  1. 校验检查点格式、完整性和定义指纹。
  2. 确认当前代码能读取该定义/schema 版本。
  3. 重新获得实例租约和状态版本。
  4. 已成功步骤不重跑;等待节点继续等待。
  5. 对 running/unknown 副作用查询幂等结果。
  6. 只把未完成且依赖满足的节点重新放入调度。
  7. 拒绝旧 worker 的迟到状态提交。

检查点完整性哈希能发现意外损坏,但不是认证机制;攻击者若能同时修改数据和未加密哈希,仍可伪造。生产系统需访问控制、加密、签名/MAC 或受保护数据库审计。

确定性重放注意事项

采用事件历史重放的运行时通常不允许工作流代码直接读取当前时间、随机数、文件系统或网络,因为同一历史可能产生不同命令。应使用运行时提供的确定性时间/随机 API,把网络、LLM 和工具调用放到活动节点,并把结果记录到历史。

即使系统不是重放型,也应避免恢复逻辑依赖隐式环境:时区、默认排序、当前模型别名和未固定配置都可能让旧实例改变路径。

练习

画出两个 worker 竞争同一步的时序图,包含:领取租约、调用下游、租约过期、第二 worker 接管、旧 worker 迟到。设计:

  1. 哪次状态提交成功?
  2. 两次下游调用如何共享幂等键?
  3. 同键不同金额时如何失败?
  4. 检查点定义版本不兼容时是迁移还是转人工?

自测

  1. 为什么租约本身不能防止重复外部副作用?
  2. exactly-once 的说法需要明确哪些系统边界?
  3. 参数哈希为什么应与幂等键一起保存?
  4. 可重放工作流为何不能直接调用 LLM?

下一步

继续 补偿、审批与人工处理。

参考资料