Як виявити та запобігти витокам пам'яті в Node.js?
Витоки пам'яті в Node.js (memory leaks) виникають коли об'єкти залишаються в пам'яті після того як вони вже не потрібні, і V8 не може їх зібрати. Heap росте. Процес падає з помилкою out-of-memory (OOM).
Теорія
TL;DR
- V8 обходить граф об'єктів від коренів (globals, стек, замикання) і звільняє все недосяжне. Витік = живий корінь тримає мертвий об'єкт.
- Аналогія: кухня ресторану де повернені тарілки досі мають мітку "в роботі". Посудомийна машина не чіпає те що виглядає зайнятим.
- Чотири основних причини: необмежені кеші, не видалені event listeners, замикання з великими об'єктами, таймери без clearInterval.
- Виявлення:
process.memoryUsage()у динаміці, heap snapshots у Chrome DevTools, абоclinic.js. - Правило: обмежуй усе, видаляй listeners при close, моніторь heap у продакшені.
Швидкий приклад
// Витік: глобальний кеш росте на кожному тіку
let cache = {};
setInterval(() => {
cache[Date.now()] = new Array(1_000_000).fill('leak'); // ~8MB в секунду
console.log(Object.keys(cache).length); // 1, 2, 3... не зменшується
}, 1000);
// Heap сягає 1GB+ за хвилини, потім OOM crash
// Виправлення: обмежений LRU-кеш
const { LRUCache } = require('lru-cache');
const bounded = new LRUCache({ max: 100, ttl: 1000 * 60 * 5 });
bounded.set(Date.now(), 'data');
// Розмір не перевищує 100 записів, старі автоматично видаляютьсяЗвичайний об'єкт тримає посилання безкінечно. V8 бачить глобальний cache як живий корінь і ніколи не збирає його записи.
Як GC пов'язаний із витоками
V8 використовує генераційний garbage collector. Короткоживучі об'єкти потрапляють до молодого покоління і швидко очищуються Scavenge. Довгоживучі об'єкти переходять до старого покоління і звільняються mark-sweep-compact. Витік виникає коли об'єкт переходить до старого покоління через живий корінь - глобальну змінну, активне замикання, callback таймера - і залишається там.
heapUsed у process.memoryUsage() показує скільки старого покоління зайнято. Постійне зростання протягом годин без зміни трафіку - сигнал витоку.
Чотири основних причини
1. Необмежені глобальні кеші. Звичайний об'єкт або Map без обмеження розміру росте безкінечно. Кожен унікальний ключ додає запис. Після 10,000 унікальних user ID кеш займає 500MB+.
2. Не видалені event listeners. Якщо додати listener всередині обробника запиту без видалення при закритті з'єднання, кожен запит залишає постійний listener. Node.js попереджає при 11 listeners на одному emitter, але збиток вже є.
// ❌ Listener додається на кожен запит, ніколи не видаляється
app.get('/stream', (req, res) => {
process.on('data', handler);
});
// ✅ Видаляємо коли з'єднання закривається
app.get('/stream', (req, res) => {
process.on('data', handler);
req.on('close', () => process.removeListener('data', handler));
});3. Замикання (closure) з великими об'єктами. Замикання тримає весь scope в якому було створено. Якщо там є масив на 10MB а потрібна тільки .length - ці 10MB живуть разом із замиканням.
4. Таймери без clearInterval. setInterval тримає посилання на свій callback і всі змінні з його scope. Без clearInterval таймер і все що він тримає живе вічно.
Коли розслідувати
Довгоживучі API-сервери та WebSocket-сервери - основна мішень. CLI-скрипт що завершується за 2 секунди не важливий. Але Express-додаток що обробляє мільйони запитів кілька днів поспіль впаде в OOM якщо ці патерни є в коді.
Я бачив сервери що стартували на 150MB і виростали до 2GB за 72 години без зміни трафіку. Це завжди витік, не проблема масштабування.
Як виявити витоки пам'яті
Крок 1: Логуй process.memoryUsage() у динаміці.
setInterval(() => {
const { heapUsed, heapTotal, rss } = process.memoryUsage();
console.log({
heapUsed: `${Math.round(heapUsed / 1024 / 1024)}MB`,
heapTotal: `${Math.round(heapTotal / 1024 / 1024)}MB`,
rss: `${Math.round(rss / 1024 / 1024)}MB`,
});
}, 10_000);Постійне зростання протягом 30+ хвилин під постійним навантаженням - сильний сигнал.
Крок 2: Heap snapshots у Chrome DevTools.
node --inspect server.jsВідкрий chrome://inspect, зроби snapshot до навантажувального тесту, запусти тест, зроби ще один. Вкладка "Comparison" покаже що було виділено і не звільнено між знімками.
Крок 3: clinic.js для автоматичного аналізу.
npx clinic heapprofiler -- node server.js
npx clinic doctor -- node server.jsclinic.js генерує візуальний звіт алокацій heap. Піки що не спадають - витоки. clinic doctor визначає тип проблеми автоматично.
Продакшен: відстежуй heapUsed через Prometheus або Datadog. Алерт на 80% від --max-old-space-size - до падіння.
Типові помилки
"GC сам почистить." GC не може зібрати об'єкти з живими посиланнями. При витоку він запускається частіше і досягає менше, додаючи до 10x CPU-навантаження до краша.
--max-old-space-size як вирішення проблеми.
node --max-old-space-size=4096 server.jsЦе відтягує OOM на кілька годин. Використовуй щоб виграти час на виправлення, не як рішення. Встанови розумний ліміт (наприклад, 1GB) і алерт на 80%.
WeakMap із примітивними ключами.
// ❌ TypeError: ключ має бути об'єктом
const wm = new WeakMap();
wm.set('user-123', data);
// ✅ Ключ-об'єкт - автоматично збирається GC разом із об'єктом
wm.set(userObject, computedData);Замикання у гарячих циклах що захоплюють великі масиви.
// ❌ Усі 1M замикань тримають посилання на повний масив arr
const fns = [];
for (let i = 0; i < 1e6; i++) {
fns.push(() => console.log(arr[i]));
}
// ✅ Кожне замикання тримає лише одне значення
for (const item of arr) {
fns.push(() => console.log(item));
}Де зустрічається у реальних проєктах
- Express:
lru-cacheу middleware маршрутів зmax: 500таttl: 300_000 - Socket.io:
socket.removeAllListeners('update')у обробникуdisconnect - Cluster-воркери:
process.removeListener('message', handler)приdisconnectворкера - PM2: прапорець
--heap-dump-on-oomробить snapshot при краші для post-mortem аналізу - Продакшен-алерти: метрика Prometheus
nodejs_heap_size_used_bytes, поріг 80%
Follow-up питання
Q: Як знайти витік у продакшені без даунтайму?
A: Увімкни --inspect на порту недоступному ззовні, підключись через SSH-тунель. Зроби два heap snapshots з інтервалом 10 хвилин під живим навантаженням. Вкладка "Comparison" у Chrome DevTools покаже нові алокації. Конструктори з великим зростанням retained size - джерело проблеми.
Q: Яка різниця між heapUsed і rss у process.memoryUsage()?
A: heapUsed - це JS heap під управлінням V8. rss (Resident Set Size) - весь обсяг пам'яті що виділила ОС процесу, включно з нативними буферами, C++-об'єктами і стеком. rss росте при стабільному heapUsed - часто витік у нативному модулі або Buffer поза JS heap.
Q: Коли setInterval спричиняє витік, а коли ні?
A: setInterval спричиняє витік коли він посилається на змінні з зовнішнього scope і ніколи не очищається. Глобальний інтервал що живе весь час роботи додатку - нормально. Інтервал створений per-request або per-connection без cleanup - витік.
Q: Чим WeakRef відрізняється від WeakMap для кешування?
A: WeakRef (Node 14+) тримає один об'єкт слабко. Використовується щоб стежити за об'єктом без запобігання його збірці, потім .deref() перевіряє чи він ще існує. WeakMap для кеш key-value де ключі - об'єкти. Коли ключ збирається GC, запис зникає автоматично.
Q: (Senior) У WebSocket-сервері з 10,000 одночасних з'єднань, як обмежити стан per-client без O(n) обходу при cleanup?
A: Розбий стан клієнтів по N фіксованих WeakMaps (наприклад, 10 maps, розподіл по clientId % 10). Ключі - socket-об'єкти, що автоматично збираються GC при відключенні. Цикл cleanup не потрібен. Додай ping-таймаут: якщо клієнт не відповідає 30 секунд, закрий сокет - запис у WeakMap зникне сам.
Приклади
Базовий: Express-маршрут із необмеженим кешем
// ❌ Глобальний об'єкт росте на кожен унікальний user ID
if (!global.userCache) global.userCache = {};
app.get('/user/:id', (req, res) => {
const id = req.params.id;
if (!userCache[id]) userCache[id] = db.getUser(id);
res.json(userCache[id]);
});
// Після 10k унікальних юзерів: heap 500MB+, OOM близько
// ✅ Обмежений TTL-кеш
const NodeCache = require('node-cache');
const cache = new NodeCache({ stdTTL: 300, checkperiod: 60 });
app.get('/user/:id', async (req, res) => {
const id = req.params.id;
let user = cache.get(id);
if (!user) {
user = await db.getUser(id);
cache.set(id, user);
}
res.json(user);
});
// Heap тримається ~50MB незалежно від кількості унікальних юзерівЗаписи закінчуються через 5 хвилин. checkperiod запускає автоматичне очищення кожні 60 секунд. Ручне видалення не потрібне.
Середній рівень: Витік listener у SSE-ендпоінті
// ❌ Кількість listeners росте з кожним підключеним клієнтом
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
eventBus.on('update', send);
// Без cleanup - listener залишається після відключення клієнта
});
// ✅ Видаляємо listener при відключенні клієнта
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
eventBus.on('update', send);
req.on('close', () => eventBus.removeListener('update', send));
});req.on('close') спрацьовує і при нормальному відключенні, і при обриві з'єднання. Це покриває всі шляхи витоку.
Просунутий рівень: Heap snapshots для діагностики у продакшені
// Додай за auth-перевіркою - ніколи не відкривай публічно
const v8 = require('v8');
app.get('/internal/heapdump', (req, res) => {
const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
v8.writeHeapSnapshot(filename);
res.json({ file: filename });
});Виклич цей ендпоінт двічі: до і після 10-хвилинного навантаження. Завантаж обидва .heapsnapshot-файли у Chrome DevTools, вкладка Memory. Режим "Comparison", сортування по "Delta". Конструктори з великим позитивним дельта - кандидати на витік. Панель "Retainers" покаже ланцюжок посилань до кореня що тримає об'єкт живим.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.