长任务把普通请求变成分布式状态机
模型和 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”通常无法由单个平台替所有参与方承诺;正确策略是消息重复可接受、业务效果幂等。
典型做法是在一个数据库事务中写业务状态、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”无法表达恢复语义。跨机器的墙钟可能漂移,因果裁决依赖版本、租约颁发方和服务端记录。
部分失败的恢复顺序
- 确认已知事实:读取持久状态、Inbox/Outbox、Tool 回执和当前租约;
- 选择检查点:回到状态已提交且后续动作可重复的边界;
- 分类 Attempt:只重试暂时错误且未超预算的步骤;
- 隔离未知:无法自动恢复的消息进入隔离/死信队列并携带上下文;
- 对账:周期性比对内部状态和外部权威状态,修复“内部完成但副作用缺失”等漂移。
Dead-letter 不是终态垃圾桶。它需要 Owner、可搜索原因、重放前校验、批量风险控制和处理 SLO。未经修复直接整批重放,会把同一个协议或数据错误再次放大。
用故障注入验证语义
| 注入点 | 期望恢复 | 必须证明 |
|---|---|---|
| 状态提交前 | 消息重投,新 Attempt 重算 | 无可见半状态 |
| 状态提交后、Ack 前 | 重复投递命中 Inbox | 不重复副作用 |
| 外部调用超时 | 先按幂等键查询/对账 | unknown 不被误写 failed |
| 租约过期且旧 Worker 晚到 | 版本冲突拒绝推进 | 旧结果保留但不覆盖 |
进一步制造重复、乱序、长延迟、网络分区和时钟偏差,验证状态只沿合法边迁移。测试结论应落成事件时间线、数据库记录和外部副作用证据,而不是只看 Worker 最终退出码。
主动提取:为什么“队列保证 Exactly-once”仍不能证明不会重复扣款?
故障推演:Worker 在外部 API 成功后、本地提交前崩溃,状态应是什么?
迁移判断:按 Run 分区后是否可以删除状态版本?
资料与事实边界
以下以官方文档、标准、论文和原始工程资料为主。厂商 API、价格、限额与模型能力会变化,落地时仍需按目标版本做能力探测和回归。
- Temporal:Activity Definition — Activity、重试与幂等的官方语义
- Temporal:Detecting Activity failures — 排队、单次执行、整体执行与心跳超时的区分
- AWS Builders’ Library:Making retries safe with idempotent APIs — 稳定业务意图、重复请求与幂等 API 的原始工程实践
- CloudEvents Specification — 跨系统事件上下文与版本化信封的标准参考