Промышленная система на 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-системе код пишет десяток команд, и правила производительности живут не в головах, а в конвейере. Ревью задаёт три вопроса к каждой правке: не добавлен ли синхронный вызов, не появился ли безлимитный словарь, не оставлен ли новый запрос без таймаута. Нагрузочный прогон входит в определение готовности для изменений в горячих путях, и регрессия ловится на этапе запроса на слияние, а не в отчёте о ночной аварии.
Проверка перед релизом крупного сервиса сводится к короткому списку:
- нагрузочный прогон показывает хвосты распределения внутри бюджета;
- задержка событийного цикла под нагрузкой остаётся в единицах миллисекунд;
- потребление памяти стабильно на протяжении длинного прогона;
- внешние вызовы имеют таймауты и не исчерпывают пул соединений;
- падение воркера не роняет процесс целиком и регистрируется;
- метрики производительности подключены к дашборду релиза.
Такая культура экономит самое дорогое - внимание. Инженеры перестают спорить о вкусах асинхронности и начинают читать распределения, а решения о рефакторинге принимаются по хвостам, а не по предчувствиям. Node.js в этих условиях раскрывается как та платформа, которой и задумывалась: скромная по памяти, быстрая на вводе-выводе и честная к тем, кто уважает её событийный цикл.