Утечки в языке со сборкой мусора
Утечка здесь — это непреднамеренное удержание: живая ссылка держит мёртвый по духу объект достижимым. Полевой справочник по реальным причинам — таймеры, слушатели, растущие кэши, замыкания, закрепляющие Context, отсоединённый DOM, нарезанные строки, глобальные
Node-сервис стартует с 180 МБ RSS и набирает 40 МБ в день. Ни падения, ни ошибки, ни очевидного виновника — код «не аллоцирует ничего, что не освобождает», ведь в JavaScript вы ничего не освобождаете. GC работает безупречно. В этом-то и проблема: каждый байт, который он отказывается забрать, достижим, и что-то в вашем коде держит его таким. Утечка в языке с GC — это никогда не пропущенный free; это ссылка, о которой вы забыли, что всё ещё держите.
Удержание, а не «забыл освободить»
Из урока 01: GC держит всё достижимое от корня, потому что достижимость завышает живость. Утечка в языке со сборкой мусора — это та самая завышенная оценка, кусающая в ответ: объект, с которым вы логически закончили, остаётся достижимым через какую-то ссылку, которую вы не собирались держать. GC прав, удерживая его; баг в вашем графе ссылок, а не в сборщике.
Поэтому охота на утечки — это никогда не «где я забыл освободить?». Это «какой retainer path?» — цепочка ссылок от корня вниз к утёкшему объекту. Оборвите любое звено на этом пути, и объект становится недостижимым и собирается. Лечение всегда — обрыв ссылки, никогда не освобождение.
Это напрямую опирается на замыкания, закрепляющие свой Context (05-closures-scope/02-closure-memory): замыкание — один из самых частых случайных удержателей, потому что оно держит всё захваченное окружение живым, пока само замыкание достижимо.
Полевой справочник по реальным удержателям
1. Таймеры и интервалы, которые никогда не очищаются. setInterval держит свой колбэк достижимым навсегда из таблицы таймеров рантайма; замыкание колбэка держит живым всё, что захватило.
function startPolling(bigState) {
setInterval(() => check(bigState), 1000); // bigState pinned forever
}
// fix: const id = setInterval(...); clearInterval(id) on teardown2. Слушатели событий, которые никогда не снимаются. el.addEventListener('x', handler) заставляет цель удерживать handler, а замыкание handler удерживает всё, что захватило. На сменах маршрутов SPA это накапливается.
window.addEventListener('resize', onResize); // держит onResize + его замыкание
// исправление: window.removeEventListener('resize', onResize) при размонтировании3. Неограниченные кэши — Map/массив/Set как кэш, который только растёт. Контейнер в области модуля (корень), поэтому каждая запись удерживается, пока не удалена. Без ограничения или TTL он растёт монотонно.
const cache = new Map();
function memo(key, compute) {
if (!cache.has(key)) cache.set(key, compute(key)); // никогда не вытесняется
return cache.get(key);
}
// исправление: LRU с ограничением размера, TTL, или WeakMap если ключ — объект4. Замыкания, делящие Context, закрепляющий большую переменную. Несколько замыканий из одной области делят один объект Context; если хоть одно замыкание долгоживущее, весь Context — включая огромную переменную, которой пользовалось лишь другое замыкание, — остаётся живым. (См. 05-closures-scope/02-closure-memory.)
5. Отсоединённые узлы DOM, всё ещё ссылаемые из JS. Вы удаляете узел из документа, но переменная JS, массив или замыкание всё ещё на него ссылаются. Узел — и всё его поддерево — нельзя собрать. В браузерах это также держит живыми отсоединённые окна/iframe — тяжёлая утечка.
6. SlicedString, закрепляющий огромного родителя. huge.slice(0, 10) в V8 может породить SlicedString (внутреннее представление подстроки, хранящее смещение и длину в родительской строке вместо копии символов), который ссылается на исходную строку вместо копирования 10 символов, поэтому крошечная подстрока держит многомегабайтного родителя живым. Принудительно копируйте, когда держите маленький срез большой строки.
7. Глобальное накопление. Всё, повешенное на globalThis / window (или модульный синглтон), закреплено на время жизни процесса. Случайная глобальная (x = 1 без объявления в sloppy mode) — классика.
8. «Собрано, но не возвращено ОС». Иногда объект собран, но RSS не падает: куча была фрагментирована, поэтому V8 держит страницы. Это не утечка уровня JS — это фрагментация (территория compaction из урока 03), диагностируется иначе (RSS высок, heapUsed низок).
Все восемь паттернов объединяет одно: каждый — это непреднамеренный путь от корня к объекту. Без него GC собирал бы свободно; с ним GC просто делает свою работу. Когда RSS ползёт вверх в продакшене, мысленно трассируйте цепочку — контейнер → замыкание → объект — пока не найдёте звено, которого там быть не должно.
Слабые ссылки: дайте GC выиграть ничью
Когда нужно ассоциировать данные с объектом без удержания его живым, используйте слабую (weak) ссылку, которую GC игнорирует при вычислении достижимости:
WeakMap/WeakSet— ключи (WeakMap) или значения (WeakSet) держатся слабо. Когда единственные оставшиеся ссылки на объект-ключ слабые, запись собирается автоматически. Идеально для кэшей метаданных по объекту, ключуемых самим объектом, — без ручного eviction, без утечки.WeakRef— слабая ссылка на один объект;.deref()возвращает объект илиundefined, если он собран. Для кэшей, где значение должно исчезнуть под давлением памяти.FinalizationRegistry— регистрирует колбэк очистки, который может запуститься после сбора объекта. Используйте экономно: финализаторы не гарантированно запускаются, запускаются в неопределённое время и никогда не должны быть основой корректности — только best-effort очистка внешних ресурсов.
const meta = new WeakMap(); // keyed by DOM node, say
meta.set(node, { lastSeen: now }); // НЕ удерживает `node` живым
// когда `node` удалён и более не доступен по другим ссылкам, запись исчезает- Корневая причина (всегда)
- непреднамеренная достижимость
- Лечение (всегда)
- оборвать retainer path
- Топ-утечка Node
- неограниченный Map / Set кэш
- Топ-утечка браузера
- слушатели + detached DOM
- Метаданные по объекту
- WeakMap (авто-eviction)
- RSS высок, heapUsed низок
- фрагментация, не утечка
Heap snapshot показывает 50 000 объектов «request context», которые должны быть недолговечными. На что одно полезнее всего посмотреть, чтобы починить утечку?
Нужно кэшировать вычисленные метаданные по узлу DOM, но кэш не должен держать удалённые узлы живыми. Какая структура подходит?
Расставьте по порядку процесс диагностики и починки медленной утечки удержания.
- 1 Подтвердить рост: RSS / heapUsed монотонно лезет со временем
- 2 Снять heap snapshots с интервалами и сделать diff, чтобы найти тип объектов, растущий неограниченно
- 3 Открыть retainer path от растущего объекта вверх к его корню GC
- 4 Определить непреднамеренную ссылку на этом пути (таймер, слушатель, запись кэша, замыкание)
- 5 Оборвать её (clearInterval/removeEventListener/eviction/WeakMap), чтобы поддерево стало недостижимым
- 6 Снять снимок заново, чтобы подтвердить, что число объектов теперь ограничено
▸Частая ошибка
Тонкость: хвататься за WeakRef/FinalizationRegistry, чтобы «починить» утечку, которая на самом деле неограниченный кэш. Финализаторы не запускаются быстро и надёжно, поэтому не ограничат вашу память под нагрузкой — а код, зависящий от запуска финализатора, баговый по построению. Правильное лечение растущего кэша — настоящая политика eviction (LRU, max-size, TTL). Берегите WeakMap для ассоциативных данных по объекту, а WeakRef — для по-настоящему опциональных кэшей, а не как пластырь поверх отсутствующего eviction.
- 01Почему в языке со сборкой мусора всё ещё можно утечь память и каково общее лечение?
- 02Перечислите самые частые конкретные удержатели и их лечение.
- 03Когда использовать WeakMap, WeakRef и FinalizationRegistry и в чём ловушка?
В трассирующем GC утечка памяти — это никогда не пропущенный free, а непреднамеренное удержание. Сборщик держит каждый объект, достижимый от корня, и достижимость завышает живость, поэтому объект, с которым вы логически закончили, остаётся живым всякий раз, когда забытая ссылка всё ещё на него указывает. Поэтому диагностика — про retainer path: цепочку ссылок от корня GC вниз к утёкшему объекту. Частые конкретные удержатели — неочищенные таймеры, неснятые слушатели событий, неограниченные Map/Set/массив кэши, замыкания, чей общий Context закрепляет большую захваченную переменную, отсоединённые узлы DOM (и окна/iframe), всё ещё ссылаемые из JS, SlicedString, держащий огромного родителя, и накопление в глобальной или модульной области. Каждое лечение одной формы: оборвать звено на retainer path, чтобы поддерево стало недостижимым, — clearInterval, removeEventListener, eviction, скопировать срез или ключевать данные слабо. WeakMap/WeakSet дают автоматические ассоциации по объекту, которые GC игнорирует, WeakRef даёт опциональные кэши, а FinalizationRegistry — best-effort очистку, но ни один не заменяет настоящую политику eviction, и на финализаторы нельзя опираться для корректности. Наконец, отличайте настоящую утечку удержания (heapUsed растёт) от фрагментации (RSS высок, heapUsed низок). Теперь, когда RSS ползёт на продакшене и GC-логи чистые, первый рефлекс — сделать heap snapshot и прочитать retainer path, а не форсировать GC или поднимать лимит кучи.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
встречается в184
- Почему GraphQL получает N+1junior
- Механика DataLoader: батчинг на границе тикаmiddle
- Контракты batch-функции: порядок, формы, ошибкиmiddle
- Federation и lookahead: батчинг за пределами DataLoadermiddle
- Защита сложности запросов: depth, cost, persisted queriesmiddle
- Senior GraphQL API: scheduling-контракт, изоляция арендаторов, наблюдаемостьsenior
- Зачем идемпотентность: безопасные retryjunior
- Серверный state machine: четыре состояния idempotency keymiddle
- Outbox и inbox: effectively-once через dual-write границуmiddle
- Конкурентность и архитектура кеша для идемпотентности на масштабеsenior
- Наблюдаемость, production-инциденты и дизайн для глобального масштабаsenior
- Event loop: один поток, три очередиjunior
- Задачи, микрозадачи и scheduler.yield()middle
- Голодание микрозадач, длинные задачи и LoAFsenior
- Event loop Node.js: фазы, nextTick и задержка циклаsenior
- React, Vue и наблюдаемость INP в продакшенеsenior
- Render pipeline: шесть стадий от байтов до пикселейjunior
- Цена стадий и модель процесса рендерераmiddle
- Инвалидация, dirty-биты и containmiddle
- Слои композитора: продвижение, перекрытие и память GPUmiddle
- Флейм-стрип DevTools и жизненный цикл кадраmiddle
- Layout thrash: форсированная синхронная компоновкаsenior
- BeginMainFrame, анимации на потоке compositor и память GPUsenior
- Observability в проде: LoAF, INP и полная поверхность атакиsenior
- Что такое V8 и почему производительность различается в 100 разjunior
- Четырёхуровневый JIT-конвейер V8 и профилированная тиеризацияmiddle
- Hidden classes, деревья переходов и расположение в памятиmiddle
- Inline caches, состояния IC и деоптимизацияmiddle
- Orinoco GC: параллельный scavenger, конкурентная разметка и барьеры записиmiddle
- Спекулятивный движок TurboFan и ловушка deopt-loopsenior
- V8 в production: Isolates, сжатие указателей и реальные аварииsenior
- Жизненный цикл service worker и стратегии кешированияmiddle
- Граничные случаи service worker: version skew, долговременность и ловушка навигацииsenior
- Что делает реконсилер: render vs commitjunior
- Объект fiber и дерево с двойной буферизациейmiddle
- Чистота фазы render и подшаги фазы commitmiddle
- Реконсиляция: эвристики диффа и ловушка ключейmiddle
- Приоритетные lanes, time-slicing и useTransitionmiddle
- Bailout, мемоизация и tearingsenior
- React Profiler, компилятор и продакшн-наблюдаемостьsenior
- Стратегии рендеринга: SSG, SSR, ISR, streaming и гидратацияjunior
- SSG, SSR, ISR, streaming и RSC — как работает каждая стратегияmiddle
- Цена гидратации: selective, progressive, острова, resumabilitymiddle
- Hydration mismatch: причины, обнаружение и правило детерминизмаsenior
- RSC, стратегия на маршрут и production-наблюдаемостьsenior
- Core Web Vitals: что измеряют LCP, INP и CLSjunior
- CLS: почему происходят сдвиги лейаута и как их остановитьmiddle
- Трейдоффы метрик, RUM-атрибуция и цикл CI+полеsenior
- Общая картина: от URL до LCP до INP как эстафетаjunior
- Восемь слоёв трассировки: от service worker до второй навигацииmiddle
- Пять канонических поломок: где производство стабильно ломаетсяsenior
- Метод трёх треков: чтение трасс и построение системы мониторингаsenior
- Что такое cache stampede и почему он делает всё хужеjunior
- Лок и single-flight: ограничение параллельных rebuildmiddle
- XFetch: вероятностное раннее истечение без координацииmiddle
- Stale-while-revalidate и CDN request coalescingmiddle
- Детектирование stampede и дизайн TTL для продакшенаmiddle
- Метастабильный сбой, fencing-токены и production-постмортемыsenior
- Что такое отношение: таблицы, строки, ключи и ограниченияjunior
- Ограничения, ключи и типы данных Postgresmiddle
- Нормальные формы, денормализация и почему схемы «прилипают»middle
- JSONB, массивы и когда side table побеждаетmiddle
- Heap-хранилище, TOAST и выравнивание колонокsenior
- Целостность схемы: deferral, версионирование и сбои в продакшнеsenior
- Реляционная модель vs документные, wide-column, граф и key-valuesenior
- Index-only scan, Visibility Map и INCLUDEsenior
- Типичные сбои в продакшне и аудит индексовsenior
- pg_statistic, ANALYZE и производственная наблюдаемостьmiddle
- Производственные режимы отказа и стабильность плановsenior
- MVCC: как Postgres раздаёт согласованные снимкиjunior
- Заголовок tuple и механика снимковmiddle
- HOT-обновления и уровни изоляцииmiddle
- VACUUM, bloat и autovacuummiddle
- CLOG, XID wraparound и MultiXactsenior
- SSI и production-тюнинг autovacuumsenior
- Реальные провалы MVCC, deployment-паттерны и распределённые снимкиsenior
- Connection pool: зачем амортизировать стоимость backend Postgresjunior
- Режимы PgBouncer: session, transaction и statementmiddle
- Размер пула: формула (ядра × 2) + шпинделей и двухуровневый стекmiddle
- Исчерпание пула и idle-in-transaction: сценарий отказа в 3 ночиmiddle
- Миграция на transaction mode: план развёртывания и prepared statements в PgBouncer 1.21middle
- Процессная модель Postgres и почему увеличение max_connections снижает производительностьsenior
- Ландшафт пулеров 2026, serverless connection storms и полная таксономия отказовsenior
- Что такое миграция схемы и почему она заменяет ad-hoc DDLjunior
- ADD COLUMN: мгновенно в PG 11+ против перезаписи в старом Postgresjunior
- Режим отказа очереди блокировок: почему мгновенный DDL может заморозить базуmiddle
- Безопасные DDL-паттерны: NOT VALID, CONCURRENTLY и исправления небезопасных операцийmiddle
- Expand-contract: нулевой простой для ломающих изменений схемыmiddle
- Advisory-блокировки, инструменты миграций и координация деплояsenior
- Таксономия сбоев миграций и дисциплина продакшнаsenior
- Зачем нужно шардирование: потолок одного Postgresjunior
- Выбор ключа шарда: стратегии hash, range, list и directorymiddle
- Партиционирование против шардирования: одно слово, два разных понятияmiddle
- Ко-локация и Citus: инвариант, делающий шардирование пригодным к использованиюmiddle
- Режим отказа hot shard: обнаружение, изоляция и долгосрочная политикаmiddle
- Schema-based шардирование и альтернативы мультиарендностиsenior
- Онлайн-решардинг, 2PC и операционная стоимость шардированияsenior
- Семь актов: от CREATE TABLE до Citusjunior
- Акты 1–3 в глубину: схема, индексы и статистика планировщикаmiddle
- Акты 4–6 в глубину: MVCC bloat, connection pooling и безопасные миграцииmiddle
- Акт 7 в глубину: шардинг, co-location и семиуровневый каскад трейдоффовmiddle
- Наблюдаемость, антипаттерны и производственный триажsenior
- Роли Raft, term и почему majority-кворум предотвращает split brainjunior
- Как Raft реплицирует log entry и решает, что его безопасно коммититьmiddle
- Выборы лидера в Raft: таймауты, правила голосования и четыре свойства безопасностиmiddle
- Raft в реальном мире: partition, медленный диск и клиентская маршрутизацияmiddle
- Расширения Raft: pre-vote, learner, snapshot и линеаризуемые чтенияsenior
- Raft в production: membership change, Multi-Raft и observabilitysenior
- Где происходит data fetching — и почему это решает LCPjunior
- Fetch waterfall''''ы — диагностика и лечение через Promise.allmiddle
- React Server Components и Suspense streamingmiddle
- Клиентский кэш: TanStack Query, SWR и stale-while-revalidatemiddle
- LCP, prefetch и race conditions в интерактивном fetchingmiddle
- Senior internals: RSC payload, слои кэша и production паденияsenior
- Трёхстороннее рукопожатие TCPjunior
- Номера последовательности и состояние соединенияmiddle
- DNS: что делает и зачем существуетjunior
- Обход резолвера: перенаправления, типы записей и gluemiddle
- TTL, кеширование и распространение DNSmiddle
- Рукопожатие за 1 RTT: key share и ECDHEmiddle
- Возобновление сессии и 0-RTTmiddle
- WebSocket: HTTP-апгрейд до постоянного соединенияjunior
- Формат WebSocket-фрейма: opcodes, маскирование, фрагментацияmiddle
- Backpressure в WebSocket: когда клиенты не успеваютmiddle
- Реконнект: jittered backoff, thundering herd, восстановление сообщенийsenior
- WebSocket в масштабе: HTTP/2 мультиплексирование, permessage-deflate, C10Msenior
- WebSocket в production: прокси, безопасность и распределённая архитектураsenior
- Что делают обратные проксиjunior
- Health checks, connection draining и slow startmiddle
- Session affinity, consistent hashing и правильное решениеmiddle
- Retry-бури, circuit breakers и load sheddingsenior
- Устойчивая архитектура LB: anycast, zone-aware маршрутизация и observabilitysenior
- Почему QUIC, а не TCP+TLSjunior
- Connection ID и миграция сетиmiddle
- Возобновление 0-RTT и шифрование пакетовsenior
- DDoS: что это и почему работаетjunior
- Атаки усиления и истощение состоянияmiddle
- Ограничение скорости: алгоритмы и архитектураmiddle
- WAF, межсетевые экраны, mTLS и HSTSmiddle
- Отравление DNS-кэша и BGP-перехватsenior
- Эшелонированная защита и экономика атакsenior
- DNS, TCP, TLS по очереди: куда уходят миллисекундыmiddle
- Перехват прокси и шлюзы безопасности: rate limiter, WAF, mTLSmiddle
- Альтернативные пути: QUIC 0-RTT, WebSocket upgrade, миграция соединенияmiddle
- Наблюдаемость: распределённые трейсы, USE/RED и семплированиеsenior
- Устойчивость: каскадные повторы, circuit breakers и error budgetsenior
- Что такое три сигнала: метрики, логи, трейсыjunior
- Зачем нужны структурные логи: дневник против таблицыjunior
- Схема продакшн-лога: поля, которые несёт каждая строкаmiddle
- PII-редакция и log injectionsenior
- OTel Logs Data Model и audit-логи как подсистемаsenior
- SLI, SLO и error budget: надёжность в числахjunior
- Error budget policy, latency SLO и составные journeysmiddle
- Продакшн-отказы SLO, самонаблюдаемость, безопасность и общая картинаsenior
- Петля инцидента: от пейджера до постмортема до предотвращенияmiddle
- Cache lines и false sharing: когда параллелизм замедляет кодmiddle
- SIMD и data layout: AoS vs SoA и разница в 4–8xmiddle
- Cache-oblivious алгоритмы, PGO и production failuressenior
- GC в production: наблюдаемость, безопасность, edge cases и управление флотомsenior
- Batching: амортизируй фиксированную цену каждой операцииjunior
- Окно батчинга: размер и время ожиданияmiddle
- Batching в Kafka и Postgresmiddle
- io_uring и наблюдаемость пакетированияmiddle
- От Nagle до io_uring: эволюция пакетированияmiddle
- Backpressure, изоляция сбоев и безопасность батчей в продакшенеsenior
- CI enforcement и RUM: делаем бюджеты рабочимиmiddle
- V8 JIT-пайплайн, HTTP-приоритеты и безопасность bundlesenior
- Цикл performance: дисциплина, а не проектjunior
- Классификация и исправление: сопоставление family bottleneck с методамиmiddle
- Observability-стек и CI gates: ловить регрессии до выпускаmiddle
- От инцидента к enforcement: SLO burn до верифицированного исправления за 35 минутmiddle
- Культура, экономика и масштаб performancesenior
- At-most-once, at-least-once, exactly-once: три контракта доставкиjunior
- Три ножки сбоя — где реально происходят дубликаты и потериmiddle
- Consumer-side dedup: самый дешёвый путь к exactly-once processingmiddle
- Kafka exactly-once semantics: idempotent producer и транзакцииmiddle
- SQS visibility timeout, DLQ и outbox patternmiddle
- Exactly-once в production: impossibility-доказательство, гибридные паттерны и реальные инцидентыsenior
- Что такое OAuth и почему пароли — не ответjunior
- Authorization code flow с PKCEmiddle
- Валидация ID-токена и управление JWKS-кешемmiddle
- Ротация refresh-токенов и scope-based least privilegemiddle
- Sender-constrained токены: DPoP и mTLSsenior
- OAuth в production: audience атаки, observability и реальные провалыsenior
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.