后台任务队列设计 是做什么的?
一个后台任务系统从一开始就必须针对三种故障模式进行设计:任务丢失、重复执行和无限重试。选错了基础组件,接下来的一年你都在修复这三个问题。无论是 BullMQ、Sidekiq、SQS 还是 Temporal 的文档,业界反复达成的共识是:至少一次交付是唯一诚实的语义——设计每个处理器保持幂等,停止追逐精确一次。(Caduh — Queues 101)
根据你的问题跳转:
- 任务在重试时跑了两次 → 幂等性
- Worker 在处理任务时崩溃,任务消失 → 可靠拉取和可见性超时
- 死信队列(DLQ)堆积,告警触发 → 错误分类 + 死信队列
- Redis 在积压期间 OOM → 内存管理
- “是否应该用 Temporal 替代?” → 队列 vs 工作流
- 重试风暴冲击下游 → 退避 + 抖动
- 并发超过速率限制 → 并发 + 限流器
适用场景
- 在 Node、Ruby、Python 或 Go 中设计新的后台任务系统。
- 从
setTimeout/cron/ 自建队列迁移到真正的消息代理。 - Worker 崩溃导致任务丢失。
- 生产环境的重试导致重复的副作用。
- 在 BullMQ(Node + Redis)、Sidekiq(Ruby + Redis)、RQ / Celery / Dramatiq(Python)、Asynq(Go + Redis)、SQS(托管)或 Temporal(持久化执行)之间做选择。
核心能力
选择与工作匹配的基础组件
| 你的场景 | 选择 | 理由 |
|---|---|---|
| 简短、有副作用的任务(发送邮件、调整图片大小)在 Node 上 | BullMQ | 原生 Redis 支持、成熟的 DLQ、流程模式,典型吞吐量 ~5k 任务/秒/节点 |
| 同上,在 Ruby 上 | Sidekiq(Pro 版以获得可靠性) | 相同结构;Pro 版的 super_fetch 通过 LMOVE 实现可靠拉取(Sidekiq 维基) |
| 多步骤工作流,涉及外部 API 扇出、持续数小时到数天的任务 | Temporal | 持久化执行;从事件历史重放(Temporal 文档) |
| AWS 原生、简单扇出、不愿管理 Redis | SQS + Lambda | 托管式至少一次交付;可见性超时和 DLQ 是头等公民(AWS SQS DLQ) |
| 跨区域、多租户隔离 | Temporal 或 SQS | 单个 Redis 无法干净地支持多区域 |
| 在数据库、邮件和支付系统中都需要精确一次 | 都不选——设计为至少一次 + 幂等处理器 | 代理内部的“精确一次”无法扩展到跨系统场景(Caduh) |
隐藏的判断标准:恢复故事的复杂程度如何? 简单的 BullMQ 队列加重试足以处理“发送收据”。一个 12 步骤的购买工作流,需要等待 24 小时等待 webhook,这是 Temporal 工作流,而不是 12 个链式队列任务。
幂等性
这是强制性属性。每个流行队列的文档都以它开头。BullMQ 的幂等任务页面指出:“无论任务在第一次尝试时成功完成,还是最初失败后在重试时成功,都不应对系统的最终状态产生影响。” (BullMQ idempotent-jobs)
实用模式:
// 1. 幂等键来源于业务事件,而非队列。
// 如果有上游事件 ID(如 Stripe event.id、Shopify hook id),请使用它。
const job = await queue.add(
'send-receipt',
{ orderId, amount },
{
jobId: `send-receipt:${orderId}`, // BullMQ 在队列中通过 jobId 去重。
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 86400 },
}
);
// 2. 在处理器内部,同时也在副作用现场进行去重。
async function processSendReceipt(job) {
const { orderId, amount } = job.data;
// 如果已发送,则不做任何事。
const sent = await db.queryOne(
'INSERT INTO email_sends (idempotency_key, order_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id',
[`send-receipt:${orderId}`, orderId]
);
if (!sent) return { skipped: 'already-sent' };
await emailProvider.send({ to: ..., subject: ..., body: render(amount) });
}
jobId 在重复任务仍在队列中时进行去重。数据库唯一约束在 worker 崩溃、重试和重放时进行去重。两者都是必需的;仅靠 jobId 是不够的,因为 BullMQ 会移除已完成的任务,去重的窗口期会消失。
可靠拉取和可见性超时
当一个 worker 拉取任务后崩溃,会发生什么?
| 系统 | 默认行为 | 恢复 |
|---|---|---|
| BullMQ | 任务有一个 lockDuration(默认 30s)。挂起任务检查器会在超时后重新入队(BullMQ production) | Worker 必须延长锁或在 lockDuration 内完成;否则任务会运行两次 |
| Sidekiq OSS | Pop 操作是非原子的;如果 worker 在 pop 和 process 之间死亡,任务会丢失 | 升级到 Sidekiq Pro 的 super_fetch(Redis 6.2+ 使用 LMOVE 到私有工作队列) |
| Sidekiq Pro super_fetch | LMOVE 到每个进程的工作列表。心跳在 60s 后过期;孤儿检查会扫描并重新入队(Sidekiq wiki) | 内置 |
| SQS | 可见性超时(默认 30s,最长 12h)。如果未删除,消息在超时后重新出现 | 将超时设置为大于处理器 p99 延迟;对长任务使用 ChangeMessageVisibility 延长 |
| Temporal | 活动有心跳和重试策略。工作流历史在 worker 死亡后仍然存在 | 从历史重放;你无需考虑这个问题 |
陷阱:lockDuration / 可见性超时短于处理器 p99 → 任务运行两次。长于可接受的恢复时间 → 崩溃的工作等待时间过长。先测量你的 p99,将超时设置为大约 3 倍,并对真正长时间运行的任务让处理器发送心跳/延长锁。
错误分类和死信队列
并非所有错误都应该无限重试。SQS 文档指出:“设置最大接收次数...以及将消息发送到 DLQ 的重驱策略(一旦超过阈值)。” (AWS SQS DLQ)
// 在抛出之前进行分类。永久性错误不应重试。
class PermanentError extends Error { constructor(m: string) { super(m); this.name = 'PermanentError'; } }
class TransientError extends Error { constructor(m: string) { super(m); this.name = 'TransientError'; } }
async function process(job) {
try {
const user = await db.user(job.data.userId);
if (!user) throw new PermanentError('user-not-found'); // → 第一次尝试就进 DLQ
if (user.suspended) throw new PermanentError('user-suspended');
await externalApi.call(user);
} catch (e) {
if (e instanceof PermanentError) {
// 立即进入 DLQ——重试无济于事。
job.discard(); // BullMQ:阻止重试
throw e;
}
throw e; // 网络/超时 → 按尝试策略重试
}
}
DLQ 还需要:
- 对增长数量的告警(不仅仅是非零——“DLQ 永远有 3 件事”是正常的;“DLQ 每分钟增长 100”是个火灾)。
- 一个重放工具(CLI 或管理 UI),以便运维人员可以修复上游 Bug,重放已进入死信的任务并清理 DLQ。
- 仪表盘(参见
grafana-dashboard-builder)。
退避和抖动
仅指数退避是不够的。如果 1000 个任务都在 12:00:00 因为下游服务短暂故障而失败,简单的指数退避会让所有 1000 个任务在 12:00:01、12:00:03、12:00:07 精确地重试——这会产生惊群效应,可能导致下游服务持续宕机。添加完全抖动:
// AWS 风格的“完全抖动”:delay = random(0, exp_backoff)
const baseDelay = Math.min(2 ** attempt * 1000, 30_000);
const delay = Math.random() * baseDelay;
对于 BullMQ 推荐的基线,going-to-production 文档建议使用 retryStrategy 设置 1s 到 20s 之间的重试间隔。(BullMQ production)
并发和限流器
每个 worker 的并发数允许从一个进程中并行运行 N 个任务。多个 worker 会倍增这个数字。如果没有队列级别的限流器,你无法限制对下游的聚合调用:
// BullMQ — 此队列上所有 worker 的全局速率限制。
const queue = new Queue('outbound-emails', {
connection,
limiter: { max: 100, duration: 1000 }, // 整个集群每秒 100 封邮件
});
正确的形式:许多并发的 worker + 一个用于下游合约限制的队列限流器。Sidekiq 有等效的速率限制器;SQS 使用 Lambda 并发或每消息速率。
内存管理
BullMQ 生产环境最强调的一个陷阱:Redis 实例上的 maxmemory-policy noeviction。任何其他策略(allkeys-lru、volatile-lru)都会在压力下静默地驱逐排队中的任务。(BullMQ production)
此外:
removeOnComplete设置激进(例如{ age: 3600, count: 1000 })。- 一旦切换到 DLQ 模式,
removeOnFail设置得更激进。 - 启用 Redis AOF 持久化——
appendfsync everysec是 BullMQ 文档中提到的推荐配置。(BullMQ production) - Worker 设置
maxRetriesPerRequest: null以防止 ioredis 在瞬时断开连接时抛出异常。(BullMQ production) - 队列端设置
enableOfflineQueue: false,使生产者快速失败而不是缓冲。
优雅关闭
process.on('SIGTERM', async () => {
await worker.close(); // 停止接收新任务;完成正在处理的任务。
await connection.quit();
process.exit(0);
});
process.on('SIGINT', async () => { /* 相同 */ });
BullMQ 生产指南明确指出:在停止进程之前关闭 worker;默认挂起超时约为 30 秒。(BullMQ production)
队列 vs 工作流决策
Temporal 文档中一个有用的框架:“与在服务之间传递数据的消息队列不同,Temporal 编排整个流程……知道你在工作流中的位置、哪些已完成、哪些待办、哪些需要重试。” (Temporal blog)
flowchart TD
A[后台工作] --> B{单个副作用还是管道?}
B -->|单个| C[BullMQ / Sidekiq / SQS]
B -->|多步骤| D{步骤持续秒级还是小时级以上?}
D -->|秒级| E[BullMQ flows / Sidekiq batches]
D -->|小时级以上或人工介入| F[Temporal / DBOS / Restate]
C --> G{需要跨区域或多租户隔离?}
G -->|是| H[SQS 或 Temporal — Redis 单集群无法胜任]
G -->|否| I[使用 Redis 集群 + AOF + noeviction]
反模式
仅通过 Redis SET-NX 实现幂等性
症状: 在 Redis 短暂故障或重启期间,同一个任务运行两次并产生重复的副作用(扣费、邮件、工单)。
诊断: Redis 是一个缓存;它可能被驱逐、重启或分区。仅存在于 Redis 中的 SET-NX 去重是尽力而为的。
修复: 在副作用记录(例如 email_sends.idempotency_key)上使用带数据库唯一约束的幂等键。创建副作用的数据库事务本身就是去重的基础。
lockDuration 短于处理器 p99
症状: 出现挂起任务日志条目;同一个任务被两个 worker 处理。客户投诉。
诊断: 默认的 lockDuration 是 30s;处理器有时需要 45s;第二个 worker 拾取了它。
修复: 测量 p99,将 lockDuration 设置为大约 3 倍 p99,并在长时间运行的处理函数内部调用 job.extendLock()。或者拆分工作。
纯指数退避(无抖动)
症状: 下游短暂故障变成下游中断;1 万个任务在锁步中冲击正在恢复的服务。 诊断: 没有抖动,重试是相关的。 修复: 完全抖动退避。限制最大延迟以限定 DLQ 的超时时间。
Redis 上设置 maxmemory-policy allkeys-lru
症状: 队列在负载下神秘地丢失任务。没有错误。(BullMQ production)
诊断: Redis 驱逐了任务键以腾出空间。
修复: 设置 CONFIG SET maxmemory-policy noeviction 并在启动时断言。Worker 应拒绝启动,否则拒绝启动。
假装跨系统精确一次
症状: 向客户重复扣费;发送了两封相同的邮件。工程师确信“消息代理是精确一次的”。 诊断: 即使是 FIFO/精确一次的代理也不会将其保证扩展到你的数据库、邮件供应商、Stripe 和分类账。 修复: 将队列视为至少一次交付。在每个副作用边界实现幂等性。
死信队列无重放工具
症状: “DLQ 中有 3200 个任务。我们不知道里面有什么。” DLQ 变成了无人监控的墓地。 诊断: 建了 DLQ,但没建运维工具。 修复: 按 ID 重放、按时间范围重放、确认后清空。CLI 或管理 UI。经过测试。
长时间阻塞事件循环的工作
症状: BullMQ worker 进程挂起;心跳停止;锁超时;任务重新运行。
诊断: 与队列心跳在同一进程中执行 CPU 密集型工作;事件循环阻塞超过 lockDuration。
修复: 为 CPU 密集型工作生成子进程或工作线程。或者运行沙箱处理器(BullMQ 支持每任务进程沙箱)。
质量门控
- 测试: 混沌测试——在任务处理过程中杀死 worker;断言任务重新运行且副作用未重复。
- 测试: 重试风暴测试——让下游服务故障持续 60s;断言退避和抖动使重试分散(指标中无惊群效应)。
- 每个处理器都是幂等的,并使用从业务事件派生的稳定幂等键。
- 数据库级别的唯一约束支持任何 Redis 内的去重(
jobId、SET-NX)。 -
lockDuration/ 可见性超时 ≥ 3 倍测量的处理器 p99 延迟,并且长时间运行的处理程序会延长锁。 - 永久性错误(
PermanentError类或等效)绕过重试直接进入 DLQ。 - DLQ 告警:当增长率超过每分钟 N 条时触发告警,而不仅仅是计数 > 0。
- DLQ 重放工具存在且经过测试(单次和范围重放)。
- Redis:
maxmemory-policy=noeviction,启用 AOF(appendfsync everysec),并在 worker 启动时断言。(BullMQ production) -
removeOnComplete和removeOnFail已配置。在长时间内追踪任务计数指标。