Пошук уроків, статей та іншого контенту
Профілюйте застосунок, знаходьте вузькі місця та оптимізуйте цикл подій, пам’ять і пропускну здатність.
Продуктивність застосунку — це не лише час відповіді одного запиту. Для серверного Node.js важливі щонайменше чотири характеристики:
затримка — скільки часу займає обробка запиту;
пропускна здатність — скільки запитів або операцій система обробляє за секунду;
використання ресурсів — CPU, пам’ять, мережа та файлові дескриптори;
стабільність під навантаженням — чи не зростає затримка та кількість помилок у пікові моменти.
Для користувача важливі не тільки середні значення. Середній час відповіді може бути хорошим, навіть якщо кожен сотий запит обробляється дуже довго. Тому аналізуйте:
p50 — медіанну затримку;
p95 — затримку для 95% запитів;
p99 — затримку для 99% запитів;
кількість помилок і тайм-аутів;
пропускну здатність під заданим навантаженням.
Оптимізацію слід починати не з припущень, а з вимірювань:
визначте сценарій і цільовий показник;
відтворіть проблему;
зберіть профіль;
знайдіть найдорожчу операцію;
внесіть одну зміну;
повторіть вимірювання.
Node.js виконує JavaScript у головному потоці. Цей потік обробляє callback-и, проміси, таймери та інші завдання циклу подій.
Операції введення-виведення, наприклад мережеві запити або робота з файлами, можуть виконуватися асинхронно. Проте JavaScript-код callback-а все одно виконується в головному потоці. Якщо callback довго працює, нові події не обробляються вчасно.
Приклад блокування:
app.get('/report', (req, res) => {
const report = createLargeReportSynchronously();
res.json(report);
});Навіть якщо HTTP-сервер асинхронний, синхронне створення звіту затримує всі інші запити в цьому процесі.
Особливо небезпечні:
fs.readFileSync() та інші синхронні операції введення-виведення;
великі цикли;
сортування великих масивів;
складні регулярні вирази;
масивний JSON.parse() або JSON.stringify();
стиснення, шифрування та хешування великих обсягів даних;
обробка великих структур у callback-ах.
Модуль node:perf_hooks містить інструменти для спостереження за циклом подій.
const {
monitorEventLoopDelay,
performance,
} = require('node:perf_hooks');
const histogram = monitorEventLoopDelay({
resolution: 20,
});
histogram.enable();
setInterval(() => {
const utilization = performance.eventLoopUtilization();
console.log({
eventLoopUtilization: Number(utilization.utilization.toFixed(3)),
minMs: Number((histogram.min / 1e6).toFixed(2)),
maxMs: Number((histogram.max / 1e6).toFixed(2)),
meanMs: Number((histogram.mean / 1e6).toFixed(2)),
p99Ms: Number((histogram.percentile(99) / 1e6).toFixed(2)),
});
histogram.reset();
}, 5000);monitorEventLoopDelay() повертає значення в наносекундах, тому для мілісекунд потрібен поділ на 1e6.
eventLoopUtilization() показує, яку частину часу цикл подій був зайнятий. Висока утилізація сама по собі не є помилкою: сервер може ефективно використовувати CPU. Важливо розглядати її разом із затримкою та пропускною здатністю.
Ознаки проблеми:
зростає p95 або p99 затримки циклу подій;
збільшується час відповіді всіх маршрутів одночасно;
CPU одного процесу близький до 100%;
під навантаженням з’являються тайм-аути;
асинхронні callback-и виконуються із суттєвим запізненням.
Синхронні API допустимі в окремих сценаріях запуску, наприклад під час початкового завантаження конфігурації. У шляху обробки запиту вони зазвичай блокують головний потік.
Замість:
const fs = require('node:fs');
const data = fs.readFileSync('./data.json', 'utf8');використовуйте асинхронний API:
const fs = require('node:fs/promises');
async function readData() {
const data = await fs.readFile('./data.json', 'utf8');
return JSON.parse(data);
}Асинхронність не робить саме перетворення JSON миттєвим: JSON.parse() все одно виконується в головному потоці. Вона лише не блокує очікування завершення читання файлу.
Якщо операцію неможливо зробити дешевшою, її можна винести з головного потоку за допомогою worker_threads. Воркер має власний потік JavaScript, тому довга CPU-операція не блокує цикл подій основного потоку.
// main.js
const path = require('node:path');
const { Worker } = require('node:worker_threads');
const http = require('node:http');
function calculateInWorker(limit) {
return new Promise((resolve, reject) => {
const worker = new Worker(path.join(__dirname, 'worker.js'), {
workerData: { limit },
});
worker.once('message', resolve);
worker.once('error', reject);
worker.once('exit', (code) => {
if (code !== 0) {
reject(new Error(`Воркер завершився з кодом ${code}`));
}
});
});
}
const server = http.createServer(async (req, res) => {
if (req.url !== '/calculate') {
res.writeHead(404);
res.end('Not found');
return;
}
try {
const result = await calculateInWorker(50_000_000);
res.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
});
res.end(JSON.stringify(result));
} catch (error) {
res.writeHead(500, {
'content-type': 'application/json; charset=utf-8',
});
res.end(JSON.stringify({ error: error.message }));
}
});
server.listen(3000, () => {
console.log('Сервер працює на http://localhost:3000');
});// worker.js
const { parentPort, workerData } = require('node:worker_threads');
let sum = 0;
for (let i = 0; i < workerData.limit; i += 1) {
sum += i % 10;
}
parentPort.postMessage({ sum });Запустіть приклад двома файлами:
node main.jsВоркер має накладні витрати:
створення потоку;
передавання даних;
серіалізація повідомлень;
додаткове споживання пам’яті.
Тому створювати новий воркер для кожного дрібного обчислення зазвичай невигідно. Для частих операцій використовують пул воркерів або інший спосіб пакетного виконання. Виносити у воркери потрібно саме CPU-інтенсивну роботу, а не звичайне очікування мережі.
Профілювання показує, де процес витрачає CPU. Це важливіше за пошук «підозрілих» рядків у коді, оскільки найдорожча операція часто опосередкована викликами бібліотек або серіалізацією даних.
--cpu-profNode.js може створити CPU-профіль без зміни коду:
node --cpu-prof server.jsПісля завершення процесу Node.js збереже файл профілю з розширенням .cpuprofile. Його можна відкрити в інструментах розробника Chromium у вкладці продуктивності.
Для тривалого тесту зручно задати каталог і назву файлу:
node \
--cpu-prof \
--cpu-prof-dir=./profiles \
--cpu-prof-name=server.cpuprofile \
server.jsПід час аналізу звертайте увагу на:
функції, які займають найбільше часу;
кількість викликів функції;
глибину стеку викликів;
різницю між власним часом функції та часом її дочірніх викликів.
Якщо функція має малий власний час, але великий загальний час, проблема може бути в операціях, які вона викликає.
Для інтерактивного аналізу можна запустити процес з інспектором:
node --inspect=127.0.0.1:9229 server.jsПісля під’єднання інструментів розробника можна записати CPU-профіль під час конкретного навантаження. Важливо, щоб у момент запису виконувалася саме проблемна операція. Профіль простою сервера не покаже причину повільного звіту або масового парсингу JSON.
Профілюйте короткий, але репрезентативний інтервал:
запустіть застосунок;
прогрійте його;
почніть запис профілю;
відтворіть сценарій під навантаженням;
зупиніть запис;
порівняйте результати до та після зміни.
Висока пропускна здатність залежить не тільки від швидкості JavaScript-коду. Система може втрачати продуктивність через:
надто багато дрібних операцій введення-виведення;
зайві перетворення даних;
необмежену кількість одночасних запитів;
відсутність повторного використання з’єднань;
передавання великих відповідей;
відсутність backpressure під час потокової обробки.
Promise.all() запускає всі операції одразу:
const results = await Promise.all(
items.map((item) => processItem(item)),
);Це може перевантажити зовнішній сервіс, базу даних або пам’ять процесу, якщо items містить тисячі елементів.
Простіше обмежити кількість одночасних операцій пакетами:
async function processInBatches(items, batchSize, processItem) {
const results = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
// Наступна порція запускається лише після завершення поточної.
const batchResults = await Promise.all(
batch.map((item) => processItem(item)),
);
results.push(...batchResults);
}
return results;
}Розмір пакета треба підбирати вимірюваннями. Надто малий пакет зменшує конкурентність, надто великий — збільшує навантаження та пікове споживання пам’яті.
Завантаження всього файлу в пам’ять створює зайву пікову витрату пам’яті:
const data = await fs.readFile('large-file.log', 'utf8');Для потокової обробки можна читати файл частинами:
const fs = require('node:fs');
const readline = require('node:readline');
async function countErrors(fileName) {
const input = fs.createReadStream(fileName, {
encoding: 'utf8',
});
const lines = readline.createInterface({
input,
crlfDelay: Infinity,
});
let count = 0;
for await (const line of lines) {
if (line.includes('ERROR')) {
count += 1;
}
}
return count;
}
countErrors('./application.log')
.then((count) => {
console.log(`Знайдено помилок: ${count}`);
})
.catch((error) => {
console.error(error);
process.exitCode = 1;
});Потоки допомагають не завантажувати весь вхід у пам’ять. Однак потокова обробка не усуває витрати CPU на парсинг або пошук. Вона вирішує саме проблему розміру даних і контролю швидкості передавання.
Якщо споживач не встигає обробляти дані, виробник не повинен безмежно додавати їх у пам’ять. API потоків Node.js підтримує backpressure: запис у потік може повернути false, сигналізуючи, що потрібно дочекатися події drain.
Для перетворень між потоками краще використовувати stream.pipeline(), оскільки він коректно передає помилки та завершує пов’язані потоки.
Проблеми пам’яті бувають різними:
короткочасні великі алокації;
поступове зростання зайнятої пам’яті;
витік через глобальні колекції або замикання;
надмірне дублювання рядків і буферів;
занадто великий кеш;
часті цикли збирача сміття.
process.memoryUsage() повертає кілька показників:
setInterval(() => {
const memory = process.memoryUsage();
console.log({
rssMb: Math.round(memory.rss / 1024 / 1024),
heapTotalMb: Math.round(memory.heapTotal / 1024 / 1024),
heapUsedMb: Math.round(memory.heapUsed / 1024 / 1024),
externalMb: Math.round(memory.external / 1024 / 1024),
arrayBuffersMb: Math.round(memory.arrayBuffers / 1024 / 1024),
});
}, 5000);Основні поля:
rss — загальний обсяг пам’яті, зайнятий процесом у системі;
heapTotal — пам’ять, виділена для V8 heap;
heapUsed — фактично використана частина V8 heap;
external — пам’ять, пов’язана з об’єктами поза V8 heap;
arrayBuffers — пам’ять для ArrayBuffer та похідних структур.
Високий rss не обов’язково означає витік у JavaScript-об’єктах. Наприклад, значна частина пам’яті може припадати на Buffer, нативні модулі або внутрішні структури середовища.
Показник heapUsed потрібно порівнювати після завершення однакових сценаріїв і циклів збирача сміття. Одноразове збільшення пам’яті не доводить наявність витоку.
Типовий підхід:
запустити застосунок у контрольованому середовищі;
зробити знімок heap до навантаження;
виконати однаковий сценарій кілька разів;
зробити ще один або кілька знімків;
порівняти кількість об’єктів і шляхи їх утримання.
Знімок heap можна отримати через інспектор або сигнал процесу, якщо це передбачено конфігурацією запуску. Під час створення знімка застосунок може тимчасово зупинити обробку та використати додаткову пам’ять, тому не робіть це безпосередньо на неконтрольованому production-навантаженні.
У heap-профілі шукайте:
колекції, які постійно збільшуються;
замикання, що утримують великі об’єкти;
кеші без обмеження розміру або часу життя;
слухачі подій, які додаються повторно;
об’єкти запитів, збережені в глобальному стані.
Збірка сміття потрібна для звільнення недосяжних об’єктів, але вона також споживає CPU. Якщо код постійно створює багато короткоживучих об’єктів, збирач сміття запускається частіше.
Потенційно дорогі шаблони:
function buildResponse(items) {
return items
.map((item) => ({
id: item.id,
name: item.name,
label: `${item.id}: ${item.name}`,
}))
.filter((item) => item.name.length > 0);
}Цей код може бути цілком прийнятним для невеликих масивів. Проблема виникає, коли він виконується дуже часто для великих наборів даних. Оптимізацію слід підтверджувати профілем, а не механічно уникати кожного нового об’єкта.
Для спостереження за паузами збирача сміття можна використовувати трасування:
node --trace-gc server.jsВивід допомагає побачити частоту та тривалість циклів GC. Сам по собі --trace-gc не визначає причину проблеми, але показує, чи пов’язані піки затримки з активністю збирача сміття.
CPU-профіль показує час виконання, але не завжди пояснює, чому процес виділяє багато пам’яті. Для цього використовують heap-профілі та інструменти інспектора.
Корисно розрізняти:
retained size — обсяг пам’яті, який буде звільнено разом із об’єктом, якщо прибрати його посилання;
shallow size — пам’ять самого об’єкта без об’єктів, на які він посилається.
Об’єкт із невеликим shallow size може мати великий retained size, якщо він утримує ціле дерево даних.
Не намагайтеся зменшити heapUsed будь-якою ціною. Кеш, попередньо підготовлені дані та буфери можуть покращувати швидкість. Мета — передбачуване споживання пам’яті без неконтрольованого зростання та довгих пауз GC.
У вебзастосунках значна частина CPU може витрачатися не на бізнес-логіку, а на підготовку HTTP-відповіді:
створення великих об’єктів;
JSON.stringify();
копіювання масивів;
перетворення типів;
передавання надмірних полів клієнту.
Практичні кроки:
повертайте лише поля, потрібні клієнту;
не формуйте великі проміжні структури без потреби;
використовуйте пагінацію для великих наборів;
не серіалізуйте один і той самий об’єкт кілька разів;
для великих результатів розглядайте потокову передачу;
перевіряйте CPU-профілем, чи справді JSON.stringify() є вузьким місцем.
Оптимізація формату даних може зменшити і CPU-витрати, і мережевий трафік, але іноді збільшує складність клієнтської частини. Рішення має ґрунтуватися на вимірюванні повного шляху — від отримання даних до доставки відповіді.
Профіль, записаний без навантаження, часто вводить в оману. Для коректного порівняння:
використовуйте однакові версії Node.js;
запускайте однакову конфігурацію застосунку;
використовуйте однакові вхідні дані;
прогрійте застосунок перед вимірюванням;
задайте стабільну інтенсивність навантаження;
вимірюйте достатньо довго, щоб побачити p95 і p99;
повторіть тест кілька разів;
порівнюйте не тільки час відповіді, а й CPU, пам’ять та кількість помилок.
Під час тесту зафіксуйте:
кількість одночасних клієнтів;
кількість запитів за секунду;
розмір запиту та відповіді;
час відповіді;
використання CPU;
rss і heapUsed;
затримку циклу подій;
кількість тайм-аутів і помилок.
Корисно мати окремий сценарій для кожного вузького місця. Якщо одночасно змінити алгоритм, розмір кешу та конкурентність, буде складно зрозуміти, яка зміна дала результат.
Розглянемо послідовність для повільного HTTP-маршруту.
Виміряйте окремо:
час запиту до бази даних;
час мережевого виклику;
час читання з диска;
час підготовки відповіді;
час серіалізації.
Якщо зовнішній сервіс відповідає 800 мс, оптимізація локального циклу на 5 мс не змінить загальну затримку суттєво.
Запишіть p95 і p99 затримки циклу подій під навантаженням. Якщо вони зростають разом із затримкою HTTP, шукайте синхронну або CPU-інтенсивну операцію.
Профіль має бути зібраний у момент, коли маршрут обробляє реальні тестові запити. Визначте функцію або ланцюжок викликів, який займає найбільше часу.
Якщо затримка зростає поступово, а heapUsed або rss не повертається до попереднього рівня після навантаження, перевірте heap-знімки та життєвий цикл кешів і слухачів.
Після зміни перевірте, чи покращилися всі важливі показники. Зменшення CPU не є успіхом, якщо через нього збільшилися помилки, час очікування зовнішньої системи або використання пам’яті.
Середнє значення приховує рідкісні, але важливі повільні запити. Завжди аналізуйте щонайменше p95 і p99.
Заміна синтаксису, ручне кешування або мікрооптимізація циклу не допоможуть, якщо основний час витрачається на базу даних чи серіалізацію.
Інспектор, детальне логування та development-налаштування можуть змінити поведінку застосунку. Для фінального порівняння використовуйте умови, близькі до production.
Promise.all()Запуск тисяч операцій одночасно може перевантажити зовнішній сервіс, збільшити кількість помилок і створити великий масив промісів у пам’яті.
async робить будь-який код неблокувальнимawait fs.readFile() не означає, що наступний JSON.parse() або великий цикл виконується в іншому потоці. CPU-код після await все одно блокує головний потік.
Воркер має вартість створення та обміну даними. Для малих задач ця вартість може перевищити виграш від паралельного виконання.
Пам’ять V8 може залишатися виділеною для повторного використання. Потрібні повторні сценарії, порівняння heap-знімків і аналіз об’єктів, які залишаються досяжними.
Якщо виробник генерує дані швидше, ніж споживач їх обробляє, черга зростає в пам’яті. Потокова обробка без контролю швидкості не гарантує низьке споживання пам’яті.
Продуктивність потрібно оцінювати через затримку, пропускну здатність, ресурси та помилки.
Головний потік Node.js не повинен виконувати довгі синхронні або CPU-інтенсивні операції.
monitorEventLoopDelay() та eventLoopUtilization() допомагають виявляти перевантаження циклу подій.
CPU-профілі показують, які функції фактично витрачають час.
process.memoryUsage(), heap-профілі та трасування GC допомагають розрізнити великий обсяг даних, часті алокації та витоки.
Для CPU-інтенсивних задач можна використовувати worker_threads, але враховуйте накладні витрати.
Для великих даних застосовуйте потоки та контролюйте backpressure.
Обмежуйте конкурентність замість безумовного запуску всіх операцій через Promise.all().
Кожну оптимізацію перевіряйте контрольним тестом із реалістичним навантаженням і порівнянням p95, p99, CPU та пам’яті.