Утечки памяти и профилирование

«Приложение за сутки съедает всю память и его убивает OOM. Ваши действия?» — типичная senior-задача на собеседовании.

Утечка памяти в Node — это не «сборщик мусора не сработал», а объекты, на которые всё ещё есть достижимая ссылка. GC не может собрать то, до чего дотягивается корень.

Что проверяет интервьюер

Есть ли у вас метод. Хороший ответ — это последовательность: подтвердить утечку метриками, снять снапшоты, сравнить, найти растущий тип объектов, дойти до места в коде. Плохой ответ — «перезапускаем по крону» (хотя как временная мера это честно, и об этом можно сказать отдельно).

Пять причин, которые встречаются чаще всего

  1. Кэш без ограничения: Map или объект, куда пишут по ключу и никогда не удаляют.
  2. Подписки на события без отписки: emitter.on() на каждый запрос. Симптом — предупреждение MaxListenersExceededWarning.
  3. Забытые таймеры: setInterval, который никто не останавливает, держит замыкание и все его переменные.
  4. Замыкания, случайно захватившие крупный объект (например, всё тело запроса) и живущие в долгоживущем колбэке.
  5. Глобальные накопители: массив логов или метрик «на потом», который растёт бесконечно.

Первую причину легко воспроизвести и легко починить — вот полностью синхронный пример, его можно запустить:

// утечка: кэш растёт без предела
const cache = new Map();
function handle(id) {
  cache.set(id, { id, payload: 'x'.repeat(100) });
}
for (let i = 1; i <= 5000; i++) handle(i);
console.log('записей в неограниченном кэше:', cache.size);

// исправлено: вытесняем самую старую запись
const limited = new Map();
const MAX = 100;
function handleLimited(id) {
  if (limited.size >= MAX) {
    limited.delete(limited.keys().next().value);   // Map хранит порядок вставки
  }
  limited.set(id, { id });
}
for (let i = 1; i <= 5000; i++) handleLimited(i);
console.log('записей в ограниченном кэше:', limited.size);
console.log('самый старый ключ:', limited.keys().next().value);

Вывод:

записей в неограниченном кэше: 5000
записей в ограниченном кэше: 100
самый старый ключ: 4901

В реальном коде вместо самописного вытеснения берут lru-cache с ограничением по числу записей или по размеру и с TTL. Если ключом служит объект и время жизни значения должно совпадать с временем жизни ключа, подходят WeakMap и WeakSet: они не мешают сборке мусора.

Как отличить утечку от нормального роста

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

setInterval(() => {
  const m = process.memoryUsage();
  logger.info({
    rss: Math.round(m.rss / 1e6),             // вся память процесса, МБ
    heapUsed: Math.round(m.heapUsed / 1e6),   // занято в куче V8
    external: Math.round(m.external / 1e6),   // буферы вне кучи V8
  }, 'memory');
}, 60000).unref();                            // unref, чтобы не держать event loop

Полезная деталь: если растёт rss, а heapUsed стабилен, утечка, скорее всего, в буферах или нативных модулях, а не в JS-объектах.

Инструменты

ИнструментЧто даёт
node --inspect + Chrome DevToolsheap snapshot, сравнение снапшотов, профиль CPU
v8.writeHeapSnapshot()снять дамп прямо из прода по сигналу
node --prof и --prof-processпрофиль CPU без внешних зависимостей
clinic doctor / clinic flameдиагностика и flame graph «из коробки»
--max-old-space-sizeлимит старого поколения кучи
node --inspect=0.0.0.0:9229 server.js   # затем chrome://inspect -> Memory -> Take snapshot
node --max-old-space-size=2048 server.js
npx clinic doctor -- node server.js

Метод трёх снапшотов

Это тот самый ответ, который отличает опытного кандидата. Снимаем снапшот после прогрева. Даём нагрузку. Снимаем второй. Даём ту же нагрузку и снимаем третий. В DevTools выбираем режим сравнения (Comparison) и смотрим, какие объекты выжили и в обоих интервалах, и продолжают расти в количестве. Дальше по цепочке удержания (Retainers) находим, кто их держит, — обычно это прямая ссылка на модуль или замыкание в вашем коде.

Типичные ошибки кандидатов

  • Говорят «GC не справляется» — GC не собирает только достижимое, дело в ссылках.
  • Делают вывод об утечке по одному растущему графику, не дождавшись полной сборки.
  • Не знают ни одного инструмента, кроме console.log(process.memoryUsage()).
  • Ставят --max-old-space-size побольше и считают задачу решённой — это отложенный OOM, а не починка.
  • Не подозревают про WeakMap и кэши с вытеснением.

Как ответить кратко

Утечка — это объекты, до которых остаётся достижимая ссылка: чаще всего безлимитный кэш, подписка на события без отписки, забытый setInterval или глобальный накопитель. Диагностика: сначала подтверждаю по метрикам, что после полной сборки мусора память не возвращается на прежний уровень, затем снимаю несколько heap snapshot через --inspect и сравниваю их, ищу растущий класс объектов и по retainers дохожу до места в коде. Лечение — ограниченные кэши с TTL, аккуратная отписка, WeakMap там, где ключ — объект.

Проверьте себя
1. Что чаще всего является причиной утечки памяти в Node.js?
AСлишком частый вызов сборщика мусора
BДостижимые ссылки: безлимитный кэш, подписки без отписки, забытые таймеры
CИспользование async/await вместо колбэков
DБольшое число одновременных HTTP-соединений
2. Как по метрикам отличить утечку от нормального роста памяти?
AЛюбой рост RSS означает утечку
BУтечка есть, если после полных сборок мусора базовый уровень памяти всё время смещается вверх
CУтечка есть, только если процесс уже упал с OOM
DНадо смотреть исключительно на external, heapUsed не показателен
3. Зачем при поиске утечки снимают несколько heap snapshot и сравнивают их?
AЧтобы измерить время работы сборщика мусора
BЧтобы увидеть, какие объекты выживают между интервалами и продолжают накапливаться
CЧтобы уменьшить размер кучи
DЧтобы Node пересчитал лимит --max-old-space-size