Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як виявити та запобігти витокам пам'яті в Node.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Витоки пам'яті в Node.js** (memory leaks) виникають коли об'єкти залишаються в пам'яті після того як вони вже не потрібні, і V8 не може їх зібрати. Heap росте до OOM. ```js setInterval(() => { const { heapUsed } = process.memoryUsage(); console.log(`${Math.round(heapUsed / 1e6)}MB`); // стежимо за постійним зростанням }, 10_000); ``` **Головне:** обмежуй кеші через `lru-cache`, видаляй event listeners при close, очищай таймери.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Витоки пам'яті в 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 у продакшені. ### Швидкий приклад ```js // Витік: глобальний кеш росте на кожному тіку 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, але збиток вже є. ```js // ❌ 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()` у динаміці.** ```js 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.** ```bash node --inspect server.js ``` Відкрий `chrome://inspect`, зроби snapshot до навантажувального тесту, запусти тест, зроби ще один. Вкладка "Comparison" покаже що було виділено і не звільнено між знімками. **Крок 3: clinic.js для автоматичного аналізу.** ```bash npx clinic heapprofiler -- node server.js npx clinic doctor -- node server.js ``` clinic.js генерує візуальний звіт алокацій heap. Піки що не спадають - витоки. `clinic doctor` визначає тип проблеми автоматично. **Продакшен:** відстежуй `heapUsed` через Prometheus або Datadog. Алерт на 80% від `--max-old-space-size` - до падіння. ### Типові помилки **"GC сам почистить."** GC не може зібрати об'єкти з живими посиланнями. При витоку він запускається частіше і досягає менше, додаючи до 10x CPU-навантаження до краша. **`--max-old-space-size` як вирішення проблеми.** ```bash node --max-old-space-size=4096 server.js ``` Це відтягує OOM на кілька годин. Використовуй щоб виграти час на виправлення, не як рішення. Встанови розумний ліміт (наприклад, 1GB) і алерт на 80%. **WeakMap із примітивними ключами.** ```js // ❌ TypeError: ключ має бути об'єктом const wm = new WeakMap(); wm.set('user-123', data); // ✅ Ключ-об'єкт - автоматично збирається GC разом із об'єктом wm.set(userObject, computedData); ``` **Замикання у гарячих циклах що захоплюють великі масиви.** ```js // ❌ Усі 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-маршрут із необмеженим кешем ```js // ❌ Глобальний об'єкт росте на кожен унікальний 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-ендпоінті ```js // ❌ Кількість 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 для діагностики у продакшені ```js // Додай за 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" покаже ланцюжок посилань до кореня що тримає об'єкт живим.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.