外观
异步任务卡死、重复消费与状态恢复故障复盘
30 秒复盘结论: 这是一场故障演练:Worker 已完成外部副作用,但在更新任务状态和确认消息前崩溃;租约过期后消息再次投递,造成任务长期停在
PROCESSING或重复执行。止损是暂停相关分区、隔离消息并按task_id/effect_id对账;长期修复采用幂等收件箱、事务发件箱、带版本租约、对账任务和死信队列。当前只有演练 Schema 与验证计划,没有真实消息、积压量或恢复时长。
目录
1. 摘要与小白解释
仓库管理员搬完一箱货,正准备在登记簿上写“完成”时突然停电。下一班管理员只看到登记簿仍写“处理中”,于是又搬了一次。这里,货物移动是外部副作用,登记簿是任务状态,交接令牌是消息确认,值班有效期是租约。
正确做法不是看到“处理中”就永远等待,也不是超时就盲目重做,而是查询货物是否已经到位,再恢复登记簿。类比忽略了多分区顺序、不可查询外部系统、事务边界和补偿失败。
| 项目 | 内容 |
|---|---|
| 现象 | 任务卡在处理中、消息重复投递、外部结果重复 |
| 直接原因 | 副作用、状态写入和消息确认不在同一事务边界 |
| 根本原因 | 缺少结果未知状态、幂等消费和周期对账 |
| 止损 | 暂停消费、隔离消息、对账外部事实、人工恢复 |
| 修复 | Inbox/Outbox、租约状态机、Reconciler、DLQ |
2. 故障教学图片
图:故障教学图片|异步任务结果未知与状态恢复闭环
替代文本: 任务 API、消息队列、Worker、外部副作用和状态库形成异步链路;副作用成功但确认丢失和租约过期导致卡死或重复,证据支持暂停消费、对账和幂等恢复,事实边界为故障演练。
教学图片待人工审图任务 API、消息队列、Worker、外部副作用和状态库形成异步链路;副作用成功但确认丢失和租约过期导致卡死或重复,证据支持暂停消费、对账和幂等恢复,事实边界为故障演练 暂不公开,正文与 Mermaid 图可正常阅读。
读图结论: 结果未知时先核对外部副作用,再恢复任务状态和消息消费。
图中红色路径同时展示“卡死”和“重复”两种表象,它们可能共享同一事务断点;绿色路径将幂等消费、可靠发布、租约和对账组合起来。
图片生成记录: model=gpt-image-2,generated=2026-07-15,prompt_version=v1;查看 Prompt。PNG C2PA 记录 softwareAgent=gpt-image、version=2.0。
质检状态: Agent 已核对中文、箭头和事实边界;维护者审图待完成。
3. 事实、影响与指标
| 结论 | 类型 | 证据或验证动作 |
|---|---|---|
| 消息至少一次投递可能重复 | 工程机制 | 在消费后、Ack 前注入进程崩溃 |
PROCESSING 表示任务仍在运行 | 不一定成立 | 必须结合租约、心跳和外部事实 |
| 增加重试次数能恢复卡死 | 不成立 | 可能扩大重复副作用 |
| 本文是实际队列事故 | 不成立 | 没有真实 Broker、任务或日志证据 |
合成记录至少包含 task_id、message_id、attempt、state、lease_owner、lease_version、lease_until、effect_id、last_heartbeat_at、error_class 和 trace_id。
text
stuck_task_age = 当前时间 - 最近一次有效状态迁移时间
duplicate_effect_rate = 同一 task_id 对应多个有效 effect_id 的任务数 / 已产生副作用的任务数
reconciliation_lag = 外部事实形成时间 - 本地状态收敛时间影响域包括生成、支付、通知、媒体处理和数据同步任务;未产生副作用的纯计算任务可以更积极地重试。具体告警阈值取决于任务 SLA 和最长合法执行时间。
相对时间线:T0 消费消息并获取租约;T0+1 外部副作用完成;T0+2 Worker 在状态写入前崩溃;T0+3 租约过期且消息再投递;T0+4 暂停分区并对账;T0+5 修复后重放隔离消息。
4. 技术栈与架构
4.1 技术点清单
| 技术点 ID | 技术点/环节 | 类型 | 采用方案 | 链路职责 | 版本/证据边界 |
|---|---|---|---|---|---|
| TP-01 | 消费幂等 | 存储模式 | Inbox 唯一键 | 同一消息只创建一次业务执行权 | 演练 Schema |
| TP-02 | 可靠发布 | 存储/消息模式 | Transactional Outbox | 状态与待发布事件原子提交 | 演练 Schema |
| TP-03 | 执行所有权 | 状态机 | 版本化 Lease 与 Heartbeat | 判断 Worker 是否仍拥有任务 | 待并发测试 |
| TP-04 | 状态恢复 | 服务 | Reconciler 加 DLQ | 对账外部事实并隔离毒消息 | 待故障注入 |
图:架构|异步任务、消息、租约与外部副作用边界
替代文本: 任务 API 在状态库创建任务和 Outbox,发布器发送到队列;Worker 通过 Inbox 和租约获得执行权后调用外部系统,Reconciler 查询状态库与外部事实,无法自动恢复的消息进入 DLQ。
图表加载中…
读图结论: 队列负责传递,不负责证明业务结果;最终状态必须由任务库和外部事实共同确认。
5. 调用与恢复流程
图:技术调用流程|副作用成功但确认丢失后的对账恢复
替代文本: Worker 通过 Inbox 和租约执行任务,外部副作用成功后 Worker 崩溃;消息再次投递时,新 Worker 不盲目执行,而是检测旧记录并由对账器查询外部结果,存在则收敛成功,不存在才重新执行。
图表加载中…
读图结论: 重复投递是正常传输语义,重复副作用才是必须由幂等与对账阻止的业务错误。
6. 止损、根因与选型
止损顺序:暂停受影响分区而不是清空队列;保存消息和任务快照;阻止旧租约继续写状态;按外部 effect_id 对账;将无法判断的任务隔离到 DLQ;确认安全后小批量重放。
- 直接原因: Worker 在副作用成功后、状态写入和 Ack 前退出。
- 根本原因: 系统把消息消费成功与业务副作用成功混为一谈,缺少对账状态。
- 促成因素: 租约无版本、心跳只更新内存、状态更新不用 CAS、毒消息无限重试。
- 非原因: Broker 重复投递不是缺陷;至少一次语义本就要求消费者幂等。
- 防线失效: 测试没有覆盖进程在关键事务断点崩溃。
| 技术点 ID | 候选方案 | 优点 | 缺点/代价 | 适用场景 | 不适用场景 | 选择结论与依据 |
|---|---|---|---|---|---|---|
| TP-01 | 仅内存去重 | 快 | 重启失效、跨实例无效 | 临时无副作用任务 | 生产副作用 | 不采用 |
| TP-01 | 持久化 Inbox | 可审计、跨实例 | 增加写入 | 至少一次消费 | 极简离线脚本 | 采用 |
| TP-02 | 先写库再直接发消息 | 简单 | 两步间可能丢消息 | 可容忍人工补发 | 核心状态事件 | 不采用 |
| TP-02 | Transactional Outbox | 本地事务一致 | 发布器与清理成本 | 数据库是真值 | 无事务存储 | 采用 |
| TP-03 | 固定超时抢占 | 简单 | 长任务会被误抢 | 执行时长稳定 | AI/媒体长任务 | 不采用 |
| TP-03 | Lease + Heartbeat + CAS | 可判断所有权 | 状态更复杂 | 长时异步任务 | 无共享状态 | 采用 |
| TP-04 | 无限自动重试 | 无人工介入 | 毒消息和副作用放大 | 无 | 高风险任务 | 禁止 |
| TP-04 | Reconciler + 有界重试 + DLQ | 可恢复且可隔离 | 需要运维入口 | 结果可查询或需人工判定 | 完全不可追踪外部动作 | 默认采用 |
7. 验证、防复发与面试表达
验证矩阵:消费前崩溃、外部调用前崩溃、调用成功后崩溃、状态提交后 Ack 丢失、心跳中断、旧 Worker 迟到写入、同消息并发消费、Outbox 重复发布、DLQ 重放。验收必须检查最终业务事实唯一、状态单调迁移、旧租约写入被拒绝和消息可审计。
防复发:任务状态机禁止任意跳转;所有副作用携带 task_id;Inbox/Outbox 表设唯一约束与归档策略;Reconciler 按状态年龄扫描;监控 stuck age、租约冲突、DLQ 增长和对账延迟;Runbook 固定“暂停—快照—对账—CAS 恢复—小批重放”。
难点卡: 最难的是区分“Worker 不在了”和“业务没有完成”,并允许旧 Worker、重投消息与对账器并发时仍只收敛到一个终态。
反模式: 把 PROCESSING 当永久锁;手工把状态改成功却不核对外部事实;删除队列消除积压;用 Exactly Once 宣传语代替业务幂等。
7.1 30 秒面试回答
这是一次异步任务故障演练:Worker 已完成外部副作用,但在写状态和 Ack 前崩溃,消息重投后可能卡死或重复执行。我先暂停分区、隔离消息并按 task_id 对账,根因是消息确认、任务状态和外部事实之间缺少幂等与恢复协议。长期修复采用 Inbox、Outbox、版本租约、对账器和 DLQ,并通过关键断点崩溃与并发重放验证;当前没有真实生产数据。
7.2 60~90 秒与追问
排查时先看数据库状态和租约,再看 Broker 是否重投,最后核对外部 effect_id,不能只根据 UI 的“处理中”判断。至少一次投递允许消息重复,因此 Inbox 防止重复获得执行权;Outbox 解决本地状态和事件发布不一致;Lease 与 CAS 防止旧 Worker 迟到覆盖新结果。无法确认外部事实时进入人工接管,不能继续自动重试。
追问:为什么 Outbox 不能解决外部 API 原子性?租约多长合适?任务不可查询怎么办?DLQ 怎样安全重放?状态机如何防止旧 Worker 覆盖?
8. 总结
一句话记忆: 异步任务的消息状态、执行状态和业务事实必须分别取证再收敛。
- 重复投递不等于重复副作用;
PROCESSING必须有租约、版本和恢复路径;- Inbox、Outbox、Reconciler 与 DLQ 各自解决不同断点;
- 恢复先对账外部事实,再 CAS 更新状态和重放;
- 本文是故障演练,不是已发生事故。