24 нояб. 2025 г.·8 мин. чтения

Утечка памяти Node.js: как найти бегущие слушатели, кэши и таймеры

Найдите утечку памяти в Node.js: выявите бегущие слушатели, растущие кэши и таймеры, затем подтвердите исправление снимками кучи и повторяемыми тестами.

Утечка памяти Node.js: как найти бегущие слушатели, кэши и таймеры

Как выглядит утечка памяти в Node.js приложении

Утечка памяти в Node.js — это когда приложение продолжает удерживать память, которая ему больше не нужна. Важная деталь: использование памяти растёт со временем и никогда полностью не возвращается в исходное состояние, даже после того, как работа, вызвавшая пик, завершилась.

В продакшене это часто проявляется как медленный прирост: всё выглядит нормально минуты или часы, затем ответы становятся медленнее, процесс чаще запускает GC, и в конце концов приложение перезапускается или падает с ошибкой out-of-memory. При наблюдении базовых метрик вы можете увидеть, что RSS и использование кучи растут в течение деплоев, циклов трафика или фоновых заданий.

Типичные признаки:

  • Память растёт после каждого запроса или выполнения задания
  • Паузы GC становятся длиннее и происходят чаще
  • Перезапуски становятся предсказуемыми (например, каждую ночь после пакетной задачи)
  • Контейнеры достигают лимитов памяти даже при нормальном трафике
  • Приложение замедляется без очевидного всплеска CPU

Не всё, что растёт, — это утечка. Некоторая динамика нормальна: прогрев кэша, одноразовая компиляция или всплеск трафика, который стабилизируется по снижении нагрузки. Также часть «утечек» на самом деле — это намеренный кэш, который слишком велик или не имеет ограничений. Разница в том, стабилизируется ли использование памяти. Здоровое приложение может прыгнуть вверх, а затем держаться около базовой линии. Утечка постоянно поднимает новые базовые линии.

Простой пример: прототип добавляет слушатель событий на каждый запрос, но никогда не убирает его. Каждый запрос «заканчивается», но слушатель держит ссылку на данные этого запроса. Память растёт шаг за шагом, пока это не станет серьёзной проблемой.

Чтобы исправлять утечки без догадок, вам нужны две вещи: воспроизводимый способ вызвать рост и способ доказать, что исправление сработало. Это доказательство обычно выглядит как выравнивание памяти и снимки кучи, показывающие, что «растущие» объекты больше не растут.

Быстрая проверка: убедитесь, что вы ищете именно утечку

Прежде чем охотиться за утечкой Node.js, убедитесь, что приложение действительно течёт (память растёт и остаётся высокой), а не просто обрабатывает временный пик (память поднялась, а затем упала после GC).

Начните с одновременного наблюдения нескольких метрик. Одна метрика может ввести в заблуждение, особенно в прототипном коде, где «быстрые исправления» часто закрывают реальную причину.

  • Process memory (RSS): общая память, которую ОС показывает для процесса.
  • Heap used: объекты JavaScript, управляемые V8.
  • Event loop lag: если растёт, приложение застревает на работе или GC трещит.
  • Request latency: утечки часто проявляются как замедление ответов со временем.
  • Error rate / timeouts: «утечка» иногда — это штормы повторных попыток или зависшие фоновые задания.

Далее отличите «рост кучи» от «роста нативной памяти» на общем уровне. Если heap used продолжает расти в течение нескольких минут стабильной нагрузки, вероятно, вы удерживаете JS-объекты (слушатели, кэши, замыкания, массивы, Maps). Если RSS растёт, а heap used остаётся в основном плоским, подозревайте память вне JS-кучи: большие Buffers, незакрытые потоки, нативные аддоны или библиотеки логирования/метрик, которые накапливают данные.

Утечки из прототипов обычно происходят по одним и тем же привычкам: глобальные синглтоны, накапливающие состояние, кэши без лимитов, слушатели событий, добавляемые на каждый запрос, и таймеры, запускаемые без пути остановки. Эти паттерны часто встречаются в коде, сгенерированном или сильно изменённом AI-инструментами, где код «работает один раз», но не очищается.

Поставьте простую цель перед тем, как менять код: воспроизведите рост за минуты, а не дни. Выберите один маршрут или задачу, которая вызывает проблему, примените стабильную нагрузку и определите «успех» так: после старта теста память увеличивается повторяемым образом (например, +20 MB каждые 2 минуты). Как только это есть, вы сможете доказать исправление позже.

Если вы получили в наследство запутанный прототип и не можете даже получить стабильные измерения, это именно тот момент, когда быстрый аудит (как делает FixMyMess) может выявить самые большие риски удержания, прежде чем вы перепишете половину приложения.

Сделайте утечку воспроизводимой, прежде чем менять код

Трудно исправить утечку, если она проявляется только «иногда». Прежде чем что-то менять, превратите проблему в повторяемое действие, которое вызывает рост памяти по команде.

Начните с выбора одного триггера, соответствующего реальному использованию. Хорошие кандидаты — один HTTP-роут, тик фоновой задачи, цикл подключения/отключения WebSocket или задача импорта. Выберите минимальное действие, которое всё ещё вызывает прирост.

Затем повторяйте этот триггер в жёстком цикле. Можно сделать это вручную (обновить ту же страницу 50 раз), но маленький скрипт лучше, он убирает человеческую вариативность. Цель не в нагрузочном тестировании, а в консистентности.

Держите переменные стабильными при воспроизведении:

  • Используйте одну и ту же учётную запись и права доступа при каждом прогоне
  • Используйте одинаковый размер входных данных (тот же payload, то же количество записей)
  • Используйте одну среду (локально или staging, не смешивайте)
  • Перезапускайте приложение между экспериментами, чтобы начать с чистой базы
  • Отключите неподходящий трафик, чтобы фоновый шум не скрывал паттерн

Теперь определите чёткую метрику «прохождение/провал». Простая: после остановки цикла и форсированного GC во время теста куча должна вернуться близко к базовой линии. Если она продолжает расти от прогона к прогону, у вас есть надёжный репро.

Конкретный пример: допустим, память растёт, когда пользователи открывают экран «live updates». Сделайте цикл, который соединяется с WebSocket, ждёт 3 секунды, затем отключается, и повторите 200 раз. Если куча растёт с каждым циклом подключения, вы сузили утечку до слушателей, таймеров или кэшей на соединение.

Здесь же часто ломаются прототипы. Если код был сгенерирован инструментами типа Replit, v0 или Cursor и трудно сделать утечку повторяемой, FixMyMess может провести быстрый аудит, чтобы изолировать то действие, которое надёжно вызывает рост, прежде чем вы потратите часы на догадки.

Снимки кучи: инструмент, который делает утечки видимыми

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

Что он показывает: какие типы объектов растут (массивы, Maps, строки, замыкания) и пути удержания, которые держат эти объекты. Чего он не покажет: точную строку кода, которая создала объект, или сам по себе скажет, что одноразовый пик — «плохо». Вам всё ещё нужен воспроизводимый тест и немного детективной работы.

Ключевая идея в снимках — удерживающие ссылки (retainers). Объект не будет собран сборщиком мусора, если что-то всё ещё ссылается на него. Retainers — это цепочка ссылок, которые держат объект живым, например: глобальный синглтон держит Map, Map держит полезные нагрузки запросов, а эти payloads содержат большие строки. «Утечка» обычно не в самом видимом объекте, а в удерживающей сущности, которую следовало очистить.

Планируйте брать три снимка, чтобы можно было сравнить, что растёт со временем:

  • Базовый: сразу после старта, до нагрузки.
  • Снимок 2: после известного количества операций (например, 200 запросов).
  • Снимок 3: после большего количества тех же операций (например, 600 запросов).

Если одни и те же группы объектов увеличиваются со снимка 2 к снимку 3, это сильный сигнал. Если память растёт, но количество объектов остаётся стабильным, возможно, вы смотрите на Buffers, нативные аддоны или нормальные кэши.

Конфиденциальность имеет значение. Снимки кучи могут содержать пользовательские данные, токены, cookies, payloads запросов и даже секреты, которые прототипы иногда логируют или хранят в памяти. Обращайтесь со снимками как с продакшн-данными: храните их аккуратно, делитесь ограниченно и удаляйте, когда закончите.

Пошагово: снимайте и сравнивайте снимки, чтобы найти рост

Подтвердите исправление
Мы используем снимки кучи, чтобы подтвердить, что «растущие» объекты перестали расти.

Когда вы подозреваете утечку в Node.js, снимки кучи — самый быстрый способ прекратить гадания. Фокус в том, чтобы делать снимки вокруг повторяемого действия, чтобы видеть, что растёт с каждым разом.

1) Сделайте три снимка вокруг одного и того же действия

Запустите приложение так, чтобы можно было делать снимки кучи (через отладчик или инспектор). Затем повторяйте одно действие пользователя, которое, как вы полагаете, вызывает утечку (запрос, загрузка страницы, запуск фоновой задачи).

Используйте простой ритм:

  • Снимок A: базовый сразу после того, как приложение «устаканилось»
  • Выполните одно и то же действие N раз (начните с 20–50)
  • Снимок B: снимок сразу после
  • Выполните действие ещё N раз
  • Снимок C: третий снимок

Если B больше, чем A, а C больше, чем B на похожую величину, то стабильный прирост на итерацию — сильный сигнал утечки.

2) Сравните снимки и проследите путь удержания

Откройте режим сравнения между A и B (а затем между B и C). Сфокусируйтесь на типах объектов, которые растут, а не на одноразовых всплесках.

Ищите:

  • Имена конструкторов, которые постоянно растут (например: Array, Map, Listener, Timeout, Buffer)
  • Коллекции, которые увеличиваются (записи Map, элементы Set, кэшированные объекты)
  • Отсоединённые или «недостижимые» на первый взгляд объекты, которые всё ещё удерживаются
  • Путь удержания (что держит объект в памяти)

Путь удержания — самое ценное. Часто он указывает на глобальный синглтон, экспорт модуля, эмиттер событий или список таймеров.

3) Запишите наблюдения перед изменением кода

Во время анализа делайте короткие заметки, чтобы не потерять нить:

  • Топ-2–3 имён конструкторов, которые растут
  • Корень удержания (global, экспорт модуля, замыкание обработчика запроса)
  • Любые подсказки по файлам или модулям, показанные в снимке
  • Примерная скорость роста (например: +500 объектов на 50 запросов)

Этот короткий список делает исправление сфокусированным. Это также те же доказательства, которые команды вроде FixMyMess используют в бесплатном аудите, чтобы указать, связано ли всё с слушателями, кэшами или таймерами до правки кода.

Бегущие слушатели: самая распространённая утечка в прототипах

Классическая утечка в Node.js в прототипных приложениях простая: слушатель добавляется снова и снова, но никогда не удаляется. Память растёт медленно, затем процесс начинает зависать, таймить или падать.

Часто это происходит, когда «функция настройки» выполняется на каждый запрос, переподключение или тик задания и делает что-то вроде emitter.on(...), не проверяя, не подписан ли уже. Каждый новый слушатель может удерживать дополнительные данные, особенно когда обработчик замыкает на объекты запроса, данные пользователя или большие Buffers.

Типичные места появления:

  • Экземпляры EventEmitter, используемые как глобальная шина
  • Подключения WebSocket, которые переподключаются и повторно подписываются
  • HTTP-потоки, где обработчики data и error накапливаются
  • Клиенты БД, которые добавляют слушателей на каждый запрос
  • События process вроде uncaughtException или SIGTERM, регистрируемые многократно

Снимки кучи могут выдать этот паттерн. Ищите растущее количество функций в массивах слушателей (часто на эмиттере) или много похожих замыканий, которые ссылаются на одни и те же внешние переменные. Сильная подсказка — видеть удерживаемые объекты, похожие на данные запроса/ответа, висящие на контексте функции-слушателя или её замыканиях.

Конкретный пример: в маршруте Express вызывается subscribeToUpdates(userId) при каждом запросе, и эта функция делает ws.on('message', ...). Если она никогда не отписывается, когда запрос заканчивается (или пользователь отключается), то WebSocket держит старые обработчики и их захваченные данные.

Исправления обычно скучные, но эффективные:

  • Используйте once для событий, которые должны сработать один раз
  • Вызывайте off/removeListener в ходе очистки (при отключении, завершении запроса, окончании задания)
  • Избегайте подписок на глобальные эмиттеры на каждый запрос; маршрутизируйте события через объекты с областью действия
  • Сохраняйте ссылку на функцию-обработчик, чтобы затем удалить ту же самую ссылку
  • Добавляйте защиту: логируйте listenerCount и относитесь к предупреждениям как к багам

Если вы унаследовали код, сгенерированный AI, с такими паттернами, FixMyMess часто начинает с картирования жизненного цикла слушателей и удаления скрытых ловушек «подписаться при каждом вызове» перед любыми другими правками.

Кэши, которые только растут: Map, memoization и глобальные синглтоны

Многие «утечки памяти» в прототипах не мистические баги. Это кэши, которые никогда не очищаются. В поисках утечки Node.js это одно из первых мест, куда стоит смотреть, потому что оно часто выглядит как «приложение работает нормально», пока трафик или время не сделают кэш огромным.

Классический паттерн — Map или обычный объект, использованный как быстрый lookup, но без ограничения размера и без истечения срока. Если ключ строится из пользовательского ввода (поисковые запросы, URL, заголовки, ID пользователей, prompts), число уникальных ключей может расти бесконечно.

Что искать

Начните с поиска всего, что хранит данные между запросами: переменные уровня модуля, синглтоны или «хелперы», экспортирующие кэш.

Несколько распространённых виновников:

  • Map для дедупликации запросов (inFlightRequests.get(key)), который никогда не удаляет записи на путях ошибок
  • Memoization дорогих функций, где ключ — необработанный ввод
  • Глобальная карта «последних ответов» для отладки или аналитики
  • Кэши, которые хранят целые объекты ответа, строки из БД или Buffers
  • Данные, подобные сессиям, сохранённые в памяти вместо реального стореджа

Небольшой сценарий, который быстро течёт: вы кэшируете GET /search?q=... по полному query string. Через неделю у вас сотни тысяч уникальных запросов, и каждое значение содержит большой JSON. Снимки кучи часто покажут крупный Map (или Object), удерживающий массивы, строки и вложенные объекты.

Более безопасные шаблоны кэша

Исправление обычно означает сделать кэш похожим на кэш, а не архив:

  • Добавьте жёсткий максимальный размер (evict LRU или старейшие записи)
  • Добавьте TTL и очистку по интервалу, который можно остановить
  • Нормализуйте ключи (lowercase, trim, сортировка params), чтобы уменьшить взрыв уникальных ключей
  • Храните ID или маленькие сводки, а не целые объекты или raw-ответы
  • Всегда удаляйте записи при ошибке и на путях тайм-аута

Если вы унаследовали AI-сгенерированный прототип, эти кэши часто разбросаны по «utility» файлам и синглтонам. FixMyMess часто находит 2–3 отдельных растущих Map в одном кодовой базе, каждый хранит больше, чем нужно.

Интервалы и фоновые циклы, которые никогда не останавливаются

Стабильность и безопасность
Пока фиксируем утечки, также усиливаем аутентификацию и удаляем открытые секреты.

Таймеры — лёгкий путь к утечке в Node.js, особенно в приложениях, выросших из прототипов. Классическая ошибка — создать новый setInterval() (или каскадный setTimeout()) внутри обработчика запроса и никогда не очищать его. Каждый запрос добавляет ещё один фоновый цикл, который держит ссылки живыми.

Это часто происходит с «быстрыми» фичами: опрос внешнего API, повторные попытки для неудачных задач, проверка очереди или обновление кэша. Когда такой код находится внутри маршрута или установки на пользователя, таймер замыкает на данные запроса (user id, токен аутентификации, payload) и это замыкание остаётся в памяти, пока существует таймер.

Реалистичный пример: маршрут Express /start-sync устанавливает интервал для опроса прогресса каждые 2 секунды. Если пользователь обновляет страницу или вызывает маршрут дважды, у вас уже два интервала для одного и того же пользователя. Умножьте это на реальный трафик — память будет расти.

Снимки кучи дадут сильные подсказки. Часто вы увидите растущее количество объектов, связанных с таймерами, и удерживаемые замыкания, указывающие на объекты запроса или сессии. Если сравнение снимков показывает больше объектов типа «listeners» и «Timeout» после каждого прогона, список таймеров растёт.

Рабочие паттерны исправления, которые обычно помогают:

  • Создавайте плановые таймеры при старте, а не внутри маршрутов.
  • Сохраняйте ID таймеров и всегда вызывайте clearInterval() или clearTimeout() при завершении задания.
  • Привязывайте жизнь таймера к соединению: отменяйте при отключении, логауте или закрытии WebSocket.
  • Защищайте от дубликатов (например, один интервал на пользователя или рабочее пространство).
  • Предпочитайте один рабочий цикл, который вытаскивает задания из очереди, вместо отдельного таймера на запрос.

После изменения повторите ту же нагрузку и сделайте новые снимки кучи. Если исправление реально, объекты, связанные с таймерами, перестанут расти и память начнёт выравниваться.

Если ваше приложение было сгенерировано в инструментах типа Lovable, Bolt или Replit и таймеры разбросаны по маршрутам, FixMyMess может провести быстрый аудит и точно указать, где создаются циклы и почему они не останавливаются.

Докажите исправление: повторите тест и подтвердите, что память стабилизируется

Реальное исправление меняет то, что приложение удерживает в памяти. Маска меняет лишь то, что вы замечаете. Перезапуск сервера, увеличение памяти контейнера или форсированный GC могут временно улучшить графики, но утечка останется.

Отнеситесь к шагу подтверждения как к лабораторному эксперименту. Используйте те же шаги воспроизведения, тот же размер данных и те же настройки рантайма, что и при первом обнаружении утечки.

Сделайте новый набор снимков кучи: один при чистом старте, один после того, как утечка успела вырасти, и (если в вашем тесте есть стадии охлаждения) один после остановки нагрузки. Затем сравните их с предыдущими «до» снимками.

Вы закончили, когда выполнены два условия:

  • Типы объектов, которые раньше росли (например, массивы слушателей, записи Map, кэшированные ответы, замыкания таймеров), перестали увеличиваться между снимками.
  • После окончания нагрузки куча поднимается и опускается, затем выравнивается в пределах стабильного диапазона, вместо того чтобы расти после каждого прогона.

Простой пример: вы убрали лишний setInterval, создававшийся на каждый запрос. В следующем прогоне снимок два больше не показывает тысячи одинаковых колбэков интервалов, удерживающих данные запросов, и снимок три не должен быть заметно выше снимка два.

Если утечка «исправлена», но куча всё ещё растёт, перепроверьте, не перенесли ли вы рост в другое место. Частые маскировки включают добавление LRU-кэша без ограничения размера или удаление слушателей в одном пути, но не на путях ошибок.

Для долгосрочной безопасности добавьте небольшую регрессионную проверку перед релизом. Держите её короткой и простой, достаточно, чтобы поймать возвращение того же паттерна:

  • Прогоните небольшую нагрузку 2–5 минут на staging-сборке.
  • Запишите пиковые RSS/heap и пометьте как провал, если они растут свыше разумного порога.
  • Опционально сохраните один снимок кучи и сравните счётчики удерживаемых объектов.

Если вы унаследовали прототип от Bolt, Lovable, v0, Cursor или Replit, этот шаг подтверждения часто выявляет «ещё один» путь утечки. FixMyMess обычно запускает цикл «повторить и сравнить» после исправлений, чтобы изменение было подтверждено, а не просто на него надеялись.

Распространённые ошибки, которые тратят часы

Узнать, что течёт
Узнайте, связано ли это с JS-кучей, Buffer'ами или нативной памятью.

Один снимок — недостаточно. Один снимок кучи может показать множество объектов, но он не скажет, что растёт. Нужны как минимум два снимка, сделанные в тех же точках теста, чтобы сравнить и увидеть, какие конструкторы и пути удержания увеличиваются.

Шум — другой убийца времени. Если вы бьёте приложение смешанными паттернами трафика (логин, загрузки, cron, случайные страницы), вы увидите рост, который трудно объяснить. Сдерживайте один повторяемый цикл, который триггерит подозреваемую утечку, и меняйте в каждой итерации только одну вещь.

Также легко свалить всё на сборщик мусора. Node.js GC может выглядеть «ленивым» под нагрузкой, но если память продолжает расти и не опускается, значит что-то всё ещё сильно ссылается. Обычные виновники — глобальные Maps, массивы в модулях, замыкания, захватывающие большие объекты, и слушатели, добавляемые на каждый запрос и никогда не удаляемые. Когда вы гонитесь за утечкой, фокусируйтесь на том, что держит ссылки, а не на настройках GC.

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

Что делать вместо этого

Стремитесь к исправлениям, которые делают рост невозможным:

  • Добавьте лимиты размера и политику вытеснения (TTL или LRU) для in-memory кэшей.
  • Убедитесь, что слушатели регистрируются один раз или удаляются при очистке.
  • Останавливайте таймеры и интервалы, когда работа завершена или сокет закрыт.
  • Избегайте хранения объектов запроса, сессий или больших ответов в глобалах.

Пример: прототип добавляет setInterval на каждую сессию пользователя для «обновления данных», но никогда не очищает его при логауте. Куча выглядит случайно, пока вы не прогоните цикл логин/логаут и не сравните снимки. Тогда один удерживаемый колбэк таймера появляется как корень.

Если вы унаследовали AI-сгенерированное Node-приложение и та же утечка возвращается после быстрых заплаток, FixMyMess обычно начинает с короткого аудита, чтобы найти точный путь удержания, затем применяет реальную очистку и лимиты, чтобы это оставалось исправленным.

Быстрый чек-лист и дальнейшие шаги

Утечку памяти Node.js легко обсуждать, но трудно доказать. Используйте этот быстрый чек-лист, чтобы держать работу в фокусе и убедиться, что вы можете показать: утечка ушла, а не «всё стало лучше на моей машине».

Быстрый чек-лист

  • Можете ли вы воспроизвести рост памяти за < 10 минут повторяемым тестом (одни и те же endpoint'ы, те же payload'ы, та же конкуренция)?
  • Сняли ли вы как минимум 3 снимка кучи (базовый, средний, перед падением) и сравнили, что растёт между ними?
  • Проверили ли вы привычные подозреваемые: слушатели событий, in-memory кэши (Maps, массивы, memoization) и таймеры/интервалы?
  • После правок, перезапустили ли вы тот же тест и подтвердили, что память стабилизируется (и циклы GC не продолжают расти)?
  • Проверили ли вы «вторичные сигналы», указывающие на причину, такие как listener counts, open handles и постоянно растущее число ключей в Map?

Если хотя бы один пункт не пройден — приостановитесь и исправьте процесс сначала. Большая часть потраченного времени уходит на изменения кода до того, как вы сможете надёжно воспроизвести проблему, или на попытки угадать по одному снимку.

Дальше

Как только вы подтвердили стабильность, не дайте победе ускользнуть:

  • Добавьте простой soak-тест в релизный процесс (10–20 минут обычно хватает, чтобы поймать регрессии).
  • Поставьте ограничители вокруг кода, склонного к росту: лимитируйте кэши, удаляйте слушатели при очистке и останавливайте интервалы по завершении работы.
  • Документируйте «владение» фоновыми циклами и синглтонами, чтобы они не умножались по мере развития приложения.

Если приложение началось как AI-сгенерированный прототип, утечки часто происходят из запутанного глобального состояния, дублированных слушателей или фоновых задач, добавленных в ходе экспериментов и так и не удалённых. В таких случаях целевой аудит и точечный рефакторинг обычно быстрее, чем заплатки по частям. FixMyMess может провести бесплатный аудит кода, указать, что растёт, и помочь превратить прототип в продакшен-код с подтверждёнными исправлениями.