Утечки памяти и профилирование
«Приложение за сутки съедает всю память и его убивает OOM. Ваши действия?» — типичная senior-задача на собеседовании.
Утечка памяти в Node — это не «сборщик мусора не сработал», а объекты, на которые всё ещё есть достижимая ссылка. GC не может собрать то, до чего дотягивается корень.
Что проверяет интервьюер
Есть ли у вас метод. Хороший ответ — это последовательность: подтвердить утечку метриками, снять снапшоты, сравнить, найти растущий тип объектов, дойти до места в коде. Плохой ответ — «перезапускаем по крону» (хотя как временная мера это честно, и об этом можно сказать отдельно).
Пять причин, которые встречаются чаще всего
- Кэш без ограничения:
Mapили объект, куда пишут по ключу и никогда не удаляют. - Подписки на события без отписки:
emitter.on()на каждый запрос. Симптом — предупреждениеMaxListenersExceededWarning. - Забытые таймеры:
setInterval, который никто не останавливает, держит замыкание и все его переменные. - Замыкания, случайно захватившие крупный объект (например, всё тело запроса) и живущие в долгоживущем колбэке.
- Глобальные накопители: массив логов или метрик «на потом», который растёт бесконечно.
Первую причину легко воспроизвести и легко починить — вот полностью синхронный пример, его можно запустить:
// утечка: кэш растёт без предела
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 DevTools | heap 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там, где ключ — объект.