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 或自定义客户端。无论使用哪种客户端,目标都相同:
- HTTP 请求中的生产者应快速失败,避免用户请求无限挂起。
- 后台 Worker 应持续重连,在 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. 部署和容量建议
- Web 生产者与 Worker 使用独立进程部署,避免 HTTP 流量和任务 CPU 相互影响。
- Worker 可以水平扩容;CPU 密集型任务结合隔离处理器,并按 CPU 核数压测。
- 发布时先停止接收新流量,再优雅关闭 Worker。
- 对 waiting、active、failed、stalled、Redis 内存与延迟设置监控。
- 配置
removeOnComplete和removeOnFail,防止历史任务无限增长。 - job data 以明文保存在 Redis 中,避免放入密码、令牌、身份证号等敏感数据;必要时只保存引用或加密字段。
- 在预发布环境演练 Redis 启动不可用、处理中断网、Worker 收到 SIGTERM 三种场景。
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
官方列出的常见原因包括:
- CPU 密集代码阻塞事件循环,无法及时续订默认约 30 秒的锁。
- Worker 与 Redis 断开,锁续订失败。
- 任务或整个队列被强制删除。
- Redis 的 maxmemory policy 错误,锁 key 被淘汰。
排查顺序:先查 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 能提前发现部分问题,但不能替代运行时校验。
队列持续积压
依次检查:
- Worker 是否在线并连接正确的 Redis、queue name 和 prefix。
- active 数是否达到并发上限。
- 单任务处理时长是否上升。
- Redis 延迟、CPU、内存和网络是否异常。
- 是否存在大量 delayed、rate limited 或 prioritized 任务。
- 生产速度是否长期高于消费速度。
扩容 Worker 只能解决消费能力不足,不能修复慢 SQL、上游限流或死循环。
9. 上线前检查清单
- Redis 使用
noeviction。 - 已启用并验证持久化与备份。
- 生产者快速失败,Worker 持续重连。
- 所有 Queue、Worker、QueueEvents 都监听
error。 - SIGINT、SIGTERM 能触发优雅关闭。
- 任务具备幂等性和超时边界。
- completed/failed 历史有清理上限。
- job data 不包含不必要的敏感信息。
- Redis Cluster hash tag 已按队列关系设计并测试。
- 已演练 Redis 断线、Worker 崩溃和滚动发布。