Как устроены утечки памяти в вебе: типичные причины и как их ловить метриками
Разберём, почему процесс растёт со временем: кэши без TTL, удержание ссылок в очередях, неверные сторы и большие объекты. Дам чек-лист диагностики и правила, которые помогают не допускать проблем в проде.
Содержание
Как устроены утечки памяти в вебе: типичные причины и как их ловить метриками
Утечка памяти в веб-приложениях — это не обязательно «сломанная» логика, где где-то забыли вызвать free(). В JavaScript, Java/Kotlin и даже в Go проблема чаще выглядит иначе: память накапливается, потому что система удерживает ссылки дольше, чем вы ожидали. Garbage Collector (GC) честно делает свою работу, но «не может освободить» то, на что ещё есть ссылки.
В результате процесс растёт со временем: увеличивается RSS (resident set size), иногда растёт heap, растёт количество объектов определённых типов, а в какой-то момент страница начинает тормозить, сервер упирается в лимиты контейнера, а затем приложение падает или начинает активно «заедать» из‑за частых GC.
Ниже — разбор типичных причин утечек в вебе и практический чек-лист диагностики через метрики и наблюдаемость. Акцент — на том, как ловить проблему ещё до OOM и как построить процесс расследования на данных.
Что считать утечкой: память, которая «не возвращается» и «не обязана возвращаться»
Прежде чем искать виновника, договоримся о терминах.
RSS ≠ heap, но оба важны
- RSS (часть процесса в памяти физически) может расти, даже если heap уменьшается: ОС может не отдавать освобождённые страницы обратно, особенно в контейнерных средах.
- Heap (управляемая память рантайма) — ближе к тому, что видит GC.
- В Node.js, например, можно следить и за
heapUsed, и за общим потреблением. В JVM — за heap/non-heap, метапространство и т. п.
Настоящая утечка vs «нормальный кэш»
Иногда рост памяти — это:
- «разогрев» кэшей (warming), который в норме стабилизируется;
- накопление данных из-за изменения нагрузки (например, больше пользователей → больше объектов);
- фрагментация (особенно в long-running процессах);
- длительная сессия и высокая гранулярность аллокаций.
Утечка обычно характеризуется тем, что при сопоставимой нагрузке память растёт монотонно или с долгосрочным трендом и не возвращается на прежний уровень после сбора GC.
Типичные причины утечек в вебе
Ниже — наиболее частые сценарии. Для каждого — что происходит «под капотом» и где это проявляется в метриках.
Кэш без TTL: «я просто хочу, чтобы было быстрее»
Одна из самых распространённых причин утечек — кэширование без ограничений по размеру и времени жизни.
Классический пример
- Кэш ключей → значений хранится в памяти.
- Ключи бесконечны: URL с query-параметрами, userId по всей базе, результаты поиска с разными фильтрами.
- TTL не задан или слишком большой.
- Нет эвакуации по LRU/LFU или лимита по объёму.
Метки на графиках
- RSS/heap растёт линейно при росте числа уникальных ключей.
- Количество записей в кэше растёт без остановки.
- GC может становиться чаще (дешёвые «короткие» объекты не освобождаются).
Типичные ошибки реализации
- «Мы используем
Map, он же работает долго» — да, ноMapудерживает ссылки. - TTL есть, но ключи всё равно остаются в структуре из-за ошибок очистки (например, забыли удалять при истечении, только логировали истечение).
- Лимит есть, но сброс не происходит в некоторых ветках кода.
Практика: лимит + TTL + метрики размера
Если вы делаете кэш внутри сервиса, заведите метрики:
cache_size(число записей)cache_hit_ratiocache_evictions_totalcache_ttl_expired_total(сколько записей истекло)- размер значений (если значения большие)
Решения уровня «встроить кэш без контроля» почти всегда со временем превращаются в утечку.
Удержание ссылок в очередях: «мы отправим позже»
Второй частый класс — память удерживается в очередях и буферах: задачах, событиях, retry-механизмах, batch-обработке.
Что обычно происходит
- Есть очередь (in-memory) или буфер, в который вы складываете объекты.
- Потребитель (worker) отстаёт: рост нагрузки, зависла внешняя система, увеличилось время обработки.
- Очередь раздувается, а объекты всё это время доступны по ссылкам.
Это не всегда «ошибка GC». Это логическая backpressure-проблема: вы продолжаете накапливать данные быстрее, чем можете их обрабатывать.
Метрики-симптомы
queue_depthилиinflight_tasksрастёт.- Latency в обработке увеличивается (например, p95/p99).
- Heap/RSS растёт вместе с глубиной очереди.
- Количество объектов конкретного типа в heap профиле растёт.
Ключевой нюанс: retention через замыкания и контекст
Иногда очередь держит задачу, а задача держит слишком много контекста:
- логи/trace объект с большим payload;
- весь HTTP response body;
- сериализованный документ;
- parent context с буфером.
Одна «неуместная» ссылка в замыкании может удерживать весь большой граф объектов.
Практика: backpressure и ограничение на вход
- Ограничивайте размер очереди.
- Вводите стратегии: drop/skip/merge, если бизнес это позволяет.
- Делаёте дедлайны на задачи и прекращаете ретраи после N попыток.
- Разносите «тяжёлое» payload отдельно (например, хранилище) и в очереди держите только ссылку/ID.
Неверные сторы и «навешивание» ссылок: фронтенд и долгоживущие объекты
В браузере утечки встречаются особенно часто из-за жизненного цикла UI-объектов: подписки, обработчики событий, замыкания и неверная очистка.
Подписки и event listeners
- Компонент размонтировался, но подписка осталась.
- Обработчик
addEventListenerне удалили. - Observables продолжают держать ссылки на старый state/props.
GC в браузере не может освободить компонент, если на него ссылается слушатель или сторовый объект.
Метрики-симптомы
В вебе напрямую измерять heap сложнее, но можно:
- отслеживать рост
performance.memory.usedJSHeapSizeв Chrome (если доступно); - использовать профилирование (heap snapshot) и смотреть retention paths;
- измерять долгоживущие объекты в DevTools.
Стор (store) как «вечное хранилище»
Нередко в state менеджерах (Redux-like, MobX, Zustand, самописный store) появляются поля:
- массивы историй;
- кэш результатов;
- «последние выбранные элементы».
Если не ограничивать эти поля, они становятся аналогом «кэша без TTL», но уже в браузере.
Ошибка: хранить DOM/React элементы в state
Старайтесь не держать прямые ссылки на DOM-узлы или большие объекты UI в глобальном store. Это удерживает весь subtree.
Большие объекты и неправильный жизненный цикл: «один payload на всех»
Утечка может быть не про «копится навсегда», а про «не освобождается достаточно рано».
Пример на сервере: буфер ответа и длинная очередь
Представьте пайплайн:
- Принимаем запрос.
- Делаем запрос к внешнему API.
- Собираем большой JSON/ZIP/stream в память.
- Кладём результат в очередь для дальнейшей обработки.
Если шаг 4 выполняется с задержками, большой объект удерживается в heap, пока задача не отработает.
Почему это выглядит как утечка
Даже если объекты освобождаются после обработки, при нестабильной нагрузке они могут «накапливаться» и создавать устойчивый рост.
Метрический подход
- Смотрите корреляцию между ростом памяти и размером входных payload (например, средний размер тела запроса).
- Смотрите
inflight_bytesили «объём в очереди», если можно оценить. - Отдельно следите за пиковыми значениями: утечка обычно даёт хвост, но не всегда пик постоянен.
Как ловить утечки метриками: практический чек-лист диагностики
Ниже — чек-лист, который можно внедрить без магии. Идея: утечка почти всегда оставляет следы в трендах (а не только в «единичных» сбоях).
1) Настройте метрики памяти и GC
Что собирать минимум:
process_memory_rss_bytesruntime_heap_used_bytes(если доступно)runtime_heap_total_bytes(опционально)gc_pause_seconds_total/gc_pause_duration_seconds_bucketgc_cycles_total/gc_duration_seconds_total- аллокации (если есть):
alloc_bytes_total
Почему это важно:
- Рост heap без роста аллокаций может указывать на удержание ссылок (retention).
- Частые GC паузы при растущей памяти — частый признак «освобождаем меньше, чем нужно».
2) Логируйте «объектные» метрики, которые связаны с причиной
С памятью редко получается работать только по факту RSS. Нужно связать её с причиной:
- кэши:
cache_size,cache_hit_ratio,cache_evictions_total - очереди:
queue_depth,queue_age_seconds(сколько старых задач),inflight_tasks - подписки (фронтенд): количество активных слушателей/подписок (можно учитывать вручную)
- буферы/пейлоады:
inflight_bytes,request_body_size_bytes
Если причина кроется в кэше без TTL, то cache_size будет расти раньше, чем вы увидите «фатальный» рост памяти.
3) Делайте корреляцию: «memory vs источник роста»
Утечка — это системная зависимость: память растёт синхронно с некоторым измерением.
Практика:
- смотрите, что происходит с
heapUsedпри ростеqueue_depth— совпадают ли тренды? - сравнивайте график
cache_sizeиheapUsed. - оцените, меняется ли поведение по типам запросов (например, после релиза новых endpoint с большими payload).
4) Установите алерты по тренду, а не только по порогам
Порог RSS «например 1.5 ГБ» может не успеть: процесс мог бы ещё жить, но уже становится опасно.
Рекомендуемые алерты (концептуально):
- скорость роста памяти (например, 5% в час при стабильной нагрузке);
- отклонение от baseline (memory percentile vs 7-дневная история);
- рост очереди + рост памяти (двухсигнальная модель).
Мини-стратегии расследования: от симптома к удерживающим ссылкам
Метрики показывают «куда смотреть», но не всегда «что удерживает». Для этого нужны профили и осмысленные гипотезы.
Для Node.js: heap snapshots + маркеры
Если вы используете Node.js:
- делайте heap snapshot в моменты «перед ростом» и «во время роста»;
- сравнивайте retainers (пути удержания) для увеличивающихся типов объектов.
Типичная последовательность:
- включили GC/heap метрики;
- заметили тренд роста;
- сделали snapshot;
- посмотрели доминирующие типы/retainers;
- сопоставили с кодом: кэш/очередь/подписка.
Для JVM/Go: heap dump / pprof / trace
- JVM: heap dump и анализ dominator tree.
- Go: pprof heap profile и goroutine leaks, если подозрение на буфер/каналы.
Типовые паттерны утечек и как исправлять
Паттерн 1: Map как кэш без политики
Сигнал: cache_size растёт, heapUsed растёт.
Решение:
- добавьте TTL и удаление;
- добавьте лимит;
- измеряйте eviction rate.
Простейший пример кэширования с TTL (как концепт; на практике используйте готовые библиотеки, где корректно реализована очистка):
type Entry<T> = { value: T; expiresAt: number };
class TtlCache<K, V> {
private map = new Map<K, Entry<V>>();
constructor(
private readonly ttlMs: number,
private readonly maxSize: number
) {}
get(key: K): V | undefined {
const e = this.map.get(key);
if (!e) return undefined;
if (Date.now() > e.expiresAt) {
this.map.delete(key);
return undefined;
}
return e.value;
}
set(key: K, value: V) {
// грубый пример: при переполнении чистим весь кэш не надо,
// лучше LRU. Здесь показана логика TTL.
if (this.map.size >= this.maxSize) {
// эвикция по первой попавшейся записи (для примера)
const firstKey = this.map.keys().next().value as K | undefined;
if (firstKey !== undefined) this.map.delete(firstKey);
}
this.map.set(key, { value, expiresAt: Date.now() + this.ttlMs });
}
}
В проде лучше:
- использовать LRU-кэш;
- отделить поток очистки TTL;
- либо очищать «лениво» плюс периодическая уборка.
Паттерн 2: очереди без backpressure
Сигнал: queue_depth и heapUsed растут вместе.
Решение:
- лимитировать размер очереди;
- вводить режим отказа или деградацию;
- уменьшать удержание payload в задачах.
В серверном коде полезно явно отказываться при переполнении:
const MAX_QUEUE = 1000;
const queue = [];
function enqueue(task) {
if (queue.length >= MAX_QUEUE) {
// Варианты: drop oldest / drop newest / reject 429
return false;
}
queue.push(task);
return true;
}
И отдельная проверка: задача должна хранить только то, что нужно для обработки. Если в задаче держите большой объект — вынесите его в хранилище и кладите в очередь ID.
Паттерн 3: подписки, которые не отписываются
Сигнал (фронтенд): после навигации на страницы memory не возвращается, наблюдаются «висящие» обработчики.
Решение:
- в React: использовать
useEffectс cleanup; - в любых event-emitter: делать
removeListener/unsubscribeпри размонтировании; - не хранить ссылки на DOM/компоненты в глобальном store.
Пример cleanup в React (концепт):
useEffect(() => {
const handler = (e: MessageEvent) => setLastMessage(e.data);
window.addEventListener('message', handler);
return () => {
window.removeEventListener('message', handler);
};
}, []);
Как выстроить процесс: «правила» против утечек
Технические меры работают, когда есть правила разработки и наблюдаемости.
Правило 1: любой кэш — с лимитом
Если объект попадает в кэш, у него должны быть:
- TTL или хотя бы механизм эвакуации;
- max size;
- метрики: размер/хиты/эвикции.
Правило 2: любой буфер/очередь — с backpressure
- Явный предел.
- Ясная стратегия переполнения.
- Метрики глубины и возраста задач.
Правило 3: любую подписку — с планом жизненного цикла
- Кто подписался?
- Когда отписываемся?
- Что произойдёт при размонтировании компонента/закрытии сессии?
Правило 4: большие payload не должны задерживаться лишний раз
- Сжимать/стримить там, где возможно.
- В очередях хранить ссылки, не держать полный body, если обработка может задержаться.
- Вводить лимиты на размер входных данных.
Правило 5: релизы сопровождаются наблюдаемостью
На релизах проверяйте не только ошибки, но и:
- память/GC,
- рост очередей/кэшей,
- latency.
Утечки часто «включаются» при конкретном новом пути кода, который начинает получать больше входных данных.
Вывод: утечки памяти — это управляемые риски, если вы измеряете причинно-следственные цепочки
Утечки в вебе почти никогда не сводятся к одному «забыл освободить». Чаще это сочетание факторов: кэш без TTL, удержание контекста в очередях, некорректные подписки в фронтенде, или большие объекты, которые дольше живут в heap из-за логики жизненного цикла.
Главный практический вывод: ловить утечки нужно не только по факту роста RSS, а через метрики причин — размер кэша, глубину очереди, inflight payload, GC-паузы и тренды heap. Тогда расследование превращается в системную диагностику, а не в гадание по профилю «наугад».
Если хочется углубиться именно в наблюдаемость, диагностику и практики качественного профилирования, полезным стартом может быть курс по теме, например $/course/ — как формат, который помогает собрать в одну систему то, что обычно изучают по кускам из
Комментарии
Пока нет комментариев