В однопоточной модели Node.js длительные синхронные вычисления (парсинг гигантских JSON, обработка изображений) или тяжелые пакетные I/O-операции (генерация выгрузок в CSV/XLSX, рассылка тысяч вебхуков) категорически недопустимо выполнять в контексте обработки входящего HTTP-запроса. Это приводит к блокировке Event Loop, резкому росту задержек (Event Loop Delay) и отказу в обслуживании новых соединений.
Решением является вынос асинхронной работы в распределенные очереди задач. BullMQ — это промышленная библиотека очередей для Node.js и TypeScript, построенная поверх структур данных Redis. В отличие от традиционных брокеров сообщений (RabbitMQ, Apache Kafka), BullMQ сочетает преимущества высокой скорости вычислений в оперативной памяти Redis и расширенных возможностей планирования: отложенные задачи, cron-расписания, приоритеты, ограничение частоты (Rate Limiting) и построение направленных ациклических графов выполнения (DAG Flows).
BullMQ не использует абстрактную эмуляцию очередей, а опирается на нативные примитивы Redis, управляемые через скомпилированные Lua-скрипты для обеспечения строгой атомарности переходов:
QueueEvents с минимальными накладными расходами.zset): используются для отложенных задач (delayed), периодических задач (repeatable) и задач с приоритетом (prioritized). В качестве ранга (score) выступает временная метка Unix в миллисекундах или числовое значение приоритета.hash): хранят метаданные конкретной задачи (job:id): полезную нагрузку (payload), число выполненных попыток (attemptsMade), трассировку ошибок (stacktrace) и возвращенный результат.list): формируют классические очереди ожидания (wait, paused) и списки завершенных/сбойных задач. Перемещение задачи между списками происходит атомарно через команды RPOPLPUSH / LMOVE.Архитектура системы очередей BullMQ состоит из трех ключевых сущностей: Queue (продюсер), Worker (потребитель) и QueueEvents (слушатель жизненного цикла).
[ HTTP API Handler ]
|
v (queue.add)
+-----------------------+
| BullMQ Queue |
+-----------------------+
|
v (Atomic Lua Script)
+-------------------------------------+
| Redis State |
| - zset: delayed (score: timestamp) |
| - list: wait (FIFO / LIFO) |
| - hash: job metadata |
+-------------------------------------+
|
| Worker acquires lock
v (LMOVE + SET lock NX PX)
+-----------------------+
| BullMQ Worker | <----+ Heartbeat Lock Renewal
| (concurrency: N) | | (lockRenewTime: 15s)
+-----------------------+ |
| |
Is Event Loop blocked? -----+
Worker crashed (OOM)?
|
v Lock Expired (30s)
+-------------------------------------+
| Stalled Job Recovery Loop |
| Moves job back to 'wait' or 'fail' |
+-------------------------------------+Жизненный цикл задачи проходит через детерминированный конечный автомат:
+------------+
| Delayed | (Scheduled timestamp reached)
+------------+
|
v
+------------+ Worker fetches
| Wait | ------------------------+
+------------+ |
^ v
| Retry / Stalled +------------+
+------------------------- | Active |
+------------+
/ \
Success Result / \ Failure / Max Attempts
v v
+-----------+ +-----------+
| Completed | | Failed |
+-----------+ +-----------+Воркер аварийно падает по SIGKILL (OOM) или зависает в синхронном цикле
Истекает таймер lockDuration (30 сек) из-за отсутствия обновлений аренды
Соседний активный воркер сканирует список active и обнаруживает задачу без лока
Инкремент счетчика job.stalledCount. Если лимит maxStalledCount не превышен — возврат в wait
При превышении лимита зависаний задача переносится в failed с событием stalled-limit
Каким образом BullMQ реализует отложенные (delayed) задачи и задачи с приоритетом без непрерывного блокирующего сканирования всей базы Redis?
concurrency под доступные ресурсы CPU хоста.Для построения отказоустойчивой фоновой обработки в Node.js архитектура разделяется на:
lockDuration), контролем параллелизма (concurrency) и изоляцией процессов (SandboxedProcessor).FlowProducer.import { Queue, Worker, QueueEvents, Job } from "bullmq";
import Redis from "ioredis";
export interface PaymentWebhookJobData {
transactionId: string;
amount: number;
currency: string;
source: string;
timestamp: number;
}
export interface PaymentJobResult {
processed: boolean;
ledgerRecordId: string;
}
const redisConnection = new Redis(process.env.REDIS_URL || "redis://127.0.0.1:6379", {
maxRetriesPerRequest: null, // Обязательное требование для работы BullMQ!
enableReadyCheck: false,
});
export const PAYMENT_QUEUE_NAME = "payment-webhooks";
/**
* Инициализация очереди (Продюсер)
*/
export const paymentQueue = new Queue<PaymentWebhookJobData, PaymentJobResult>(
PAYMENT_QUEUE_NAME,
{
connection: redisConnection,
defaultJobOptions: {
attempts: 5, // Максимум 5 попыток при возникновении ошибок
backoff: {
type: "exponential",
delay: 2000, // Начальная пауза 2 сек, затем 4, 8, 16...
},
removeOnComplete: {
age: 86400, // Хранить завершенные задачи в течение 24 часов
count: 5000, // Лимит количества завершенных задач
},
removeOnFail: {
age: 604800, // Хранить упавшие задачи 7 дней для аудита
},
},
}
);
/**
* Инициализация надежного воркера с защитой от Stalled Jobs
*/
export function createPaymentWorker(): Worker<PaymentWebhookJobData, PaymentJobResult> {
const worker = new Worker<PaymentWebhookJobData, PaymentJobResult>(
PAYMENT_QUEUE_NAME,
async (job: Job<PaymentWebhookJobData, PaymentJobResult>): Promise<PaymentJobResult> => {
// Обновление прогресса выполнения для мониторинга
await job.updateProgress(10);
// Имитация интеграции с внутренней финансовой системой
if (job.data.amount <= 0) {
throw new Error(`Invalid transaction amount: ${job.data.amount}`);
}
await job.updateProgress(50);
// Бизнес-логика транзакции
const ledgerRecordId = `led_${job.data.transactionId}`;
await job.updateProgress(100);
return {
processed: true,
ledgerRecordId,
};
},
{
connection: redisConnection,
concurrency: 10, // Одновременная обработка до 10 задач в рамках одного процесса
lockDuration: 30000, // Время удержания распределенной блокировки (30 сек)
lockRenewTime: 10000, // Продление блокировки каждые 10 сек (Watchdog)
maxStalledCount: 2, // Максимум 2 зависания перед окончательным перемещением в Failed
stalledInterval: 30000, // Интервал проверки зависших задач воркером
}
);
// Регистрация критических событий телеметрии
worker.on("completed", (job: Job) => {
console.info(`[Worker] Job ${job.id} completed successfully`);
});
worker.on("failed", (job: Job | undefined, err: Error) => {
console.error(`[Worker] Job ${job?.id} failed on attempt ${job?.attemptsMade}: ${err.message}`);
});
worker.on("error", (err: Error) => {
console.error("[Worker] Global worker error:", err.message);
});
worker.on("stalled", (jobId: string) => {
console.warn(`[Worker] Job ${jobId} was marked as STALLED (lock expired)`);
});
return worker;
}import { FlowProducer, FlowJob } from "bullmq";
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL || "redis://127.0.0.1:6379", {
maxRetriesPerRequest: null,
});
export const flowProducer = new FlowProducer({ connection: redis });
/**
* Создание иерархического конвейера:
* Родительская задача агрегации зависит от параллельного выполнения двух дочерних задач
*/
export async function createReportPipeline(reportId: string, companyId: string) {
const flow: FlowJob = {
name: "aggregate-final-report",
queueName: "report-aggregator",
data: { reportId, companyId },
children: [
{
name: "extract-sales-data",
queueName: "data-extraction",
data: { reportId, source: "sales" },
opts: { attempts: 3 },
},
{
name: "extract-expenses-data",
queueName: "data-extraction",
data: { reportId, source: "expenses" },
opts: { attempts: 3 },
},
],
};
const tree = await flowProducer.add(flow);
return tree.job.id;
}Атомарный Lua-скрипт создает Hash метаданных и помещает ID задачи в список wait или zset delayed.
Воркер атомарно перемещает задачу из wait в active через LMOVE и устанавливает распределенный замок lockDuration.
Каждые 10 секунд фоновый таймер воркера продлевает аренду замка задачи на 30 секунд.
При успехе результат записывается в хэш задачи, задача переносится в completed, а замок снимается.
Если воркер упал по OOM, замок истекает. Соседний воркер фиксирует сбой и отправляет задачу на повтор (Retry).
version: "3.8"
services:
redis-cluster:
image: redis:7.2-alpine
command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy noeviction
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
queue-worker:
build:
context: .
dockerfile: Dockerfile.worker
deploy:
replicas: 4 # Горизонтальное масштабирование на 4 независимых процесса
resources:
limits:
cpus: "1.5"
memory: 1024M
environment:
NODE_ENV: production
REDIS_URL: redis://redis-cluster:6379
WORKER_CONCURRENCY: 10
stop_grace_period: 45s # Предоставление воркеру 45 секунд на завершение текущих задач
restart: on-failure
volumes:
redis_data:Воркер выполняет тяжелую задачу конвертации видеофайла в основном потоке Node.js. Спустя 35 секунд соседний воркер перехватывает ту же задачу и начинает выполнять её параллельно. В чем причина такого поведения и как гарантировать надежность?
Разработчик выполняет синхронный парсинг XML-файла размером 100 МБ через JSON.parse или синхронную криптографию (bcrypt.hashSync) прямо в функции new Worker(...). Главный поток Node.js замирает на 15 секунд. В этот момент фоновый таймер продления замка (lockRenewTime) не может выполниться! Срок действия lockDuration истекает, и другой воркер признает задачу Stalled, перехватывает ее и начинает выполнять параллельно. Решение: использовать SandboxedProcessor (запуск задачи в отдельном дочернем процессе/потоке) либо асинхронные потоковые методы.
По умолчанию ioredis имеет лимит ретраев команд. Если соединение с Redis кратковременно прерывается, BullMQ ожидает, что команды будут буферизироваться до восстановления соединения. При дефолтных настройках ioredis выбрасывает исключение MaxRetriesPerRequestError, что приводит к аварийному завершению воркеров и потере контекста задач. В конфигурации клиента всегда должен быть указан параметр maxRetriesPerRequest: null.
Постановка миллионов задач в очередь без параметров removeOnComplete и removeOnFail. Каждая завершенная задача оставляет свой Hash метаданных в памяти Redis. Через несколько месяцев объем ключей превышает лимит оперативной памяти сервера, и Redis начинает отказывать во всех операциях записи. Всегда настраивайте лимиты по количеству или возрасту записей (age: 86400, count: 5000).
Использование встроенных таймеров JavaScript внутри сервиса, развернутого в нескольких подах Kubernetes. Каждый из 5 подов запускает собственный setInterval и одновременно запускает одну и ту же тяжелую задачу (например, списание подписок). BullMQ Repeatable Jobs регистрирует расписание централизованно в Redis, гарантируя строго единственный запуск регламентного задания в кластере.