Memory leaks в Node.js: как найти, исправить и предотвратить
Утечки памяти в 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.
Алгоритм поиска утечки:
- Делаем первый snapshot (кнопка "Take snapshot")
- Нагружаем приложение — выполняем операции, которые подозреваем в утечке
- Делаем второй snapshot
- Переключаемся на режим "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 и пишите нагрузочные тесты.