分布式不变量基础设施可以重复投递和重复执行 Attempt,但同一业务意图的合法副作用只能生效一次;无法确认是否生效时,状态必须是 unknown,而不是由超时猜成 failed。

长任务把普通请求变成分布式状态机

模型和 Tool 的尾延迟远高于本地函数,人工审批会跨越进程生命周期,扩缩容和发布会回收 Worker。网络只能告诉调用者“没有在期限内收到结果”,不能证明远端没有执行。只要存在队列、后台 Worker 或外部副作用,Run 就必须按持久状态机而非一条 Promise 管理。

分布式设计的目标不是消灭失败,而是让每个失败窗口有可解释状态:尚未领取、执行中、结果未知、可重试、等待人工、已完成或已隔离。模糊的 processing=true 无法表达 Attempt、租约和外部结果。

Run、Step、Attempt 与 Event

稳定身份

Run 表示用户任务;Step 表示可提交的逻辑阶段;Attempt 表示一次可能重复的执行;Tool Call ID 关联模型动作;幂等键标识业务意图。重试创建新 Attempt,但沿用 Step 与幂等键。若每次重试都生成新业务键,去重系统无法识别“同一意图”。

事件信封

身份与版本

Event ID 唯一标识消息,Event Type 与 Schema Version 决定解析语义;Producer、Tenant 和 Trace 标识来源。类型版本属于协议,不应只靠 Topic 名或消费者代码猜测。

因果与时间

信封包含 Run/Step/Attempt、父事件或因果 ID、产生时间和可选序列位置。产生时间用于诊断,不用于跨机器证明严格顺序;状态版本或业务序号才是并发裁决依据。

载荷与安全

载荷带业务数据、预期状态版本和敏感级别。大 Tool 结果存为 Artifact 并只传引用;事件保留策略、加密和脱敏应与数据类别绑定。Event Bus 不是把 Prompt 和机密复制到所有消费者的理由。

type RunEvent<T> = {
  eventId: string
  type: string
  schemaVersion: number
  runId: string
  stepId: string
  attemptId: string
  causationId?: string
  expectedStateVersion: number
  occurredAt: string
  dataClass: 'public' | 'internal' | 'sensitive'
  payload: T
}

快照与事件的分工

快照保存当前可服务状态,支持低成本恢复;事件记录发生了什么,支持审计、重放和重建。快照必须带最后应用 Event/State Version;事件重放必须使用确定性迁移器。不要把日志当事件源,也不要让事件无限增长而没有快照、归档和版本策略。

先接受 At-least-once,再设计幂等

常见可靠队列以 At-least-once(至少一次)换取不轻易丢消息:消费者可能在业务提交后、确认消息前崩溃,于是同一消息再次投递。跨数据库、队列和第三方 API 的“端到端 Exactly-once”通常无法由单个平台替所有参与方承诺;正确策略是消息重复可接受、业务效果幂等。

消费者崩溃窗口:确认放在提交前会丢,放在提交后会重复
Receive读取 Event,尚未确认
Begin检查 Inbox 与状态版本
Commit状态、Inbox、Outbox 同事务提交
Ack确认消息;崩溃则允许重复投递

典型做法是在一个数据库事务中写业务状态、Inbox 去重记录和 Outbox 待发布事件;独立 Relay 至少一次发布 Outbox。消费者再次收到 Event ID 时返回已记录结果。Outbox 解决“状态提交但事件没发”,不解决外部不可幂等副作用,后者仍需业务幂等键、查询或补偿。

同一 Run 的顺序与并发

乐观并发控制

每次状态提交带 expectedVersion,只有当前版本匹配才更新并递增。冲突后重读状态,判断本 Attempt 已过期、可以重新计算,还是需要人工。锁超时不是授权另一个 Worker 覆盖结果;Compare-and-swap 才能证明提交基于哪个状态。

按 Run 或业务实体分区

按 Run ID 分区可减少单 Run 乱序,却可能因超长 Run 形成热点;按业务实体分区保护订单等共享对象,但一个 Run 跨多个实体时不再天然有序。分区键是吞吐与一致性的架构决策,应由冲突域决定而不是由 Topic 命名习惯决定。

晚到事件

旧 Attempt 的结果可能在新 Attempt 完成后到达。消费者用 Attempt ID 和状态版本拒绝其推进,但保留结果、耗时和外部关联用于对账。直接丢弃会失去发现重复副作用的证据,直接应用则会让旧结果覆盖新事实。

租约、心跳和时间语义

租约表示 Worker 在有限时间内拥有推进权,不是永久锁。心跳证明“仍在报告进展”,租约过期允许其他 Worker 重新领取,因此过期前后可能短暂重叠。新 Worker 仍需状态版本和幂等保护,不能把租约当 Exactly-once。

区分业务 Deadline、队列可见性超时、单 Attempt 超时和心跳超时。Temporal 的 Activity 文档也把排队、单次执行、整体执行和心跳分别建模;这说明“timeout=30s”无法表达恢复语义。跨机器的墙钟可能漂移,因果裁决依赖版本、租约颁发方和服务端记录。

部分失败的恢复顺序

  1. 确认已知事实:读取持久状态、Inbox/Outbox、Tool 回执和当前租约;
  2. 选择检查点:回到状态已提交且后续动作可重复的边界;
  3. 分类 Attempt:只重试暂时错误且未超预算的步骤;
  4. 隔离未知:无法自动恢复的消息进入隔离/死信队列并携带上下文;
  5. 对账:周期性比对内部状态和外部权威状态,修复“内部完成但副作用缺失”等漂移。

Dead-letter 不是终态垃圾桶。它需要 Owner、可搜索原因、重放前校验、批量风险控制和处理 SLO。未经修复直接整批重放,会把同一个协议或数据错误再次放大。

用故障注入验证语义

在事务边界两侧杀死 Worker
注入点期望恢复必须证明
状态提交前消息重投,新 Attempt 重算无可见半状态
状态提交后、Ack 前重复投递命中 Inbox不重复副作用
外部调用超时先按幂等键查询/对账unknown 不被误写 failed
租约过期且旧 Worker 晚到版本冲突拒绝推进旧结果保留但不覆盖

进一步制造重复、乱序、长延迟、网络分区和时钟偏差,验证状态只沿合法边迁移。测试结论应落成事件时间线、数据库记录和外部副作用证据,而不是只看 Worker 最终退出码。

主动提取:为什么“队列保证 Exactly-once”仍不能证明不会重复扣款?
队列的保证只覆盖它控制的日志和消费者事务边界;第三方付款系统不在同一原子事务中。仍需业务幂等键、付款回执查询和对账。
故障推演:Worker 在外部 API 成功后、本地提交前崩溃,状态应是什么?
内部只能确定结果未知。恢复时用稳定幂等键或外部关联 ID 查询权威结果;无法查询时转人工或补偿,不能因本地没记录就重新执行。
迁移判断:按 Run 分区后是否可以删除状态版本?
不可以。重放、租约重叠、运维修复和跨实体动作仍可能产生并发;分区降低乱序概率,不构成提交前提证明。

资料与事实边界

以下以官方文档、标准、论文和原始工程资料为主。厂商 API、价格、限额与模型能力会变化,落地时仍需按目标版本做能力探测和回归。

  1. Temporal:Activity Definition — Activity、重试与幂等的官方语义
  2. Temporal:Detecting Activity failures — 排队、单次执行、整体执行与心跳超时的区分
  3. AWS Builders’ Library:Making retries safe with idempotent APIs — 稳定业务意图、重复请求与幂等 API 的原始工程实践
  4. CloudEvents Specification — 跨系统事件上下文与版本化信封的标准参考