Профили CPU и охота на утечки по heap-снимкам
CPU-профиль показывает, куда уходит время — широкие кадры flame-графа горячие. Утечка — это old space, который растёт через GC и не падает; ищи её тремя heap-снимками, гонись за retained size и retainer path, потом ограничь кэш или сними слушателя.
Сервис оформления заказов начало OOM-килить каждые 14 часов, как по часам. Первым рефлексом команды было поднять --max-old-space-size с 2048 до 4096 — и краш просто переехал на каждые 28 часов. RSS рос ровной прямой через каждую сборку мусора и никогда не возвращался вниз, а это сигнатура утечки, а не проблемы тюнинга. Три heap-снимка спустя ответ оказался однострочным: модульный Map в роли кэша ответов, с ключом по id запроса и без вытеснения. Каждый запрос добавлял одну запись, которую ничто никогда не удаляло. «Фикс» с удвоением кучи лишь купил время, чтобы тот же Map вырос вдвое больше перед тем, как снова убить процесс. За следующие двадцать минут ты научишься читать CPU-профиль, снимать три heap-снимка и идти по retainer-цепочке прямо к виновнику.
Профилирование CPU: найди широкий кадр
Когда сервис тормозит или жрёт ядро, ты не гадаешь — ты сэмплируешь. CPU-профиль записывает, какая функция на стеке в каждый интервал сэмплирования, так что функция, появляющаяся чаще всех, и есть место, куда реально уходит время. Самый быстрый путь в Node — встроенный флаг --cpu-prof: он пишет файл .cpuprofile при выходе, который открывается во вкладке Performance Chrome DevTools. Там ты читаешь flame-граф, где каждый прямоугольник — кадр стека, а ширина = self time — самые широкие кадры и есть твой горячий путь, точка. Глубина — это лишь вложенность вызовов; игнорируй её и гонись за шириной.
# пишет isolate-*.cpuprofile при чистом выходе; открой в Chrome DevTools → Performance
node --cpu-prof --cpu-prof-dir=./prof server.js
# классический tick-процессор V8: текстовый отчёт, без GUI
node --prof server.js
node --prof-process isolate-*.log > processed.txt--prof плюс node --prof-process дают те же сэмплированные данные в виде текстового tick-отчёта — удобно на headless-машине, где DevTools не открыть. Инструменты выше оборачивают это: 0x генерит интерактивный flame-граф одной командой, а clinic flame делает то же с проводящим тебя сценарием. Все они — сэмплирующие профайлеры (снимают стек каждые N микросекунд, дёшево, ~1–2% накладных) в отличие от инструментирования (оборачивают каждый вызов, точные счётчики, но настолько тяжёлые, что искажают сами тайминги, которые ты меришь). Для боевой диагностики почти всегда нужно сэмплирование. Классическая находка: regex или глубокий JSON.parse всплывает одним жирным кадром, съедающим 40% self time, — оптимизируй его, закэшируй или вынеси с главного потока в Worker.
Heap-снимки: охота на утечку тремя снимками
CPU-профиль утечку не найдёт; для этого ты фотографируешь кучу. Heap-снимок — это полный граф каждого живого объекта и того, кто на кого указывает. Сделай один через v8.writeHeapSnapshot() изнутри процесса, сними по требованию через --heapsnapshot-signal=SIGUSR2 (затем kill -USR2 <pid>) или возьми из вкладки Memory в DevTools. Один снимок говорит, что большое сейчас; он не скажет, что течёт. Для этого ты применяешь технику трёх снимков.
import v8 from "node:v8";
// снимок 1 — базовый, после прогрева
v8.writeHeapSnapshot("./snap-1.heapsnapshot");
await exerciseSuspectPath({ times: 5000 }); // долби подозрительный маршрут
// снимок 2 — после нагрузки
v8.writeHeapSnapshot("./snap-2.heapsnapshot");
await exerciseSuspectPath({ times: 5000 }); // сделай ещё раз
v8.writeHeapSnapshot("./snap-3.heapsnapshot");Загрузи все три во вкладку Memory, переключи дропдаун на Comparison и используй «Objects allocated between snapshots»: всё, чей счётчик только растёт от снимка 1 → 2 → 3 и никогда не падает после GC, — это твоя утечка. Стабильная нагрузка должна выходить на «пилу» — выделить, собрать, повторить — поэтому ровная восходящая лестница и есть улика. Следующая колонка важнее всего: не shallow size (собственные байты объекта), а retained size — память, которая освободилась бы, если освободить этот объект, то есть всё, что только он держит живым. Сортируй по retained size, выбери конструктор, чей счётчик вырос, и иди по его retainer path (цепочке ссылок, держащих его) прямо к кэшу, массиву или слушателю, который не отпускает.
| Источник утечки | Сигнатура в снимке | Фикс |
|---|---|---|
Безграничный кэш Map/массив без вытеснения | Счётчик одного конструктора растёт каждый снимок; огромный retained size на одном Map | Ограничь: LRU с max-размером / TTL или WeakMap, если ключ — объект |
| Забытые слушатели событий | Счётчик слушателей/замыканий растёт; MaxListenersExceededWarning в логах | removeListener / off при teardown; once для одноразовых |
| Замыкание, захватившее большой объект | Retainer path идёт через контекст замыкания, держащий большой буфер/граф | Обнули ссылку; не замыкай больше, чем нужно |
| Модульный глобал, который только растёт | Верхнеуровневый массив/Set растёт монотонно; не сжимается через GC | Ограничь, периодически сбрасывай или вынеси состояние из модульной области |
В heap-снимке ты находишь запись кэша, у которой shallow size 48 байт, а retained size 90 МБ. О чём это говорит?
GC, лимит кучи и что на самом деле значит «утечка»
Прежде чем тянуться к --max-old-space-size, спроси себя: возвращается ли память обратно после полного GC, или пол только растёт? Ответ на этот вопрос разделяет проблему провижена и утечку — а фиксы у них принципиально разные.
Куча V8 поколенческая. Новые выделения попадают в new space, собираемое Scavenge — быстрым, частым, копирующим выживших наружу. Объекты, пережившие пару Scavenge, повышаются в old space, собираемое медленным Mark-Sweep-Compact. Практическое определение утечки выпадает прямо отсюда: old space (и RSS — Resident Set Size, суммарная физическая память процесса), который растёт через полные GC и никогда не возвращается вниз. Здоровый процесс пилит; текущий — лестничит. Смотри это напрямую через --trace-gc, который печатает по строке на сборку с размерами до/после.
node --trace-gc server.js
# [12345:0x...] 4210 ms: Mark-Sweep 1850.2 (2048.0) -> 1844.9 (2048.0) MB
# ^ после полного GC почти не упало → утечкаЖёсткий потолок — --max-old-space-size=<МБ>. На 64-битном Node дефолт old space большой, но ограниченный (исторически ~2 ГБ; зависит от платформы и версии), и реальная нагрузка с большой легитимной кучей может потребовать его поднять. Но поднятие — это ручка тюнинга, а не фикс утечки: как показывает хук, удвоение лимита лишь удваивает время до OOM. Диагностический вопрос всегда один: возвращается ли память после полного GC? Если да — ты недопровиженен, подними лимит. Если нет — у тебя утечка, снимай снимки.
▸Почему это работает
Почему необработанный, вечно растущий Map полностью побеждает GC? Сборка мусора освобождает только недостижимое. Модульный Map достижим всю жизнь процесса, и каждое значение, что ты в него кладёшь, достижимо через Map — так что с точки зрения V8 ничто из этого никогда не мусор. Сборщик работает идеально; ты просто сказал ему, удерживая ссылку, что всё это живое. Вот почему фикс — никогда не «тюнить GC», а «убрать ссылку»: добавь вытеснение, чтобы старые ключи стали недостижимыми и следующий Mark-Sweep смог их вернуть.
Боевой API на headless-сервере временами упирает одно ядро CPU. Открыть против него Chrome DevTools нельзя, и ты хочешь самый малотрудный способ увидеть горячую функцию. За что хватаешься первым?
Раннбук триажа утечки
Когда память растёт и тебя OOM-килит, ходы механические, а не магические. Подтверди, что это утечка (old space не падает после полного GC по --trace-gc). Сними базовый снимок, жёстко нагрузи подозрительный путь, сними снова, повтори для третьего. Через вид сравнения «allocated between snapshots» найди конструктор, чей счётчик только растёт. Сортируй по retained size, открой retainer path и иди по нему к корневой ссылке — почти всегда это безграничный кэш, забытый слушатель, жирное замыкание или модульный глобал. Затем заставь ссылку исчезнуть: добавь LRU-вытеснение или TTL кэшу, removeListener/off при teardown, обнули захваченный объект. Перезапусти diff снимков, чтобы доказать, что счётчик теперь ровный. Регрессионный тест, утверждающий стабильный RSS после N итераций, не даёт ей вернуться.
// до: течёт по одной записи на запрос, вечно
const cache = new Map();
function handle(req) {
cache.set(req.id, expensiveResult(req)); // никогда не вытесняется → OOM за часы/дни
}
// после: ограничено — старые ключи становятся недостижимы и GC их возвращает
import { LRUCache } from "lru-cache";
const cache = new LRUCache({ max: 10_000, ttl: 60_000 });
function handle(req) {
cache.set(req.id, expensiveResult(req)); // вытесняет старейшие за 10k записей
}Расставь шаги охоты на утечку по heap-снимкам — от первого подозрения до проверенного фикса:
- 1 Подтверди утечку: --trace-gc показывает, что old space не падает после полного GC
- 2 Сними базовый снимок после прогрева
- 3 Жёстко нагрузи подозрительный путь, затем сними снова — повтори для третьего
- 4 Через вид сравнения найди конструктор, чей счётчик только растёт
- 5 Сортируй по retained size и иди по retainer path к корневой ссылке
- 6 Обрежь ссылку (LRU-вытеснение / removeListener) и пере-diff снимки, чтобы доказать ровность
С запущенным --trace-gc ты видишь, что old space стоит на ~1850 МБ до Mark-Sweep и ~1845 МБ после, прибавляя по несколько МБ каждый цикл. Что значит этот паттерн?
- 01В чём разница между shallow и retained size в heap-снимке и по какому из них сортировать, чтобы охотиться на утечку?
- 02Почему удвоение --max-old-space-size не чинит утечку памяти и как отличить утечку от настоящего недопровижена?
Профилирование и охота на утечку — две разные работы с двумя разными инструментами. Для CPU ты сэмплируешь: --cpu-prof пишет .cpuprofile для вкладки Performance Chrome DevTools, --prof плюс node --prof-process даёт дружащий с headless текстовый tick-отчёт, а 0x или clinic flame оборачивают те же данные во flame-граф, где ширина — это self time, — гонись за самым широким кадром, игнорируй глубину. Для памяти ты фотографируешь кучу. Heap-снимок (v8.writeHeapSnapshot(), --heapsnapshot-signal=SIGUSR2 или вкладка Memory в DevTools) показывает, что живо сейчас, но только техника трёх снимков — базис, нагрузить путь, снимок, повторить — раскрывает утечку через вид сравнения «objects allocated between snapshots»: конструктор, чей счётчик только растёт, и есть виновник. Сортируй по retained size (что освободится, если освободить это), а не по shallow size, и иди по retainer path к корню: безграничный кэш Map/массив, забытый слушатель событий (MaxListenersExceededWarning), жирное замыкание или модульный глобал. Механизм под этим — поколенческая куча V8: быстрый Scavenge на new space, медленный Mark-Sweep-Compact на old space, — поэтому точное определение утечки это old space, который растёт через полные GC и никогда не падает, что ты смотришь через --trace-gc. Поднятие --max-old-space-size — ручка провижена, никогда не фикс утечки: если память возвращается после полного GC, ты недопровиженен, если нет — у тебя утечка. Фикс всегда в том, чтобы сделать ссылку недостижимой — добавь LRU-вытеснение или TTL, removeListener при teardown, обнули замыкание, — затем пере-diff снимки, чтобы доказать, что счётчик ровный. Теперь, когда видишь RSS, растущий прямой линией через каждый GC, ты первым делом тянешься к --trace-gc, а не к большему лимиту кучи.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.