Промышленная система на Node.js живёт по правилам, которые не написаны ни в одном руководстве для начинающих, но строго исполняются средой исполнения. Один процесс, один поток, один событийный цикл, который принимает миллионы соединений и раздаёт ответы. Пока код уважает эту архитектуру, приложение летает на скромном железе. Стоит загрузить цикл синхронной работой, и миллионы ждут одного: медленного чтения файла, тяжёлого парсинга, неудачного регулярного выражения. Оптимизация зрелой системы начинается не с переписывания горячих функций, а с понимания, что именно тормозит всех.

Второй столп - измерения. Интуиция о производительности подводит даже опытных людей: предположение о виновнике работает в одном случае из трёх. Enterprise-подход отличается дисциплиной: сначала измерение и профилирование, потом диагноз, потом правка, потом повторное измерение. Такая последовательность экономит недели работы над кодом, который ни при чём.

Событийный цикл задаёт правила производительности Node.js

Среда исполнения обрабатывает всё в одном потоке: пришёл запрос - событие в очередь, цикл взял, отдал ответ, взял следующее. Скорость достигается тем, что операции ввода-вывода не ждутся: пока база думает, цикл обслуживает другие соединения. Такая модель блестяще работает на типичной нагрузке веб-сервиса и резко ломается, когда в очередь попадает работа, требующая процессорного времени.

Разница между честной и убийственной записью одного и того же действия:

// блокирует цикл до конца чтения
const config = fs.readFileSync("/etc/app/large-config.json", "utf8");

// отдаёт управление: цикл продолжает работать
fs.readFile("/etc/app/large-config.json", "utf8", (err, text) => {
  if (err) return handle(err);
  const config = JSON.parse(text);
});

Синхронный вызов на файле в сотню мегабайт замораживает все соединения процесса на время чтения. Для сервиса с тысячами активных сессий это выглядит как мгновенная деградация всего сайта без видимой причины. Правило простое: синхронные операции ввода-вывода не существуют в коде, обслуживающем запросы, и проверяются поиском по кодовой базе как класс дефектов.

Долгие вычисления не лечатся асинхронностью - они лечатся переездом. Шифрование больших файлов, пересчёт аналитики, обработка изображений выносятся в отдельные потоки исполнения, и цикл продолжает обслуживать пользователей, пока воркер пыхтит над тяжёлой задачей. Граница проходит по времени: всё, что рискует занять больше нескольких миллисекунд процессорного времени, кандидат на вынос.

Блокирующие вызовы находятся по следам задержек цикла

Деградация событийного цикла заметна раньше жалоб пользователей, если её измерять. Задержка цикла - время между моментом, когда событие могло быть взято, и моментом, когда оно реально взято. В здоровой системе эта величина держится в единицах миллисекунд. Рост до сотен говорит о блокировках, рост до секунд - о аварии в замедленном действии.

Простой детектор задержек встраивается в любое приложение:

function watchLoop(threshold = 100) {
  let last = performance.now();
  setInterval(() => {
    const now = performance.now();
    const lag = now - last - 1000;
    last = now;
    if (lag > threshold) {
      logger.warn({ lag: Math.round(lag) }, "event loop lag");
    }
  }, 1000);
}

Таймер сам является событием: чем дольше его задержали, тем сильнее был занят цикл. Собранная метрика превращает туманное иногда тормозит в конкретные минуты и корреляции с релизами. Опыт эксплуатации крупных систем даёт устойчивый список подозреваемых: синхронные методы файловой системы, парсинг гигантских ответов, регулярные выражения с катастрофическим откатом, синхронное шифрование, обход больших массивов в обработчиках запросов.

Особая статья - динамические проверки и сериализация. Тяжёлый запрос к базе иногда возвращает ответ, который сериализуется в строку на десятки мегабайт, и мгновенная операция ответа превращается в секунды молчания. Постраничные выборки и ограничения полей лечат это на уровне схемы данных, а не кода ответа.

Память и сборщик мусора настраиваются под профиль нагрузки

Управление памятью в среде исполнения автоматическое, но не бесплатное. Сборщик мусора периодически останавливает поток на чистку, и при больших кучах эти остановки вырастают в заметные паузы. Enterprise-система измеряет память постоянно: использование кучи, частоту сборок, размер внешних буферов. Пороги настраиваются под профиль: сервис с частыми короткими запросами живёт с меньшей кучей и быстрыми сборками, тяжёлый обработчик данных получает увеличенный лимит.

Утечки находят повторяемостью: снимки кучи делаются до и после нагрузки, разница показывает объекты, которые не освобождаются. Типичные виновники - глобальные кэши без ограничений, слушатели событий, не снятые при закрытии соединений, замыкания, удерживающие большие структуры через долгоживущие колбэки. Каждый такой источник находится за один вечер со снимками, и месяцами без них.

Кэш - место, где память приносит пользу или убыток. Безлимитный кэш результатов - классическая мина: полезная на старте, он растёт годами и кончается ночной аварией. Рабочий кэш всегда имеет лимит и политику выселения:

class LimitedCache {
  constructor(max = 1000) {
    this.max = max;
    this.map = new Map();
  }
  get(key) {
    if (!this.map.has(key)) return undefined;
    const value = this.map.get(key);
    this.map.delete(key);
    this.map.set(key, value);
    return value;
  }
  set(key, value) {
    if (this.map.has(key)) {
      this.map.delete(key);
    } else if (this.map.size >= this.max) {
      this.map.delete(this.map.keys().next().value);
    }
    this.map.set(key, value);
  }
}

Обновление существующего ключа при этом не вытесняет чужие записи: ключ сначала переустанавливается в конец очереди, и только новый ключ при заполненном лимите выталкивает самую старую. Такой набор хранит тысячу свежих записей вместо миллионов случайных, и его поведение предсказуемо при любой нагрузке. Правило звучит коротко: любой словарь в памяти получает максимальный размер в день рождения, а не в день аварии.

Кластеры и воркеры масштабируют приложение по ядрам

Один процесс Node.js занимает одно ядро процессора. Сервер с шестнадцатью ядрами под одним процессом отдаёт шестнадцатую часть мощности, остальное простаивает. Модуль кластеров поднимает несколько процессов с общим портом, и ядро раздаёт соединения между ними. Для системы, упирающейся в процессор, это вертикальный рост без изменения кода:

import cluster from "node:cluster";
import os from "node:os";

// файл живёт как ES-модуль, статические импорты не смешиваются с require
if (cluster.isPrimary) {
  for (let i = 0; i < os.availableParallelism(); i++) {
    cluster.fork();
  }
  cluster.on("exit", (worker) => {
    logger.warn({ pid: worker.process.pid }, "worker died, respawn");
    cluster.fork();
  });
} else {
  await import("./server.js");
}

Погибший воркер перезапускается автоматически, и уставшая утечка в одном процессе не роняет систему целиком. Разделение процессов требует дисциплины состояния: память одного процесса недоступна другому, поэтому сессии и кэши переезжают в общее хранилище. Это не недостаток, а гигиена, потому что локальное состояние в кластере ломается ровно в момент роста.

Тяжёлые задачи внутри одного процесса уходят в потоки исполнения. Пул воркеров принимает задачи по очереди, и каждый воркер живёт в собственном изолированном контексте с передачей данных через сообщения или общие буферы. Обмен большими структурами через общие буферы без копирования - ключевая техника для задач обработки данных: пересылка сотен мегабайт копированием съест всю выгу от распараллеливания.

Внешние сервисы и базы данных держатся на пулах и лимитах

Производительность сервиса на Node.js упирается не в сам код, а в соседей: базы данных, внешние API, кэширующие слои. Каждый внешний вызов получает три обязательных атрибута: таймаут, лимит одновременных запросов и пул соединений. Без таймаута зависший сосед уводит воркеров в вечное ожидание по одному, без лимита пул соединений исчерпывается под нагрузкой, без пула каждый запрос платит за установку соединения.

Работа с большими ответами строится на потоках данных. Отдача файла на гигабайт чтением в память - кратчайший путь к аварийной перезагрузке. Конвейер потоков читает порциями и отдаёт порциями, и память остаётся ровной при любом размере:

import { pipeline } from "node:stream/promises";
import fs from "node:fs";

async function sendFile(path, res) {
  await pipeline(fs.createReadStream(path), res);
}

Тот же принцип работает с ответами баз данных: курсорная выборка порциями по сто строк не рождает пик памяти, который случается при выгрузке миллиона строк одним запросом. Обратное направление симметрично: приём большого тела запроса читается потоком с ограничением размера, и загрузка полугигабайтного файла ленивым пользователем не выбивает сервис из строя.

Пакетные операции экономят и время, и соединения. Сотня одиночных вставок гоняет протокол туда-сюда сотню раз, одна пакетная вставка делает то же за один проход. Отчётные выборки агрегируются на стороне базы: сумма, посчитанная на сервере базы, дешевле тысячи строк, пересланных в приложение для сложения. Каждая такая экономия освобождает соединение для живого запроса и уменьшает очередь, которая иначе растёт именно в час пик.

Инструменты измерения заменяют споры о производительности

Бюджет производительности проверяется до релиза, а не после. Нагрузочный прогон на эталонном стенде отвечает на главный вопрос: выдержит ли новая версия текущий пик. Инструменты для генерации нагрузки и профилирования показывают не только среднее время ответа, но и хвосты распределения: девяносто девятый процентиль честно сообщает, что один из ста запросов живёт в десять раз дольше остальных, и именно этот запрос принадлежит реальному недовольному человеку.

Профилирование снимает покров с горячих мест: снимок процессора показывает функции, съевшие время, снимок памяти - объекты, которые не отпускаются. Типичный сюрприз: оптимизируют то, что выполняется сто раз в секунду, а тормозит то, что выполняется один раз и занимает восемьсот миллисекунд. Без снимка это различие не видно, и недели уходят на ускорение кода, который никто не ждёт.

Хвосты читаются вместе с частотой: одна медленная операция за минуту на фоне тысячи быстрых - не всегда беда, если она не блокирует общий поток. Настоящая цель оптимизации не среднее число, а предсказуемость: посетитель, который ждёт стабильно двести миллисекунд, счастливее того, кто в половине случаев ждёт сто, а в половине - три секунды.

Производственные метрики замыкают контур: время ответа по процентам, задержка цикла, потребление памяти, глубина очередей к внешним сервисам. Сравнение до и после релиза делает движение видимым: релиз, который поднял девяносто девятый процентиль с двухсот до восьмисот миллисекунд, задерживается независимо от мнений о его архитектурной красоте.

Культура производительности в крупной команде

В enterprise-системе код пишет десяток команд, и правила производительности живут не в головах, а в конвейере. Ревью задаёт три вопроса к каждой правке: не добавлен ли синхронный вызов, не появился ли безлимитный словарь, не оставлен ли новый запрос без таймаута. Нагрузочный прогон входит в определение готовности для изменений в горячих путях, и регрессия ловится на этапе запроса на слияние, а не в отчёте о ночной аварии.

Проверка перед релизом крупного сервиса сводится к короткому списку:

  1. нагрузочный прогон показывает хвосты распределения внутри бюджета;
  2. задержка событийного цикла под нагрузкой остаётся в единицах миллисекунд;
  3. потребление памяти стабильно на протяжении длинного прогона;
  4. внешние вызовы имеют таймауты и не исчерпывают пул соединений;
  5. падение воркера не роняет процесс целиком и регистрируется;
  6. метрики производительности подключены к дашборду релиза.

Такая культура экономит самое дорогое - внимание. Инженеры перестают спорить о вкусах асинхронности и начинают читать распределения, а решения о рефакторинге принимаются по хвостам, а не по предчувствиям. Node.js в этих условиях раскрывается как та платформа, которой и задумывалась: скромная по памяти, быстрая на вводе-выводе и честная к тем, кто уважает её событийный цикл.