Управление памятью в Node.js: куча, утечки, профилирование

Содержание

О проверке кода. Примеры не запускались в этой сессии (по договорённости — фокус на контенте, а не прогонах). Говорим прямо, а не ставим бейдж «проверено».

Node.js управляет памятью автоматически через сборщик мусора движка V8. Но «автоматически» не значит «беспроблемно»: на сервере, работающем сутками, утечки и упор в лимит памяти — реальная угроза. Разберём, как это контролировать.

Измерение: process.memoryUsage

const usage = process.memoryUsage();

usage.rss;        // Resident Set Size — вся память процесса
usage.heapTotal;  // выделено под кучу V8
usage.heapUsed;   // реально занято объектами в куче
usage.external;   // память вне кучи (буферы, C++ объекты)

// в мегабайтах, читаемо
console.log(`Куча: ${(usage.heapUsed / 1024 / 1024).toFixed(1)} МБ`);

process.memoryUsage() — основа мониторинга памяти. Ключевые поля:

  • rss — вся память процесса в ОЗУ (куча + стек + код);
  • heapUsed — сколько реально занято объектами JavaScript. Именно за его ростом следят при поиске утечек;
  • external — память буферов и нативных объектов вне кучи V8.

Периодический вывод heapUsed в лог — простейший способ заметить, что память ползёт вверх и не возвращается.

Как устроена куча V8

Память процесса Node.js

Куча V8 (heap)

Объекты JavaScript

  • Здесь живут все объекты, строки, массивы
  • Управляется сборщиком мусора
  • Ограничена лимитом (~2–4 ГБ)
  • heapUsed — что занято

Вне кучи

Буферы и служебное

  • Buffer и нативные объекты (external)
  • Стек вызовов
  • Код и метаданные
  • Не ограничено лимитом кучи

Объекты JavaScript живут в куче V8, которая ограничена по размеру. Сборщик мусора освобождает недостижимые объекты, но сама куча не может расти бесконечно — есть лимит. Буферы и бинарные данные хранятся вне кучи (external), поэтому обработка больших файлов через буферы не упирается в лимит кучи так быстро.

Лимит памяти и его изменение

# по умолчанию куча ограничена (порядок 2–4 ГБ в зависимости от версии)
node app.js

# поднять лимит до 4 ГБ
node --max-old-space-size=4096 app.js

Куча V8 имеет лимит, и упор в него роняет процесс с JavaScript heap out of memory. Флаг --max-old-space-size (в мегабайтах) поднимает потолок — это спасает при легитимной большой нагрузке (обработка крупных данных). Но если память растёт бесконечно, увеличение лимита лишь оттягивает падение: настоящая причина — утечка, и лечить надо её, а не потолок.

Утечки памяти

Утечка — когда объекты достижимы, но уже не нужны, и сборщик не может их удалить. Причины те же, что в браузере, но на сервере опаснее — процесс живёт долго:

// ❌ глобальный кэш растёт без предела — классика серверной утечки
const cache = new Map();
app.get('/data/:id', (req, res) => {
  cache.set(req.params.id, expensiveComputation());   // никогда не чистится
  res.json(cache.get(req.params.id));
});

Частые источники на сервере:

  • растущие коллекции — кэш, массив истории, Map без ограничения и очистки;
  • незакрытые таймерыsetInterval без clearInterval;
  • накопление слушателейemitter.on в обработчике запроса без снятия (предупреждение о утечке слушателей);
  • замыкания, держащие большие объекты.

На долгоживущем сервере даже маленькая утечка на каждый запрос за сутки съедает всю память.

Поиск утечки: heap snapshot

// быстрая диагностика: логировать heapUsed под нагрузкой
setInterval(() => {
  const mb = (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1);
  console.log(`heapUsed: ${mb} МБ`);
}, 10000);

Если heapUsed растёт и не возвращается после сборок мусора — есть утечка. Дальше — точный поиск через heap snapshot:

  1. запустить с --inspect, открыть DevTools → Memory;
  2. снять snapshot, дать нагрузку, снять второй;
  3. сравнить (Comparison) — что за объекты выросли в числе;

Растущий тип объектов укажет и на причину, и на место, где копятся ссылки. Это стандартная процедура диагностики утечек в production.

Главный приём экономии: потоки

// ❌ грузит весь файл в память — на 4 ГБ файле упадёт
const data = fs.readFileSync('huge.csv');

// ✅ поток — в памяти лишь текущий кусок
fs.createReadStream('huge.csv')
  .pipe(parser)
  .pipe(writer);

Главный способ не забивать память — не загружать данные целиком. readFile читает весь файл в кучу; потоки обрабатывают его по кускам, держа в памяти лишь текущий фрагмент. Файл на 4 ГБ через readFile уронит процесс, а через поток обработается на скромной памяти. Это же касается больших ответов БД и сетевых данных — думайте потоками, а не «загрузить всё, потом обработать».

Практический чек-лист

  • логируйте heapUsed в production — заметите рост заранее;
  • ограничивайте размер кэшей, чистите старое, используйте WeakMap где уместно;
  • закрывайте таймеры и снимайте слушатели;
  • большие данные — только потоками;
  • утечку лечите причиной, а не поднятием лимита;
  • при легитимной большой нагрузке — --max-old-space-size.

Смежные темы: сборка мусора — как освобождается память; потоки — обработка больших данных; Buffer — память вне кучи; отладка — профилирование. Полный список — в уроках Node.js.

Частые вопросы

Как посмотреть использование памяти в Node.js?
Через process.memoryUsage(), который возвращает объект с полями rss, heapTotal, heapUsed и external в байтах. rss — вся память процесса, heapUsed — реально занятая объектами часть кучи. Это основа для мониторинга и поиска утечек.
Какой лимит памяти у Node.js по умолчанию?
Куча V8 ограничена примерно двумя гигабайтами на старых версиях и больше на новых, но не бесконечна. Лимит меняют флагом –max-old-space-size в мегабайтах. Упор в лимит вызывает падение с ошибкой нехватки памяти.
Почему в Node.js растёт память?
Из-за утечек — случайно удерживаемых ссылок: глобальные коллекции, которые только пополняются, незакрытые таймеры и слушатели, кэш без предела. Объекты остаются достижимыми, поэтому сборщик мусора их не освобождает.
Как найти утечку памяти в Node?
Снять heap snapshot через инспектор или пакет, сделать два снимка с промежутком под нагрузкой и сравнить, что выросло. Растущее число объектов одного типа между снимками указывает на утечку и место, где копятся ссылки.
Как обработать большой файл, не забив память?
Через потоки: читать и обрабатывать данные по частям, а не загружать целиком через readFile. Поток держит в памяти лишь текущий кусок, поэтому справляется с файлами больше доступной памяти.