Node.js под нагрузкой: cluster, worker_threads и цена выбора
Один процесс Node.js использует одно ядро. Разбираем, когда спасает cluster, когда worker_threads, а когда ни то ни другое — и чем платите за каждый вариант.

Сервис на Express держит 400 запросов в секунду, пока в обработчик не приходит функция, считающая хеш пароля или парсящая большой JSON. После этого латентность по всем запросам разом уходит с 20 мс в район 800 мс, хотя нагрузка формально та же. Причина в том, что Node.js исполняет ваш JavaScript в одном потоке, и любая синхронная работа на 200 мс блокирует событийный цикл целиком — все клиенты ждут, пока освободится единственный поток.
Node.js не использует несколько ядер сам по себе. На 8-ядерной машине по умолчанию вы задействуете одно ядро. Есть два штатных механизма это исправить: модуль cluster (форк процессов) и worker_threads (потоки внутри процесса). Они решают разные задачи, и путать их дорого. Ниже — что каждый даёт, чем платите и как выбрать. Материал написан для Node.js 20/22 LTS.
Откуда берётся боль
Событийный цикл Node.js однопоточный для вашего кода. I/O (запросы к базе, сети, файлам) уходит в libuv и не блокирует поток — поэтому Node хорошо держит тысячи одновременных соединений. Но как только вы делаете CPU-работу синхронно, поток занят, и цикл не может обработать следующее событие.
Два разных сценария перегрузки:
- Много лёгких запросов, упёрлись в одно ядро. Каждый запрос обрабатывается быстро, но их столько, что одно ядро на 100%, а остальные семь простаивают. Лечится масштабированием на процессы —
cluster. - Отдельные тяжёлые операции блокируют всех. Хеширование, компрессия, парсинг больших payload, обработка изображений. Один такой вызов замораживает event loop. Лечится выносом операции в отдельный поток —
worker_threads.
Прежде чем масштабировать, замерьте, во что упёрлись: в число ядер или в блокировку цикла. Это разные болезни, и лечатся они разными инструментами. Cluster не ускорит одну тяжёлую операцию, а worker_threads не поможет, если у вас просто много быстрых запросов.
cluster: несколько процессов на одном порту
Модуль cluster форкает несколько процессов-воркеров, каждый со своим event loop и своей памятью. Мастер-процесс слушает порт, а входящие соединения распределяются между воркерами (по умолчанию — round-robin на всех платформах, кроме Windows).
// server.js — Node.js 20+
const cluster = require('node:cluster');
const http = require('node:http');
const { availableParallelism } = require('node:os');
if (cluster.isPrimary) {
const workerCount = availableParallelism(); // число доступных ядер
console.log(`мастер ${process.pid}, запускаю ${workerCount} воркеров`);
for (let i = 0; i < workerCount; i++) {
cluster.fork();
}
// если воркер упал — поднимаем новый, чтобы не терять ядро
cluster.on('exit', (worker, code) => {
console.log(`воркер ${worker.process.pid} умер (${code}), рестарт`);
cluster.fork();
});
} else {
http.createServer((req, res) => {
res.end(`ответ от воркера ${process.pid}`);
}).listen(3000);
}Запустите node server.js и постучите в порт несколько раз — в логе увидите разные PID в ответах. Значит, соединения расходятся по процессам.
Чем платите за cluster
- Память умножается. Каждый воркер — отдельный V8-хип. Восемь воркеров по 150 МБ — это 1,2 ГБ на то, что в одном процессе занимало бы 150 МБ.
- Нет общего состояния. Кэш в памяти, счётчики, WebSocket-соединения — у каждого воркера свои. Сессии в памяти работать перестанут: следующий запрос уйдёт на другой воркер и не найдёт сессию. Нужен внешний стор — Redis или база.
- Sticky-сессии. Для WebSocket и long-polling round-robin ломает соединения. Нужна привязка клиента к воркеру по IP на уровне балансировщика.
На практике в проде cluster часто заменяют менеджером процессов (pm2 в режиме cluster) или просто несколькими контейнерами за общим балансировщиком — это снимает с вашего кода заботу о рестартах и мастере.
worker_threads: потоки для CPU-работы
Когда проблема не в числе запросов, а в отдельной тяжёлой операции, форкать весь сервер бессмысленно. worker_threads создаёт потоки внутри одного процесса. Каждый поток — свой V8-изолят и свой event loop, но они живут в общем процессе и могут обмениваться данными эффективнее, чем процессы.
// pool-usage.js — Node.js 20+
const { Worker } = require('node:worker_threads');
// выносим CPU-тяжёлую задачу в отдельный поток
function runHeavyTask(inputData) {
return new Promise((resolve, reject) => {
const worker = new Worker('./heavy-worker.js', {
workerData: inputData,
});
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) reject(new Error(`воркер вышел с кодом ${code}`));
});
});
}
module.exports = { runHeavyTask };// heavy-worker.js
const { workerData, parentPort } = require('node:worker_threads');
const crypto = require('node:crypto');
// синхронная тяжёлая операция — здесь она блокирует ТОЛЬКО этот поток
const result = crypto.pbkdf2Sync(
workerData.password,
workerData.salt,
600000, // число итераций
32,
'sha256'
);
parentPort.postMessage(result.toString('hex'));Главный event loop остаётся свободным: пока поток считает хеш, сервер отвечает на остальные запросы. Проверка: под нагрузкой латентность лёгких запросов не проседает, когда параллельно идут тяжёлые.
Чем платите за worker_threads
- Создание потока не бесплатно. Новый V8-изолят стоит десятки миллисекунд. Создавать поток на каждый запрос — антипаттерн: нужен пул переиспользуемых воркеров.
- Передача данных. По умолчанию объекты копируются (structured clone) между потоком и мастером. Большой payload туда-обратно съедает выигрыш. Для бинарных данных используйте
ArrayBufferс передачей владения (transfer list) илиSharedArrayBuffer— тогда копирования нет. - Сложнее отлаживать. Ошибка в потоке не всплывает в мастер сама — её нужно ловить через события
errorиexit.
Что выбирать
| Признак задачи | cluster | worker_threads |
|---|---|---|
| Много запросов, упёрлись в одно ядро | Да | Нет |
| Отдельные CPU-тяжёлые операции | Нет | Да |
| Нужно масштабировать весь HTTP-сервер | Да | Нет |
| Изоляция памяти между единицами | Полная (разные процессы) | Частичная (общий процесс) |
| Обмен большими бинарными данными | Дорого (IPC) | Дёшево (SharedArrayBuffer) |
| Падение одной единицы роняет остальные | Нет, процессы изолированы | Да, если падает процесс целиком |
Короткий критерий:
- Проблема — пропускная способность (одно ядро под 100%, остальные простаивают):
clusterили несколько контейнеров. - Проблема — латентность из-за блокировок (тяжёлые операции морозят цикл):
worker_threadsс пулом. - Часто нужны оба: cluster масштабирует сервер по ядрам, а внутри каждого воркера пул потоков разгружает тяжёлые операции.
Где это ломается
- Кэш в памяти после cluster. Самая частая регрессия: локальный in-memory кэш перестаёт работать предсказуемо, потому что у каждого воркера он свой. Данные то есть, то нет — в зависимости от того, на какой воркер попал запрос.
- Утечка потоков. Забыли завершить воркер после задачи — потоки копятся, память растёт. В пуле воркеры переиспользуются, а не создаются заново.
- worker_threads не спасёт от асинхронной блокировки. Если тормозит не CPU, а медленный внешний вызов, поток не поможет — проблема в I/O, а не в занятости цикла.
- Мастер как единая точка отказа в cluster. Если падает мастер, порт остаётся без слушателя. Менеджер процессов или оркестратор снимает этот риск.
Что делать дальше
Сначала измерьте, а не масштабируйте вслепую. Полезные шаги:
- Снимите профиль CPU под нагрузкой (
node --profили Clinic.js), найдите синхронные операции длиннее 50 мс. - Посмотрите на задержку event loop — метрику предоставляет
perf_hooks.monitorEventLoopDelay(). Если p99 растёт под нагрузкой, цикл блокируется. - Если упёрлись в одно ядро — сравните пропускную способность одного процесса и cluster на вашем железе под реальным трафиком, а не синтетикой.
- Для тяжёлых операций возьмите готовый пул (например,
piscina) вместо ручного управления жизненным циклом воркеров.
Инвалидация распределённого кэша и настройка sticky-сессий для WebSocket — отдельные темы, здесь мы разбираем только выбор между процессами и потоками.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Node.js Documentation — Cluster, Node.js Documentation — Worker threads
Частые вопросы
У меня Node.js 18, приведённый код заработает?
cluster ускорит одну тяжёлую функцию?
Сколько воркеров форкать в cluster?
Что использовать в проде — cluster или pm2 и контейнеры?
Не сработало: после cluster сессии слетают, что делать?
worker_threads съедает выигрыш на передаче данных — как быть?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.