15 січня 2026 р.
Проекти, що працюють у продакшені на Node.js, часто стикаються з проблемою поступової деградації продуктивності. Спочатку сервер працює швидко та чуйно. Однак після обробки кількох мільйонів запитів протягом кількох днів процес несподівано падає з помилкою нестачі оперативної пам'яті (out-of-memory). Така поведінка свідчить про наявність витоку пам'яті.
На відміну від багатьох інших систем, які повністю приховують деталі роботи збирача сміття, керування пам'яттю в Node.js вимагає глибокого розуміння структури двигуна V8, фаз очищення пам'яті та того, як поводяться JavaScript замикання (closures) і посилання під великим навантаженням. У цій статті ми розберемо структуру купи (heap) V8, детально проаналізуємо три реальні витоки пам'яті з прикладами коду, та побудуємо покроковий план відладки за допомогою Chrome DevTools.
Щоб знайти витік пам'яті, спершу потрібно розібратися, де зберігаються дані та як V8 приймає рішення про їхнє видалення. У Node.js загальна пам'ять, яку споживає процес, називається Resident Set Size (RSS). Вона складається з кількох ключових сегментів:
+-------------------------------------------------------------+
| Resident Set Size (RSS) |
+-------------------------------------------------------------+
| +------------------------+ +------------+ +-----------+ |
| | V8 Heap | | C++ Stack | | V8 Code | |
| +------------------------+ +------------+ +-----------+ |
| | New Space | |
| | Old Pointer Space | +------------+ +-----------+ |
| | Old Data Space | | Node Libs | | Metadata | |
| | Large Object Space | +------------+ +-----------+ |
| | Map Space / Code Space| |
| +------------------------+ |
+-------------------------------------------------------------+
Під час запуску циклу збирання сміття V8 шукає активні посилання, починаючи з так званих GC Roots (коренів збирання сміття). Коренями є об'єкти, доступні за будь-яких умов: глобальний об'єкт global, змінні у поточному стеку викликів функцій та внутрішні зв'язки C++ ядра Node.js. Якщо об'єкт не зв'язаний ланцюжком посилань з жодним із коренів, він вважається недосяжним і видаляється. Витік пам'яті - це ситуація, коли об'єкт більше не потрібен логіці програми, але зв'язок із GC Roots зберігається.
Давайте розберемо три поширені помилки, через які розробники блокують очищення пам'яті збирачем V8.
Один із найвідоміших витоків у JavaScript пов'язаний із тим, як closures ділять між собою лексичне оточення. Коли всередині функції створюються інші функції, вони зберігають посилання на змінні батьківської функції. Якщо хоча б одна з внутрішніх функцій використовує змінну з батьківського оточення, ця змінна зберігається в контексті для всіх функцій цього блоку.
Ось приклад коду, який швидко вичерпає пам'ять:
// Змінна на рівні модуля
let originalContainer = null;
function runLeakyOperation() {
const previousContainer = originalContainer;
// Це замикання використовує 'previousContainer'
const unusedClosure = function() {
if (previousContainer) {
console.log("Дані попереднього контейнера знайдено");
}
};
// Створюємо новий об'єкт та перезаписуємо змінну
originalContainer = {
generatedAt: Date.now(),
hugePayload: new Array(1000000).fill("A"), // Близько 8 МБ
someMethod: function() {
// Ця функція активна на 'originalContainer'
// Вона ділить лексичне оточення з 'unusedClosure'
return "Активні дані";
}
};
}
// Запуск цієї операції призведе до лавиноподібного витоку пам'яті
setInterval(runLeakyOperation, 100);
Чому виникає витік?
Щоразу під час виклику runLeakyOperation створюється новий об'єкт та записується в originalContainer. У цьому об'єкті є метод someMethod. Оскільки в лексичному оточенні runLeakyOperation визначена функція unusedClosure, яка посилається на previousContainer, змінна previousContainer потрапляє в контекст батьківського оточення.
Метод someMethod ділить спільне лексичне оточення з unusedClosure. Отже, someMethod тримає посилання на це оточення, яке в свою чергу тримає посилання на previousContainer. Через це утворюється зв'язний список старих об'єктів, які ніколи не зможе видалити збирач сміття.
Як це виправити: Очищуйте посилання перед завершенням виконання функції або винесіть логіку замикань в окремі контексти:
function runCleanOperation() {
const previousContainer = originalContainer;
const unusedClosure = function() {
if (previousContainer) {
console.log("Контейнер знайдено");
}
};
originalContainer = {
generatedAt: Date.now(),
hugePayload: new Array(1000000).fill("A"),
someMethod: function() {
return "Безпечний метод";
}
};
// Викликаємо функцію та розриваємо зв'язок
unusedClosure();
}
Node.js побудований на подіях. Дуже часто розробники додають слухачі подій на довгоживучі об'єкти (глобальний process, синглтони баз даних, роутери) зсередини короткоживучих запитів чи функцій:
const express = require('express');
const app = express();
const globalTracker = require('./tracker'); // Довгоживучий об'єкт синглтон
app.get('/api/users/:id', (req, res) => {
const userId = req.params.id;
const onUserUpdated = (data) => {
if (data.id === userId) {
console.log(`Користувача ${userId} оновлено`);
}
};
// Додаємо слухач події на глобальний об'єкт
globalTracker.on('user-update', onUserUpdated);
res.json({ id: userId, name: "Інформація про користувача" });
});
Чому виникає витік?
Синглтон globalTracker існує протягом усього часу роботи додатку. При кожному запиті на /api/users/:id створюється нова функція onUserUpdated і записується в масив слухачів globalTracker.
Функція onUserUpdated є замиканням і утримує посилання на userId та весь скоуп запиту. Оскільки globalTracker досяжний з GC Roots, ці слухачі разом із контекстом запиту залишаться в пам'яті назавжди, накопичуючись із кожним клієнтом.
Як це виправити:
Обов'язково відписуйтесь від подій або використовуйте метод once, якщо подія повинна спрацювати лише один раз:
app.get('/api/users/:id', (req, res) => {
const userId = req.params.id;
const onUserUpdated = (data) => {
if (data.id === userId) {
console.log(`Користувача оновлено`);
globalTracker.off('user-update', onUserUpdated);
}
};
globalTracker.on('user-update', onUserUpdated);
// Додаємо відписку при завершенні клієнтського запиту
res.on('finish', () => {
globalTracker.off('user-update', onUserUpdated);
});
res.json({ id: userId });
});
Потоки (streams) у Node.js дозволяють читати великі файли частинами, не завантажуючи їх у RAM повністю. Проте неправильне з'єднання читача та письменника може призвести до накопичення даних у пам'яті.
Приклад TCP сервера, який пише лог на повільний диск без контролю потоку:
const net = require('net');
const fs = require('fs');
const server = net.createServer((socket) => {
const fileWriter = fs.createWriteStream('./logs/traffic.log');
socket.on('data', (chunk) => {
// Пишемо дані напряму без перевірки пропускної здатності
const canAcceptMore = fileWriter.write(chunk);
if (!canAcceptMore) {
// Якщо швидкість запису на диск менша за швидкість мережі,
// дані починають накопичуватися в оперативній пам'яті додатку.
console.log("Диск перевантажений, накопичуємо буфер у пам'яті...");
}
});
});
server.listen(8080);
Чому виникає витік? Якщо сокет отримує пакети швидше, ніж диск встигає їх записувати, Node.js не може відкинути ці дані. Вони накопичуються в черзі в оперативній пам'яті. Якщо клієнт завантажує гігабайтний файл на великій швидкості, це швидко призведе до збою OOM (out-of-memory).
Як це виправити:
Завжди використовуйте вбудований метод .pipe(), який автоматично керує паузами та відновленням потоків, або скористайтеся сучасним модулем stream/promises:
const { pipeline } = require('stream/promises');
const cleanServer = net.createServer(async (socket) => {
const fileWriter = fs.createWriteStream('./logs/traffic.log');
try {
// pipeline автоматично контролює потік даних та очищує ресурси
await pipeline(socket, fileWriter);
} catch (err) {
console.error('Помилка передачі даних', err);
}
});
cleanServer.listen(8080);
Для пошуку витоків потрібно побудувати чіткий процес діагностики:
Діагностика витоку пам'яті
+------------------+ +-------------------+ +--------------------+
| 1. Запуск | | 2. Базовий стан | | 3. Навантаження |
| з --inspect | --> | Знімок купи №1 | --> | autocannon тест |
+------------------+ +-------------------+ +--------------------+
|
v
+------------------+ +-------------------+ +--------------------+
| 6. Виправлення | | 5. Порівняння | | 4. Піковий стан |
| та ре-білд | <-- | Аналіз Retainers | <-- | Знімок купи №2 |
+------------------+ +-------------------+ +--------------------+
Запустіть ваш сервер із прапорцем інспектування. Це відкриє WebSocket порт 9229:
node --inspect=0.0.0.0:9229 server.js
Відкрийте Google Chrome та введіть у рядок адреси chrome://inspect. У списку "Remote Target" знайдіть свій Node.js додаток і натисніть кнопку inspect. Перейдіть до вкладки Memory.
Виберіть тип профілювання Heap snapshot та натисніть кнопку Take snapshot. Назвіть знімок "Start". Це базовий стан вашої пам'яті після запуску програми.
Щоб спровокувати витік пам'яті та зробити його помітним, запустіть утиліту навантажувального тестування на кшталт autocannon. Надішліть тисячі запитів на сервер:
# Тест тривалістю 30 секунд зі 100 паралельними підключеннями
npx autocannon -c 100 -d 30 http://localhost:3000/api/users/123
Після завершення тесту витік повинен залишитися в пам'яті, оскільки збирач сміття не зможе видалити заблоковані об'єкти.
Поверніться в Chrome DevTools і зробіть ще один знімок купи. Назвіть його "Load".
Якщо ви бачите конструктори на кшталт (closure) чи EventEmitter, які зросли на тисячі одиниць, розгорніть їхній список. Оберіть будь-який об'єкт. Внизу у вкладці Retainers ви побачите ланцюжок посилань, який тримає об'єкт у пам'яті. Піднімаючись деревом вгору, знайдіть посилання на файли та номери рядків вашого власного коду, де було створено це замикання або об'єкт.
На бойових серверах відкривати порти відладки небезпечно з міркувань безпеки. Для цього можна реалізувати скрипт, який слідкує за пам'яттю додатка та створює знімки купи автоматично при перевищенні лімітів.
const v8 = require('v8');
const fs = require('fs');
const path = require('path');
const MEMORY_THRESHOLD_PERCENT = 85;
function checkMemoryUsage() {
const memory = process.memoryUsage();
const heapUsed = memory.heapUsed;
const heapTotal = memory.heapTotal;
const percentage = (heapUsed / heapTotal) * 100;
console.log(`Моніторинг пам'яті: ${percentage.toFixed(2)}% використано (${Math.round(heapUsed/1024/1024)}MB)`);
if (percentage > MEMORY_THRESHOLD_PERCENT) {
console.warn("Попередження: Високе використання оперативної пам'яті! Створюємо знімок...");
const snapshotPath = path.join(__dirname, `../snapshots/leak-${Date.now()}.heapsnapshot`);
// Перевірка існування папки для знімків
const dir = path.dirname(snapshotPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Створення знімку купи на диск
const snapshotStream = v8.getHeapSnapshot();
const fileStream = fs.createWriteStream(snapshotPath);
snapshotStream.pipe(fileStream);
fileStream.on('finish', () => {
console.log(`Знімок купи успішно збережено у: ${snapshotPath}`);
});
}
}
// Запуск моніторингу кожні 10 секунд
setInterval(checkMemoryUsage, 10000);
Завантаживши файл .heapsnapshot з продакшн сервера на свій комп'ютер, ви зможете імпортувати його в локальний Chrome DevTools для детального аналізу.
Виявлення та виправлення витоків пам'яті стає простим процесом, коли ви знаєте правила роботи збирача сміття двигуна V8. Контролюйте видалення слухачів подій, уникайте збереження контекстів замикань на тривалий час і завжди стежте за пропускною здатністю потоків введення-виведення.
При запуску Node.js у Docker контейнерах налаштовуйте ліміт пам'яті через прапорець --max-old-space-size. Це дозволить двигуну вчасно ініціювати очищення купи, уникаючи несподіваних перезапусків контейнера системою Kubernetes чи Docker:
# Обмеження купи у 1.5 ГБ для стабільної роботи контейнера
node --max-old-space-size=1536 server.js
Ці кроки допоможуть зберегти ваші сервіси стабільними та готовими до великих навантажень.