В однопотоковій моделі виконання Node.js виконання тривалих синхронних обчислень (парсинг багатогігабайтних файлів JSON, обробка зображень чи відео) або важких пакетних операцій введення-виведення (генерація звітів на 500 000 рядків у 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 ]
|
v (queue.add)
+-----------------------+
| BullMQ Queue |
+-----------------------+
|
v (Атомарний Lua-скрипт)
+-------------------------------------+
| Стан Redis |
| - zset: delayed (score: timestamp) |
| - list: wait (FIFO / LIFO) |
| - hash: job metadata |
+-------------------------------------+
|
| Воркер захоплює замок
v (LMOVE + SET lock NX PX)
+-----------------------+
| BullMQ Worker | <----+ Продовження замка (Watchdog)
| (concurrency: N) | | (lockRenewTime: 15s)
+-----------------------+ |
| |
Event Loop заблоковано? ----+
Воркер аварійно впав (OOM)?
|
v Замок минув (30s)
+-------------------------------------+
| Цикл відновлення Stalled |
| Повертає задачу у 'wait' чи 'fail' |
+-------------------------------------+Кожне завдання проходить через детермінований скінченний автомат протягом свого життєвого циклу:
+------------+
| Delayed | (Настав запланований час)
+------------+
|
v
+------------+ Воркер отримує задачу
| Wait | ------------------------+
+------------+ |
^ v
| Ретрай / Stalled +------------+
+------------------------- | Active |
+------------+
/ \
Успішний фінал / \ Збій / Вичерпано спроби
v v
+-----------+ +-----------+
| Completed | | Failed |
+-----------+ +-----------+concurrency відповідно до ядер процесора.Надійна фонова обробка в Node.js організовується на трьох рівнях:
lockDuration), контролем паралелізму (concurrency) та ізоляцією виконання.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, // Зберігати виконані завдання у Redis 24 години
count: 5000, // Ліміт до 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 секунд фоновий таймер виконує Lua PEXPIRE для продовження оренди замка на 30 секунд.
У разі успіху результат записується в хэш завдання, воно переходить у completed, а замок знімається.
Якщо воркер аварійно впав, строк замка минає. Сусідній воркер фіксує збій і повертає завдання на повтор.
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 процеси Node.js
resources:
limits:
cpus: "1.5"
memory: 1024M
environment:
NODE_ENV: production
REDIS_URL: redis://redis-cluster:6379
WORKER_CONCURRENCY: 10
stop_grace_period: 45s # Час на коректне завершення поточних активних завдань
restart: on-failure
volumes:
redis_data:Виконання важких синхронних розрахунків (наприклад, парсинг XML на 100 МБ через JSON.parse чи виклик bcrypt.hashSync) прямо всередині обробника new Worker(...). Головний потік зависає на 15 секунд. За цей час фоновий таймер автоподовження замка (lockRenewTime) не може виконатися! Строк дії lockDuration закінчується, сусідній воркер позначає завдання як Stalled, перехоплює його і починає виконувати паралельно. Рішення: використовувати SandboxedProcessor (виконання в окремому дочірньому процесі або Worker Thread) чи асинхронні потокові методи.
За замовчуванням ioredis має ліміт повторних спроб виконання команд. У разі короткочасного розриву зв'язку з Redis бібліотека BullMQ вимагає буферизації команд до відновлення зв'язку. При стандартних налаштуваннях ioredis викидає фатальну помилку MaxRetriesPerRequestError, що призводить до падіння процесу воркера та втрати контексту завдань. Завжди вказуйте maxRetriesPerRequest: null.
Постановка мільйонів завдань у чергу без вказання removeOnComplete та removeOnFail. Кожне завершене завдання залишає свій Hash метаданих у пам'яті Redis. З часом мільйони записів вичерпують виділену оперативну пам'ять, і Redis починає відхиляти операції запису з помилкою OOM. Завжди налаштовуйте ліміти зберігання (age: 86400, count: 5000).
Використання таймерів JavaScript усередині сервісу, що масштабується у 5 подах Kubernetes. Кожен із 5 подів запускає власний setInterval, виконуючи одну й ту саму операцію (наприклад, списання коштів за підпискою) багаторазово. Механізм Repeatable Jobs у BullMQ зберігає розклад централізовано в Redis, гарантуючи строго одноразове виконання регламентного завдання в кластері.
Воркер аварійно падає через OOM або зависає в синхронному коді
Спливає час оренди замка lockDuration (30 сек) через відсутність оновлень
Сусідній активний воркер сканує список active і виявляє завдання без замка
Інкрементується job.stalledCount; якщо ліміт не перевищено — повернення у wait
У разі перевищення ліміту зависань завдання переходить у статус failed