将构建、同步和通知放进队列后,接口响应变快了,新的问题也随之出现:进程重启时任务是否丢失,同一个任务会不会执行两次,外部接口超时后该不该重试,失败到什么程度需要人工处理。

队列库能提供投递和消费机制,却无法替业务回答这些问题。

异步任务从创建、排队、运行到成功、重试、失败和人工恢复的状态机

图 1:队列里的消息可以重复,业务任务状态必须持久、幂等,并且知道自动化何时停止。

一,任务能否重复执行

网络超时不代表对方没有成功。消费者重试前,必须假设上一次可能已经完成。为任务建立业务幂等键,并在执行前检查已有结果,可以避免重复创建发布或通知。

幂等键应来自业务语义,而不是每次请求随机生成。例如同一提交向同一环境发布,可以使用 projectId + commitSha + environment。数据库先以唯一约束占住事实,再投递队列:

create unique index uniq_release_request
on release_job(project_id, commit_sha, environment);
const job = await db.releaseJob.upsert({
  where: { projectId_commitSha_environment: input },
  create: { ...input, status: "queued", attempt: 0 },
  update: {},
});
await queue.publish({ jobId: job.id });

队列偶尔重复投递没有关系,消费者读取同一任务并检查状态。真正危险的是先发布消息、后写数据库:进程在两步之间退出,队列里就会出现查不到业务事实的任务。更严格的场景应使用 Outbox,把业务写入和待发送消息放在同一事务。

二,状态是否可追踪

只保存“成功/失败”不足以恢复。任务要记录输入摘要、当前步骤、尝试次数、外部标识和最后错误。队列中的瞬时状态还应同步到持久存储,避免任务丢失后没有证据。

三,错误是否适合重试

超时和临时限流可以指数退避重试,参数错误和权限拒绝则不会因等待而消失。错误分类不清,会制造无意义流量,甚至把小故障放大。

错误类型 示例 策略
临时错误 网络中断、服务 503 指数退避并加入随机抖动
限流 HTTP 429 优先遵循 Retry-After
永久错误 参数非法、资源不存在 立即失败,不重试
授权错误 Token 过期、权限撤销 尝试刷新一次,否则人工处理
结果未知 写请求超时 先按幂等键查询,再决定是否重试

重试代码最重要的不是公式,而是上限和分类:

const delayMs = Math.min(60_000, 1_000 * 2 ** attempt) * (0.8 + Math.random() * 0.4);

没有抖动时,大量失败任务会在同一秒醒来,再次把下游压垮。退避是保护依赖,不是让任务看起来更努力。

四,自动化在哪里停止

超过重试上限的任务要进入明确的失败队列,并提供查看、修正和重新执行入口。可靠系统不是永不失败,而是失败后仍然知道发生了什么、下一步由谁处理。

顺序与并发也要写进语义

同一工程的两次发布任务可能同时进入队列。如果后一个先完成,状态就会倒退。需要按业务键限制并发,或者让更新携带版本条件,只允许从合法前置状态推进。

消费者数量增加会提高吞吐,也会放大数据库和外部接口压力。扩容依据不应只是队列长度,还要观察任务等待时间、执行时间与下游限额。队列把峰值摊开,不代表下游容量已经增加。

异步架构把时间从请求链路中移走,也把一致性问题暴露出来。只有任务拥有可重复、可观察、可恢复的生命周期,队列才真正提高可靠性。

每类任务都保留一份故障回放包

回放包包含脱敏输入、状态转移、每次 attempt、租约变化、外部回执、最终产物和代码版本。事故后能在隔离环境重放到某个检查点,而不是只看散落日志。

我们用它验收三件事:重复投递不会产生重复副作用;Worker 中途退出任务能被接管;结果未知不会被当成失败自动重试。可靠性只有能够重复演示失败和恢复,才真正进入工程资产。