后台任务与队列:定时抓取、重试、幂等和失败恢复
本文目录
队列把“现在必须完成的请求”与“稍后可以完成的工作”分开,但不会自动提供业务正确性。消息可能重复、乱序、延迟,进程可能在外部副作用成功后、确认消息前崩溃。工程上应默认至少一次投递,并让消费者可安全重复执行,而不是相信“只处理一次”。
先定义任务状态机
一条任务通常经历 queued -> leased/running -> succeeded,失败后进入 retry-wait,达到上限后进入 dead-letter。每次领取任务要有 lease token、租约到期时间和尝试次数;工作超过租约时可续约。确认、续租和失败回写都必须校验当前 token,防止过期 worker 覆盖新 worker 的状态。
任务记录至少包括:稳定的 jobId、类型、经过校验的 payload、幂等键、创建时间、计划时间、状态、attempt、nextRunAt、leaseUntil、错误分类与 trace/correlation ID。不要把大型文件、访问令牌或无期限业务快照塞进队列;存受控的对象 ID,并重新检查当前权限和数据状态。
幂等是重试的前提
数据库副作用可以通过唯一约束、条件更新或幂等记录实现重复安全。外部支付/邮件/API 若支持幂等键,应将稳定业务 ID 传给下游。仅在 worker 内存中记住“做过了”不可靠,重启后会丢失;“副作用成功、队列 ACK 失败”正是最常见的重复窗口。
async function processJob(job, deps) {
const lease = await deps.queue.claim(job.id);
if (!lease) return { skipped: true };
try {
const prior = await deps.results.findByIdempotencyKey(job.idempotencyKey);
if (prior) {
await deps.queue.ack(job.id, lease.token);
return prior;
}
const result = await deps.handler(job.payload, {
signal: lease.abortSignal,
idempotencyKey: job.idempotencyKey,
});
await deps.results.storeOnce(job.idempotencyKey, result);
await deps.queue.ack(job.id, lease.token);
return result;
} catch (error) {
const decision = deps.classify(error);
if (decision.retryable && job.attempt < deps.maxAttempts) {
await deps.queue.retry(job.id, lease.token, {
delayMs: deps.backoffWithJitter(job.attempt),
safeErrorCode: decision.code,
});
} else {
await deps.queue.deadLetter(job.id, lease.token, decision.code);
}
throw error;
}
}
上例只是状态编排示意:如果 handler 的外部副作用成功,而 storeOnce 尚未持久化,重复窗口仍存在。应让下游参与幂等,或让数据库业务更新和幂等记录处于同一事务;两者都不支持时,需要人工对账和补偿,而不能承诺 exactly-once。
可靠地“写数据库并发消息”
先在数据库事务中写业务记录和 outbox 事件,再由独立 publisher 发送事件,成功后标记已发布。Publisher 可重复发送,所以消费者仍要幂等。不要先提交业务数据库,再直接 publish,然后希望两个系统永远不会只成功一个。
定时任务同样要定义去重窗口和唯一业务键。例如日期 + 数据源 + 任务类型形成唯一约束,使定时器重复触发时复用同一个运行记录。多个 worker 抢任务时使用数据库行锁/条件更新或队列的原子 claim 能力;单机 setInterval 不适合作为多副本的分布式调度锁。
重试策略与错误分类
只对临时故障重试:网络超时、429 或部分 5xx 可以按策略重试;输入格式错误、权限拒绝、资源已删除等永久错误应立即终止或转人工修复。指数退避加随机抖动,设置最大间隔、最大尝试次数和总体截止时间。响应 Retry-After 时遵守其含义并仍受业务上限保护。
重试必须有预算。对方服务持续不可用时,开放式重试会放大故障。通过并发上限、每下游隔离队列、熔断、暂停消费和死信比例告警控制影响面。恢复时先修复原因,再限速重放;不要一次性把数小时积压全部冲向下游。
取消、超时与优雅关闭
每个任务应有最大运行时间和取消信号。部署退出时停止领取新任务,允许当前任务在限定时间内完成;到期后释放租约或安全重试。外部调用要有连接/响应超时,长任务通过心跳续租。失联 worker 的租约到期后,其他消费者可以接手,但旧进程恢复时不能再提交未校验租约的写入。
运行指标与故障恢复
监控队列深度、最老任务年龄、入队/完成/失败速率、重试率、死信数、运行时长分位数、租约超时、worker 并发和下游状态。队列深度短暂升高不一定是事故,最老任务持续变老和处理速率低于到达速率才是积压风险。
每种任务准备操作手册:如何暂停消费、查看安全脱敏后的失败原因、修复输入、重试单条、批量重放、取消、补偿以及核对副作用。死信是需要处理的状态,不是“丢到另一个队列就结束”。保存必要审计字段,避免在错误消息里输出个人数据和凭证。
上线检查
- 用进程在每个步骤崩溃的故障注入测试重复投递和租约过期。
- 验证相同幂等键不会重复产生数据库或第三方副作用。
- 暂停 worker、断开 Redis/数据库/下游、制造 429,观察告警和积压变化。
- 测最大 payload、并发、保留期、磁盘/内存容量与队列备份恢复。
- 做一次从死信到修复、限速重放和业务核对的演练。
参考:Prisma transactions and idempotent APIs、Node.js process signals。无论使用定时器、数据库轮询还是消息中间件,均需按当前版本的确认、可见性和重试语义复核。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。