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

Типичная картина: сервис на Node.js держит 200 RPS с медианой 15 мс, а на 400 RPS latency p99 улетает с 40 мс до 900 мс, хотя CPU по top показывает 60% и памяти вагон. Виновата не «медленная нода», а то, что вы гоняете весь трафик через один процесс, который умеет использовать ровно одно ядро. Ниже — что именно ломается первым при попытке это масштабировать, в каком порядке и как это поймать до того, как оно поймает вас в проде.
Материал написан для Node.js 18/20/22 (LTS-ветки). Поведение cluster и worker_threads между этими версиями стабильно; отдельные отличия отмечены в тексте.
Почему один процесс упирается раньше, чем кончается железо
Node выполняет ваш JavaScript в одном потоке — том самом, где крутится event loop. Асинхронный I/O уходит в пул libuv и в ядро ОС, но любой синхронный кусок кода (парсинг большого JSON, JSON.stringify на мегабайтном объекте, синхронная криптография, шаблонизация) блокирует event loop целиком. Пока он занят, ни один другой запрос не двигается.
Отсюда первый и главный симптом деградации: растёт задержка event loop, а не CPU. На 8-ядерной машине один процесс Node в лучшем случае утилизирует ~12,5% суммарного CPU под чисто вычислительной нагрузкой. Метрики хоста при этом врут — они показывают «всё хорошо».
Node не медленный. Медленный ваш единственный event loop, который вы заставили обслуживать нагрузку, рассчитанную на восемь.
Мерить надо не CPU хоста, а задержку самого цикла. В Node это делается через perf_hooks:
// Node 18+, работает в любом процессе
const { monitorEventLoopDelay } = require('perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
// задержки в наносекундах, переводим в миллисекунды
console.log({
p50: histogram.percentile(50) / 1e6,
p99: histogram.percentile(99) / 1e6,
max: histogram.max / 1e6,
});
histogram.reset();
}, 5000);Если p99 задержки цикла выше 50–100 мс — процесс перегружен, и никакие добавленные реплики базы это не вылечат. Нужно масштабировать сам Node.
Два инструмента и что они масштабируют на самом деле
В Node два штатных способа задействовать больше одного ядра, и они решают разные задачи. Их постоянно путают.
| Критерий | cluster | worker_threads |
|---|---|---|
| Что создаёт | Отдельные процессы (fork) | Потоки внутри одного процесса |
| Память | Своя куча у каждого, ничего не шарится | Общий процесс, можно шарить через SharedArrayBuffer |
| Цена запуска | Десятки МБ на воркер (свой V8) | Дешевле, но не бесплатно |
| Падение одного | Не роняет остальных | Может уронить весь процесс при утечке/краше нативного кода |
| Для чего | Масштабировать приём HTTP-запросов по ядрам | Выносить тяжёлые CPU-задачи из event loop |
Короткое правило: cluster — чтобы принимать больше запросов, worker_threads — чтобы один запрос не блокировал остальные тяжёлыми вычислениями. Часто нужны оба.
Что ломается первым при cluster
Классический скелет мастера с воркерами по числу ядер:
// Node 18+, cluster.js
const cluster = require('node:cluster');
const http = require('node:http');
const { availableParallelism } = require('node:os');
if (cluster.isPrimary) {
const count = availableParallelism(); // число логических ядер
for (let i = 0; i < count; i++) {
cluster.fork();
}
// перезапускаем упавший воркер
cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died (${signal || code}), restarting`);
cluster.fork();
});
} else {
http.createServer((req, res) => {
res.end('ok');
}).listen(3000);
console.log(`worker ${process.pid} started`);
}availableParallelism() появился в Node 18.14 — на более старых версиях берите os.cpus().length. Код выглядит невинно, но под нагрузкой ломается по очереди в предсказуемых местах.
1. Состояние в памяти воркера
Ломается первым и болезненнее всего. Любой in-memory кеш, счётчик, rate-limiter или сессия, живущие в переменной процесса, теперь существуют в N копиях. Запрос попадает в случайный воркер, и половина обращений видит «пустой» кеш. Rate-limiter на 100 req/min при 8 воркерах превращается в лимит 800 req/min. Сессии в памяти рассыпаются: пользователь то залогинен, то нет.
Лечится выносом состояния наружу — Redis, memcached, база. In-memory кеш допустим только если он допускает рассинхрон между воркерами и вы это осознанно приняли.
2. Распределение соединений неравномерное
На Linux Node по умолчанию использует round-robin в мастере (schedulingPolicy). Но long-lived соединения (WebSocket, keep-alive, SSE) прилипают к воркеру на всё время жизни. Round-robin раздаёт новые соединения, а не запросы. Итог: один воркер держит 5000 висящих WebSocket, остальные простаивают. CPU по хосту низкий, а один процесс задыхается.
3. Утечки не изолируются, а размножаются
Утечка памяти, которая раньше валила один процесс раз в сутки, теперь валит по одному воркеру в разное время. Автоперезапуск в cluster.on('exit') маскирует проблему: сервис вроде жив, но постоянно теряет часть in-flight запросов при рестартах. Без graceful shutdown (перестать принимать, дослать текущие, потом выйти) каждый рестарт — это оборванные ответы.
4. Graceful shutdown, которого нет
При деплое оркестратор шлёт SIGTERM. Наивный воркер умирает мгновенно, обрывая соединения. Минимальный правильный вариант:
// внутри воркера
const server = http.createServer(handler).listen(3000);
process.on('SIGTERM', () => {
// перестаём принимать новые соединения
server.close(() => {
// все текущие ответы досланы
process.exit(0);
});
// страховка: не ждём вечно зависшие keep-alive
setTimeout(() => process.exit(1), 10000).unref();
});Что ломается при worker_threads
Потоки нужны, когда одна операция ест CPU и держит event loop: хеширование паролей пачками, обработка изображений, разбор огромного CSV, тяжёлая сериализация. Вынос в поток разгружает основной цикл.
1. Копирование данных в поток дороже вычисления
Данные между потоками передаются через postMessage с алгоритмом structured clone — то есть копируются. Если вы шлёте в воркер 50 МБ JSON, чтобы посчитать сумму, копирование съест больше, чем сама задача. Обходится через transferList (передача владения ArrayBuffer без копии) или SharedArrayBuffer для разделяемой памяти.
// main.js, Node 18+
const { Worker } = require('node:worker_threads');
const buffer = new ArrayBuffer(50 * 1024 * 1024);
const worker = new Worker('./process.js');
// передаём владение буфером — без копирования
// после этого buffer в main становится непригодным
worker.postMessage({ buffer }, [buffer]);2. Создание потока на каждый запрос убивает смысл
Запуск воркера — это новый экземпляр V8, десятки миллисекунд и мегабайты. Плодить поток на каждый HTTP-запрос — антипаттерн: под нагрузкой вы получите тысячи потоков и OOM. Нужен пул фиксированного размера с очередью задач. Писать пул руками не обязательно — есть piscina, покрывающая ровно этот сценарий; проверяйте актуальность API по её документации перед использованием.
3. Один тяжёлый воркер не спасает от очереди
Если задач больше, чем потоков в пуле, они встают в очередь. Latency отдельного запроса складывается из времени ожидания в очереди плюс времени счёта. При недостаточном размере пула вы просто перенесли затор из event loop в очередь пула — p99 не улучшится. Размер пула подбирается замером, стартовая точка — число физических ядер минус те, что заняты приёмом HTTP.
Как выбирать между ними
- Нагрузка — это много I/O-запросов (API, проксирование, работа с базой): берите
clusterпо числу ядер, состояние выносите наружу. worker_threads тут не нужны. - Есть отдельные тяжёлые CPU-операции внутри в остальном лёгких запросов: основной процесс на
clusterплюс пулworker_threadsпод конкретную операцию. - Один процесс на одном ядре тянет нагрузку: не усложняйте. Кластер добавляет проблемы с состоянием и деплоем — включайте его, когда упёрлись в event loop, а не заранее.
Что мерить и что делать дальше
Не масштабируйте вслепую. Сначала снимите три метрики под реальным профилем нагрузки, затем принимайте решение:
- Задержка event loop (p50/p99) через
monitorEventLoopDelay— главный индикатор перегрузки процесса. - Утилизация event loop через
performance.eventLoopUtilization()изperf_hooks: значение около 1.0 означает, что цикл не простаивает — процесс упёрся в потолок. - Распределение соединений по воркерам — если оно перекошено, round-robin вам не помогает, ищите long-lived соединения.
Дальше: настройте graceful shutdown до первого продакшн-деплоя, вынесите всё разделяемое состояние в Redis или базу, а вместо самописного пула потоков посмотрите готовое решение. Прогон нагрузочного теста (autocannon, k6) с фиксацией p99 до и после кластеризации на том же железе покажет, дала ли она эффект или вы просто размножили проблему на N процессов.
Инвалидация распределённого кеша и sticky-сессии для WebSocket за балансировщиком — отдельные темы, здесь рассмотрен только выбор и границы применимости самих инструментов масштабирования.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Node.js Documentation — Cluster, Node.js Documentation — Worker threads
Частые вопросы
У меня Node 16, availableParallelism не работает — что делать?
Я включил cluster, но latency p99 не улучшилась — почему?
Можно ли шарить in-memory кеш между воркерами cluster?
Сколько воркеров cluster создавать?
worker_threads уронил весь процесс — как изолировать?
Нужен ли мне PM2 или встроенного cluster достаточно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.