17 - 生产部署与故障排查

开发环境中能够添加和处理任务,只说明基本代码可运行。生产环境还必须考虑 Redis 数据是否会丢、连接中断如何恢复、发布时如何停止 Worker,以及异常积压时怎样定位原因。

1. Redis 不是普通缓存:必须使用 noeviction

Redis 常被当作缓存使用,达到内存上限后可按策略淘汰 key。但 BullMQ 的任务、锁、延迟集合和事件流共同组成队列状态,任意 key 被淘汰都可能破坏一致性。

官方要求:BullMQ 使用的 Redis 实例应配置:

maxmemory-policy noeviction

可通过 Redis CLI 检查:

redis-cli CONFIG GET maxmemory-policy

不要让 BullMQ 与允许淘汰 key 的普通缓存共用同一个 Redis 实例。noeviction 并不解决内存不足;达到上限后写入会失败,因此还要监控内存并设置任务自动清理策略。

2. 配置 Redis 持久化

BullMQ 官方生产指南推荐启用 AOF,通常每秒同步一次能在可靠性和性能间取得较实用的平衡:

appendonly yes
appendfsync everysec

持久化会影响性能,应针对真实任务量压测。还应确认托管 Redis 的持久化、备份和故障转移配置确实开启,而不是只看服务名称里是否有“高可用”。

需要先明确业务可接受的数据损失窗口。everysec 在极端故障时仍可能损失最近约一秒写入;对绝不能丢的业务,可配合数据库 outbox 等更强的业务持久化模式。

3. 生产者与 Worker 的连接策略不同

BullMQ 默认使用 ioredis,也支持通过适配器使用 node-redis、Bun Redis 或自定义客户端。无论使用哪种客户端,目标都相同:

使用 ioredis 时,Worker 连接的 maxRetriesPerRequest 应为 null;BullMQ 内部创建 Worker 连接时会使用这一要求。如果自己传入 ioredis 实例,也必须正确配置。Queue 生产者则通常设置有限重试,并按接口超时策略失败。

import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';

const producerConnection = new IORedis({
  host: '127.0.0.1',
  port: 6379,
  enableOfflineQueue: false,
  maxRetriesPerRequest: 1,
});

const workerConnection = new IORedis({
  host: '127.0.0.1',
  port: 6379,
  maxRetriesPerRequest: null,
});

const queue = new Queue('reports', { connection: producerConnection });
const worker = new Worker(
  'reports',
  async (): Promise<void> => {
    // 处理任务
  },
  { connection: workerConnection },
);

Worker 和 QueueEvents 使用阻塞 Redis 命令,会在内部创建 duplicate connection。估算部署连接数时,不能只按对象数量简单计算为一条。

如果使用 node-redis 适配器,应在原始客户端配置等价的 reconnect、连接超时和命令超时策略。不要照搬 ioredis 特有选项。

4. 监听错误,避免静默故障

queue.on('error', (error: Error) => {
  console.error('Queue Redis 错误', error);
});

worker.on('error', (error: Error) => {
  console.error('Worker Redis 或内部错误', error);
});

worker.on('failed', (job, error: Error) => {
  console.error(`任务 ${job?.id ?? 'unknown'} 失败`, error);
});

worker.on('stalled', (jobId: string) => {
  console.warn(`任务 ${jobId} stalled`);
});

failed 通常表示业务处理函数失败;error 可能是连接或 Worker 基础设施错误;stalled 表示任务锁未正常续订。它们不应混成同一种告警。

5. 优雅关闭 Worker

worker.close() 会停止领取新任务,并等待当前任务完成或失败。它本身没有自动超时,因此每个任务仍应有合理的取消和超时机制。

type ShutdownSignal = 'SIGINT' | 'SIGTERM';

let isShuttingDown = false;

async function gracefulShutdown(signal: ShutdownSignal): Promise<void> {
  if (isShuttingDown) {
    return;
  }

  isShuttingDown = true;
  console.log(`收到 ${signal},开始关闭`);

  try {
    await worker.close();
    await queue.close();
    await producerConnection.quit();
    await workerConnection.quit();
  } catch (error: unknown) {
    console.error('关闭失败', error);
    process.exitCode = 1;
  }
}

process.on('SIGINT', () => {
  void gracefulShutdown('SIGINT');
});

process.on('SIGTERM', () => {
  void gracefulShutdown('SIGTERM');
});

容器或进程管理器的 termination grace period 必须大于正常任务完成和资源清理所需时间。如果进程被强制杀死,BullMQ 的 stalled 机制会让其他 Worker 重新处理任务,所以任务仍必须幂等。

BullMQ 2.0 及以后不再需要为了 stalled job 单独运行 QueueScheduler。不要从旧教程机械复制这一组件。

6. 部署和容量建议

7. Redis Cluster 与 hash tags

BullMQ 的原子操作可能同时访问多个 key,而 Redis Cluster 要求这些 key 位于同一 hash slot。官方 pattern 建议在 prefix 或队列名中使用花括号 hash tag:

import { Queue } from 'bullmq';

const queue = new Queue('reports', {
  connection: { host: '127.0.0.1', port: 6379 },
  prefix: '{reports}',
});

也可以把 hash tag 放在队列名中:

const queue = new Queue('{reports}', {
  connection: { host: '127.0.0.1', port: 6379 },
});

独立队列使用不同 hash tag 可以分散到不同节点;但 Flow 或跨队列原子操作涉及的 key 必须满足同槽要求。选择“分散负载”还是“跨队列原子性”前,要先画清队列之间的关系,并在真实 Cluster 环境验证,不能只在单节点 Redis 上测试。

8. 常见故障排查

Missing lock for job

常见错误形式:

Missing lock for job 1234. moveToFinished

官方列出的常见原因包括:

排查顺序:先查 Redis 连接和 maxmemory-policy,再查事件循环阻塞、任务耗时和是否调用了删除 API。CPU 密集代码应迁移到 sandboxed processor,而不是先盲目增大锁时间。

Lua redis() command arguments must be strings or integers

常见原因是环境变量为 undefined、空字符串或类型不正确,却被直接用于 queue name、prefix 或任务参数。

const queueName = process.env.QUEUE_NAME;

if (!queueName) {
  throw new Error('QUEUE_NAME 未配置或为空');
}

const validatedQueue = new Queue(queueName, {
  connection: { host: '127.0.0.1', port: 6379 },
});

配置应在进程启动时一次性校验并快速失败。TypeScript 的 strictNullChecks 能提前发现部分问题,但不能替代运行时校验。

队列持续积压

依次检查:

  1. Worker 是否在线并连接正确的 Redis、queue name 和 prefix。
  2. active 数是否达到并发上限。
  3. 单任务处理时长是否上升。
  4. Redis 延迟、CPU、内存和网络是否异常。
  5. 是否存在大量 delayed、rate limited 或 prioritized 任务。
  6. 生产速度是否长期高于消费速度。

扩容 Worker 只能解决消费能力不足,不能修复慢 SQL、上游限流或死循环。

9. 上线前检查清单

官方资料