Серверный рендеринг React (React SSR) — одна из самых ресурсоемких задач в веб-разработке. Чтобы превратить дерево компонентов в готовую HTML-строку, движок V8 непрерывно строит виртуальный DOM и выделяет сотни мегабайт объектов. В Booking.com этот конвейер обслуживает тысячи серверов под круглосуточным потоком запросов со всего мира.
Годами стандартом масштабирования Node.js оставался запуск мультипроцессного кластера через PM2 (pm2 start app.js -i max) или node:cluster. Но у запуска отдельных процессов на каждое процессорное ядро есть высокая цена.
Анатомия расточительства в процессах PM2
Запуская 8 или 16 процессов Node.js на одном сервере, инженеры дублируют рантайм V8 в памяти столько же раз. Каждый процесс содержит собственный JIT-компилятор, независимый сборщик мусора (GC), копии библиотек из node_modules и буферы.
Это напоминает постройку отдельной котельной для каждого кабинета в офисном здании. Память расходуется не на рендеринг страниц, а на копии среды исполнения. Кроме того, передача сетевых дескрипторов от мастер-процесса через IPC создает ощутимую задержку.
Команда Booking.com под руководством Ильи Малявина перевела ключевой сервис SSR с процессов на рабочие потоки (worker_threads), управляемые сервером Platformatic Watt.
Экономика памяти и балансировка SO_REUSEPORT
Главный выигрыш потоков — работа в едином адресном пространстве операционной системы. Потоки делят общий системный процесс, избавляя сервер от дублирования бинарного кода рантайма. При этом изоляция сохраняется: каждый поток в Node.js изолирован в собственном контексте V8 (V8 Isolate), предотвращая гонки данных.
Входящие запросы распределяются без посредников: Platformatic Watt задействует механизм ядра Linux SO_REUSEPORT. Соединения распределяются между потоками напрямую на уровне ядра ОС.
Конфигурация watt.json задает пул потоков и параметры сборщика мусора V8:
{
"$schema": "https://schemas.platformatic.dev/@platformatic/watt/2.0.0.json",
"server": {
"hostname": "0.0.0.0",
"port": 3000,
"workers": 8,
"reusePort": true
},
"runtime": {
"entrypoint": "ssr-service",
"services": [
{
"id": "ssr-service",
"path": "./services/ssr",
"workers": 8,
"nodeArgs": [
"--max-semi-space-size=64",
"--max-old-space-size=2048"
]
}
]
}
}
Флаги --max-semi-space-size=64 и --max-old-space-size=2048 оптимизируют работу с короткоживущими объектами: V8 быстрее очищает молодое поколение без блокирующих пауз Stop-the-World, сглаживая скачки p99 задержек.
Диспетчер пула worker_threads
Внутренняя логика распределения задач опирается на node:worker_threads. Главный поток принимает запросы и направляет их свободным воркерам:
import { Worker, isMainThread, parentPort } from 'node:worker_threads';
import { cpus } from 'node:os';
if (isMainThread) {
class SSRPool {
constructor(workerPath, size = cpus().length) {
this.idle = [];
this.queue = [];
for (let i = 0; i < size; i++) {
const worker = new Worker(workerPath, { execArgv: ['--max-semi-space-size=64'] });
worker.on('message', ({ html, error }) => {
const task = worker.task;
worker.task = null;
this.idle.push(worker);
this.next();
error ? task.reject(new Error(error)) : task.resolve(html);
});
this.idle.push(worker);
}
}
render(component, props) {
return new Promise((resolve, reject) => {
this.queue.push({ component, props, resolve, reject });
this.next();
});
}
next() {
if (this.queue.length && this.idle.length) {
const worker = this.idle.pop();
const task = this.queue.shift();
worker.task = task;
worker.postMessage({ component: task.component, props: task.props });
}
}
}
export const pool = new SSRPool(new URL(import.meta.url));
} else {
parentPort.on('message', async ({ component, props }) => {
try {
const html = `<div data-ssr="${component}">${JSON.stringify(props)}</div>`;
parentPort.postMessage({ html, error: null });
} catch (err) {
parentPort.postMessage({ html: null, error: err.message });
}
});
}
Итоги миграции и выводы для продакшена
Переход на потоки требует внимательности: сторонние нативные модули (N-API) обязаны поддерживать worker threads, а глобальные переменные больше не могут служить общим кэшем.
Результат инженеров Booking.com подтверждает оправданность перехода: совокупные затраты на инфраструктуру сервиса SSR снизились на 38%. Плотность размещения контейнеров на тех же серверах выросла почти вдвое, а отклик в 99-м перцентиле стал более предсказуемым. При тяжелых SSR-нагрузках переход с PM2 на worker threads дает ощутимый выигрыш в ресурсах.
