Один процесс Node выполняет JavaScript на одном ядре. Сервер с 8 ядрами при одном процессе использует для JS одно. Ещё одна проблема: тяжёлое вычисление блокирует event loop для всех. Решения разные, и их часто путают.
Три инструмента
| Задача | Инструмент | Что это |
|---|---|---|
| Загрузить все ядра одной машины под HTTP | cluster или несколько процессов | отдельные процессы с общим портом |
| Вынести CPU-работу из event loop | worker_threads | потоки внутри одного процесса |
| Выдержать нагрузку больше одной машины | горизонтальное масштабирование | много экземпляров за балансировщиком |
cluster
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
import http from 'node:http';
if (cluster.isPrimary) {
for (let i = 0; i < availableParallelism(); i++) cluster.fork();
cluster.on('exit', () => cluster.fork()); // упал: поднимаем новый
} else {
http.createServer(handler).listen(3000);
}
Главный процесс запускает воркеры, все слушают один порт. Входящие соединения главный процесс раздаёт по кругу (round-robin, по умолчанию везде, кроме Windows). Каждый воркер это отдельный процесс со своей памятью: переменная-счётчик или кеш в памяти у каждого свой.
PM2 в режиме cluster делает то же самое и добавляет перезапуски и деплой без простоя. В Kubernetes и Docker обычно не используют cluster вовсе: запускают один процесс на контейнер и масштабируют числом реплик, а балансирует сама платформа.
worker_threads
import { Worker } from 'node:worker_threads';
function hashInWorker(data) {
return new Promise((resolve, reject) => {
const w = new Worker(new URL('./hash-worker.js', import.meta.url), { workerData: data });
w.once('message', resolve);
w.once('error', reject);
});
}
Воркер это отдельный поток со своим экземпляром V8, своим event loop и своей кучей. Данные передаются сообщениями (structured clone, то есть копией). Без копирования можно передать ArrayBuffer через transfer list или разделить SharedArrayBuffer с синхронизацией через Atomics.
Когда нужны воркеры: ресайз картинок, парсинг огромных файлов, тяжёлая криптография, сжатие, вычисления. Когда не нужны: ввод-вывод. HTTP-запросы и запросы в базу и так не блокируют цикл, воркер их не ускорит.
Создание воркера стоит миллисекунды и память, поэтому в проде держат пул воркеров (например, библиотека piscina), а не создают по одному на задачу.
Горизонтальное масштабирование
Чтобы добавлять экземпляры без проблем, сервис должен быть stateless:
- сессии в Redis или в подписанном токене, а не в памяти процесса;
- файлы в объектном хранилище (S3), а не на локальном диске;
- кеш общий (Redis) или такой, что расхождение между экземплярами не страшно;
- фоновые задачи в очереди, а не в
setIntervalкаждого экземпляра (иначе крон выполнится N раз).
WebSocket-соединения живут на конкретном экземпляре. Чтобы сообщение дошло до пользователя, подключённого к другому экземпляру, используют общую шину (Redis pub/sub). Sticky sessions нужны, если протокол делает несколько HTTP-запросов в рамках одной сессии, например long polling в Socket.IO.
Память
Размер кучи V8 ограничен. Для больших процессов его поднимают флагом --max-old-space-size=4096 (в МБ). В контейнере значение ставят заметно ниже лимита памяти контейнера: кроме кучи процессу нужны буферы, стек и нативная память.
Частые ошибки
- Ждать, что
worker_threadsускорят запросы к базе. - Хранить сессии в памяти процесса и потом включить cluster: пользователей начнёт «разлогинивать».
- Создавать новый воркер на каждый запрос.
- Запускать cluster внутри контейнера и одновременно масштабировать репликами: сложно ограничить ресурсы.
Что спросят на собесе
- Чем
clusterотличается отworker_threads. - Как Node использует несколько ядер.
- Что значит stateless-сервис и зачем он нужен.
- Как масштабировать WebSocket на несколько экземпляров.
- Когда воркеры не помогут.