Дайджесты новостей
Концептуальное сравнение архитектур Node.js: тяжелые изолированные процессы PM2 против легковесных worker threads с общей памятью и балансировкой Platformatic Watt.

Как Booking.com снизил затраты на Node.js на 38%: переход с процессов PM2 на worker threads

Серверный рендеринг 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 дает ощутимый выигрыш в ресурсах.