lenec ru

← все посты

Memory leaks в Node.js: как найти, исправить и предотвратить

18K

Утечки памяти в Node.js — одна из самых коварных проблем в production. Приложение работает стабильно на тестах, но через несколько часов или дней в бою начинает жрать гигабайты RAM, тормозить и падать с OOM. Разберём, как находить, чинить и предотвращать memory leaks в реальных проектах.

Признаки утечки памяти

Первый звоночек — постоянный рост потребления памяти. Мониторинг показывает, что RSS (Resident Set Size) и heap used растут линейно, даже если нагрузка стабильна. Типичные симптомы:

  • RSS растёт — процесс занимает всё больше физической памяти
  • Heap size увеличивается — V8 не может освободить объекты
  • GC работает чаще — сборщик мусора пытается отвоевать память, но безуспешно
  • Latency растёт — запросы обрабатываются медленнее из-за GC pause
  • OOM Killer — в итоге процесс убивает ОС или сам Node.js с ошибкой "JavaScript heap out of memory"

Проверить текущее потребление памяти можно встроенными средствами:

const used = process.memoryUsage();
console.log({
  rss: `${Math.round(used.rss / 1024 / 1024)} MB`,           // вся память процесса
  heapTotal: `${Math.round(used.heapTotal / 1024 / 1024)} MB`, // выделено под heap
  heapUsed: `${Math.round(used.heapUsed / 1024 / 1024)} MB`,   // занято в heap
  external: `${Math.round(used.external / 1024 / 1024)} MB`    // C++ объекты
});

Если heapUsed растёт без остановки — у вас утечка. Если RSS растёт, а heap стабилен — проблема может быть в native-модулях или буферах.

Heap snapshot в Chrome DevTools

Самый мощный инструмент для поиска утечек — heap snapshot. Он показывает все объекты в памяти, их размеры и цепочки ссылок (retainers), которые не дают GC их удалить.

Запускаем Node.js с флагом --inspect:

node --inspect server.js

Открываем Chrome, переходим в chrome://inspect, кликаем "inspect" на нашем процессе. В DevTools идём на вкладку Memory.

Алгоритм поиска утечки:

  1. Делаем первый snapshot (кнопка "Take snapshot")
  2. Нагружаем приложение — выполняем операции, которые подозреваем в утечке
  3. Делаем второй snapshot
  4. Переключаемся на режим "Comparison" и сравниваем снимки

В колонке "Delta" видим объекты, которые появились между снимками. Если там тысячи одинаковых объектов (массивы, замыкания, DOM-ноды в случае Electron) — это кандидаты на утечку. Кликаем на объект, смотрим "Retainers" внизу — это цепочка ссылок, которая держит объект в памяти. Часто там видно глобальную переменную, event listener или незакрытый таймер.

Типичные причины утечек

Глобальные переменные и кеши

Самая частая ошибка — складывать данные в глобальный объект или Map без ограничения размера:

const cache = new Map();

app.get('/user/:id', async (req, res) => {
  const userId = req.params.id;
  if (!cache.has(userId)) {
    const user = await db.getUser(userId);
    cache.set(userId, user);  // утечка: cache растёт бесконечно
  }
  res.json(cache.get(userId));
});

Фикс — использовать LRU-кеш с ограничением:

const LRU = require('lru-cache');
const cache = new LRU({ max: 500, ttl: 1000 * 60 * 5 }); // 500 записей, TTL 5 минут

Замыкания (closures)

Замыкания захватывают весь scope, даже если используют только одну переменную. Классический пример:

function createHandler() {
  const hugeData = Buffer.alloc(10 * 1024 * 1024); // 10 MB
  const timestamp = Date.now();
  
  return function handler(req, res) {
    res.send(`Request at ${timestamp}`); // использует только timestamp
    // но hugeData тоже остаётся в памяти, пока жив handler
  };
}

app.get('/leak', createHandler());

Решение — явно обнулять ненужные переменные или выносить handler наружу:

function createHandler() {
  const timestamp = Date.now();
  return function handler(req, res) {
    res.send(`Request at ${timestamp}`);
  };
}

Event listeners

Подписка на события без отписки — классика. Особенно опасно с EventEmitter:

class Worker {
  constructor(eventBus) {
    this.eventBus = eventBus;
    this.eventBus.on('task', this.handleTask.bind(this)); // утечка
  }
  
  handleTask(task) {
    // обработка
  }
}

// создаём 1000 воркеров, каждый подписывается, но никто не отписывается
for (let i = 0; i < 1000; i++) {
  new Worker(globalEventBus);
}

Фикс — всегда отписываться в cleanup:

class Worker {
  constructor(eventBus) {
    this.eventBus = eventBus;
    this.handler = this.handleTask.bind(this);
    this.eventBus.on('task', this.handler);
  }
  
  destroy() {
    this.eventBus.off('task', this.handler);
  }
  
  handleTask(task) {
    // обработка
  }
}

Таймеры и интервалы

Забытые setInterval или setTimeout держат замыкания в памяти:

function startPolling(url) {
  const data = { url, results: [] };
  
  setInterval(async () => {
    const res = await fetch(url);
    data.results.push(await res.json()); // results растёт бесконечно
  }, 5000);
}

Решение — сохранять ID таймера и очищать его:

function startPolling(url) {
  const data = { url, results: [] };
  
  const intervalId = setInterval(async () => {
    const res = await fetch(url);
    const json = await res.json();
    data.results.push(json);
    
    if (data.results.length > 100) {
      data.results.shift(); // ограничиваем размер
    }
  }, 5000);
  
  return () => clearInterval(intervalId); // возвращаем cleanup
}

const stopPolling = startPolling('https://api.example.com/status');
// позже: stopPolling();

Инструменты диагностики

node --inspect

Встроенный инспектор — must-have. Кроме heap snapshots, даёт профайлер CPU, покрытие кода и live-редактирование. Для production можно использовать --inspect-brk (остановка на старте) или подключаться к уже запущенному процессу через SIGUSR1:

kill -SIGUSR1 <pid>

clinic.js

Набор инструментов от команды Node.js для диагностики performance. Для утечек памяти используем clinic heapprofiler:

npm install -g clinic
clinic heapprofiler -- node server.js

После нагрузки останавливаем процесс (Ctrl+C), clinic генерирует HTML-отчёт с графиками аллокаций и flamegraph. Показывает, какие функции аллоцируют больше всего памяти.

memwatch-next

Библиотека для мониторинга памяти в runtime. Умеет детектировать утечки автоматически:

const memwatch = require('@airbnb/node-memwatch');

memwatch.on('leak', (info) => {
  console.error('Memory leak detected:', info);
  // info содержит рост heap, количество GC и т.д.
});

memwatch.on('stats', (stats) => {
  console.log('GC stats:', {
    usage_trend: stats.usage_trend,
    current_base: stats.current_base,
    num_full_gc: stats.num_full_gc
  });
});

Полезно в production для алертов, но не заменяет heap snapshot для глубокого анализа.

Практический пример: находим и чиним утечку

Возьмём реальный кейс — Express-сервер с WebSocket-подключениями. Пользователи жалуются, что через 2-3 часа работы сервер начинает тормозить.

const express = require('express');
const WebSocket = require('ws');

const app = express();
const wss = new WebSocket.Server({ port: 8080 });

const sessions = new Map();

wss.on('connection', (ws, req) => {
  const sessionId = req.headers['x-session-id'];
  sessions.set(sessionId, ws);
  
  ws.on('message', (msg) => {
    console.log(`Received: ${msg}`);
  });
  
  // ОШИБКА: не удаляем сессию при disconnect
});

app.listen(3000);

Запускаем с node --inspect server.js, делаем heap snapshot, подключаем 100 клиентов, отключаем их, делаем второй snapshot. В Comparison видим 100 объектов WebSocket в памяти. Смотрим Retainers — они висят в Map sessions.

Фикс:

wss.on('connection', (ws, req) => {
  const sessionId = req.headers['x-session-id'];
  sessions.set(sessionId, ws);
  
  ws.on('message', (msg) => {
    console.log(`Received: ${msg}`);
  });
  
  ws.on('close', () => {
    sessions.delete(sessionId); // чистим Map
    console.log(`Session ${sessionId} closed, active: ${sessions.size}`);
  });
  
  ws.on('error', (err) => {
    console.error(`Session ${sessionId} error:`, err);
    sessions.delete(sessionId);
  });
});

После фикса делаем третий snapshot — WebSocket-объекты корректно удаляются. Проблема решена.

Подводные камни

  • Streams — незакрытые readable/writable streams держат буферы. Всегда вызывайте stream.destroy() или используйте pipeline() с автоматическим cleanup
  • Promises — rejected promise без .catch() может держать контекст. Используйте process.on('unhandledRejection') для отлова
  • Native addons — утечки в C++ коде не видны в heap snapshot. Используйте Valgrind или AddressSanitizer
  • Circular references — V8 умеет их собирать, но если объект доступен из корня (global, module.exports), цикл не поможет

Профилактика

Чтобы не ловить утечки в production:

  • Используйте WeakMap/WeakSet для кешей, где ключи — объекты. GC сам почистит, когда ключ станет недостижим
  • Ограничивайте размеры коллекций — if (array.length > 1000) array.shift()
  • Пишите тесты с нагрузкой — прогоняйте 10k запросов и проверяйте, что process.memoryUsage().heapUsed вернулся к baseline
  • Мониторьте метрики в production — RSS, heap, GC pause. Алертите на аномальный рост
  • Используйте линтеры — eslint-plugin-no-leaking-timers, eslint-plugin-promise для отлова частых ошибок

Вывод

Утечки памяти в Node.js — решаемая проблема, если знать инструменты. Heap snapshot в Chrome DevTools — основное оружие для глубокого анализа. Clinic.js и memwatch-next помогают в production-мониторинге. Главное — понимать типичные паттерны утечек (глобальные кеши, незакрытые listeners, забытые таймеры) и всегда чистить ресурсы в cleanup-функциях. Профилактика дешевле лечения — ограничивайте коллекции, используйте WeakMap и пишите нагрузочные тесты.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.