Як оптимізувати продуктивність додатку Express.js?
Оптимізація продуктивності Express.js - набір конкретних технік для зменшення затримки відповідей, збільшення пропускної здатності під навантаженням і скорочення використання CPU/пам'яті в production Node.js серверах.
Теорія
TL;DR
- Більшість проблем зі швидкодією Express пов'язані з базою даних, а не з самим Express. Спочатку виправляй запити, потім інфраструктуру.
NODE_ENV=productionсамостійно дає приріст швидкості в 2-5x: вимикає debug middleware і перекомпіляцію шаблонів.- Compression (gzip рівень 6) стискає JSON-відповіді на 70-80%, додаючи лише 5-10% навантаження на CPU.
- Clustering множить пропускну здатність на кількість ядер CPU. На 4-ядерній машині це 4x більше запитів.
- Redis-кешування перетворює 50ms DB-запит на читання з пам'яті менше ніж за 1ms для повторних звернень.
Швидкий приклад
До/після - найбільші виграші в одному блоці:
// ДО: dev-режим, синхронні операції, без стиснення
app.get('/users', (req, res) => {
const users = db.query('SELECT * FROM users'); // синхронно - блокує event loop
res.json(users); // без gzip, повний розмір payload
});
// ПІСЛЯ: production-ready
app.set('env', 'production'); // вимикає dev middleware overhead
app.use(compression({ level: 6 })); // gzip для відповідей >1KB, -70% розміру
app.get('/users', async (req, res) => {
const users = await db.query('SELECT * FROM users'); // async, event loop вільний
res.json(users);
});
// p95 затримка: 150ms -> ~8ms при 1k одночасних користувачівAsync-зміна важлива, бо Node.js однопотоковий. Один синхронний DB-виклик блокує всі інші запити в черзі.
Production vs. development режим
NODE_ENV=production - це не просто прапор. Express використовує його щоб кешувати шаблони замість перекомпіляції при кожному запиті, прибирати детальні stack trace з відповідей про помилки, і дозволяти middleware на зразок morgan пропускати кольорові виводи та debug-логи.
Реальна різниця відчутна: той самий endpoint йде з приблизно 200ms у dev-режимі до близько 40ms в production. Встановлюй змінну в менеджері процесів, а не лише локально.
# PM2
NODE_ENV=production pm2 start app.js
# Або напряму
NODE_ENV=production node app.jsКоли застосовувати кожну техніку
Різні вузькі місця потребують різних рішень. Спочатку профілюй з clinic.js або autocannon, потім виправляй.
- Event loop delay > 20ms: прибери синхронний I/O, зокрема
fs.readFileSyncв обробниках - Велике споживання трафіку: compression middleware, CDN для статики
- CPU одного ядра на 100%: cluster mode або PM2 cluster
- Повторні повільні DB-запити: Redis-кешування з TTL
- N+1 запити: eager loading через Sequelize
includeабо Prismainclude - Великий data export: streaming замість буферизації всього в пам'яті
- Роздача статики: передай Nginx (
sendfile,epoll) - приблизно в 10x швидше Express для файлів від 10KB
Як це працює всередині
Node.js виконує JavaScript в одному потоці, але делегує I/O в thread pool libuv (файлова система, DNS, crypto). При виклику fs.readFileSync() ти блокуєш цей єдиний JS-потік і всі інші запити чекають. Async-виклики передають роботу libuv і одразу звільняють потік.
Compression підключається до res.write() через zlib (C++ bindings). Рівень 6 - оптимальна точка: 80% стиснення при близько 20% overhead по CPU. Рівні 8-9 майже не покращують стиснення, але вдвічі збільшують навантаження на CPU.
Clustering викликає cluster.fork() для створення дочірніх процесів. Кожен worker - це повноцінний Node.js інстанс зі своїм V8 heap і event loop. Майстер розподіляє вхідні TCP-з'єднання через round-robin між воркерами, що спільно використовують порт. На 4-ядерному сервері замість одного event loop ти отримуєш чотири незалежних.
Типові помилки
Помилка 1: Великий ліміт body parser для всього застосунку
// Неправильно - парсить великі тіла синхронно, вектор DoS
app.use(express.json({ limit: '50mb' }));
// Правильно - жорсткий ліміт, Nginx відхиляє надто великі запити
app.use(express.json({ limit: '1mb', strict: false }));Великий парсинг в Node.js синхронний і блокує event loop на 100ms+ на запит під навантаженням.
Помилка 2: Синхронне читання файлів в обробниках запитів
// Неправильно - блокує всі запити на 200-500ms
app.get('/config', (req, res) => {
const config = fs.readFileSync('./config.json'); // event loop заблоковано
res.json(JSON.parse(config));
});
// Правильно - async і кешуємо результат в пам'яті
let cachedConfig = null;
app.get('/config', async (req, res) => {
if (!cachedConfig) {
const raw = await fs.promises.readFile('./config.json');
cachedConfig = JSON.parse(raw);
}
res.json(cachedConfig);
});Помилка 3: Compression middleware після роутерів
// Неправильно - роутери спрацьовують до compression, нічого не стискається
app.use('/api', router);
app.use(compression());
// Правильно - compression має бути першим
app.use(compression());
app.use('/api', router);Порядок middleware в Express - це порядок виконання. Якщо compression реєструється після роутера, відповіді вже надіслані до того, як compression може їх перехопити.
Помилка 4: Послідовні await для незалежних запитів
// Неправильно - кожен await чекає попереднього, в 3x повільніше
const users = await getUsers(); // 30ms
const products = await getProducts(); // +30ms
const orders = await getOrders(); // +30ms = 90ms загалом
// Правильно - паралельне виконання
const [users, products, orders] = await Promise.all([
getUsers(),
getProducts(),
getOrders()
]); // ~30ms загаломПомилка 5: Clustering без перезапуску воркерів після краша
// Неправильно - мертві воркери не замінюються, навантаження концентрується
cluster.on('exit', (worker) => {
console.log('Worker died'); // і нічого більше
});
// Правильно - завжди перезапускай мертвих воркерів
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} помер, перезапускаємо`);
cluster.fork();
});Без цього краш одного воркера непомітно знижує пропускну здатність. В production PM2 робить це автоматично: pm2 start app.js -i max.
Де використовується в реальних проєктах
- Netflix запускає Nginx перед Express-кластерами для API-шлюзів, з Redis-кешуванням важких recommendation payloads при понад мільярді запитів на день.
- PayPal використовує Helmet і rate-limit middleware на всіх Express-маршрутах fraud API.
- Slack роздає всю статику через Nginx і направляє до Express тільки динамічні запити з connection pooling.
- Високонавантажені API з понад 500 req/s майже завжди потребують clustering разом з connection pool, розмір якого дорівнює добутку кількості воркерів і DB-з'єднань на воркер.
З мого досвіду: команди, які спочатку профілюють з clinic.js, виправляють правильну проблему. Команди, що одразу переходять до clustering, часто просто масштабують своє вузьке місце.
Питання на співбесіді
Q: Як compression впливає на CPU при високій конкурентності?
A: zlib на рівні 6 додає 5-15% навантаження на CPU, але економить 70% трафіку. Перевір через autocannon. Якщо CPU постійно вище 80%, знизь до рівня 1 або встанови threshold: 2048, щоб пропускати малі відповіді.
Q: Поясни, як балансування навантаження (load balancing) в cluster працює на рівні ОС.
A: Майстер-процес слухає порт і розподіляє вхідні TCP-з'єднання між воркерами через round-robin. З Node 10.16 воркери також можуть спільно використовувати порт напряму через SO_REUSEPORT, дозволяючи ОС самій планувати з'єднання між процесами.
Q: Коли Redis-кешування стає проблемою замість рішення?
A: Cache stampede (thundering herd): коли TTL спливає і сотні запитів одночасно промахуються мимо кешу і звертаються до DB. Вирішується через probabilistic early expiry або mutex, що дозволяє тільки одному запиту оновити кеш поки інші чекають на результат.
Q: Застосунок досягає 100% CPU при 1k req/s попри clustering. Що перевіряєш першим?
A: Перевір вичерпання DB connection pool через активні з'єднання в pg-pool, потім частоту GC-пауз через --trace-gc. Висока активність GC зазвичай означає забагато короткоживучих об'єктів на запит. Heap-профайл через 0x або clinic flame покаже де саме відбувається алокація.
Q: Nginx чи Express для роздачі статичних файлів?
A: Nginx використовує системний виклик sendfile() (zero-copy) і epoll для async I/O на рівні ОС. Для файлів від 10KB він приблизно в 10x швидше Express. Express підходить для невеликої кількості статики під час розробки, але не в production.
Приклади
Базовий: Compression + production режим
const express = require('express');
const compression = require('compression');
const app = express();
app.set('env', 'production');
// Gzip для відповідей більше 1KB, менші пропускаємо
app.use(compression({ level: 6, threshold: 1024 }));
app.get('/data', (req, res) => {
const payload = { items: new Array(5000).fill({ id: 1, name: 'product' }) };
res.json(payload);
// Без стиснення: ~200KB | Зі стисненням: ~18KB | -84%
});
app.listen(3000);threshold: 1024 вмикає compression тільки для відповідей більше 1KB. Стискати дрібні відповіді - марна трата CPU без жодного виграшу.
Середній рівень: Cluster + Redis-кеш для API профілів користувачів
const cluster = require('cluster');
const os = require('os');
const express = require('express');
const { createClient } = require('redis');
if (cluster.isPrimary) {
// По одному воркеру на кожне ядро CPU
for (let i = 0; i < os.cpus().length; i++) {
cluster.fork();
}
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} помер, перезапускаємо`);
cluster.fork();
});
} else {
const app = express();
const redis = createClient();
redis.connect();
app.get('/user/:id', async (req, res) => {
const cacheKey = `user:${req.params.id}`;
// Спочатку перевіряємо кеш
const cached = await redis.get(cacheKey);
if (cached) return res.json(JSON.parse(cached));
// Cache miss - звертаємось до DB
const user = await db.users.findById(req.params.id);
await redis.setEx(cacheKey, 300, JSON.stringify(user)); // TTL 5 хвилин
res.json(user);
});
app.listen(3000);
}
// Результат: ~5k req/s на 4-ядерній машині проти ~1.2k req/s в одному процесі
// Cache hit rate: 85-95% після прогрівуTTL 300 секунд - баланс між актуальністю даних і частотою cache hit. Для рідко змінюваних даних на кшталт профілів або деталей продуктів 5 хвилин - безпечне значення за замовчуванням.
Просунутий рівень: Streaming великих експортів + connection pooling
const { Pool } = require('pg');
const Cursor = require('pg-cursor');
// Розмір пулу для cluster: max на воркер * кількість воркерів = загальні DB-з'єднання
const pool = new Pool({
max: 5, // 5 воркерів * 5 з'єднань = 25 загалом
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000
});
app.get('/api/export', async (req, res) => {
const client = await pool.connect();
try {
res.setHeader('Content-Type', 'application/json');
res.write('[');
// Стримуємо рядки замість завантаження всього в пам'ять
const cursor = client.query(
new Cursor('SELECT * FROM orders WHERE created_at > $1', [req.query.from])
);
let first = true;
let rows;
do {
rows = await cursor.read(100); // 100 рядків за раз
for (const row of rows) {
if (!first) res.write(',');
res.write(JSON.stringify(row));
first = false;
}
} while (rows.length === 100);
res.write(']');
res.end();
} finally {
client.release(); // завжди повертаємо з'єднання до пулу
}
});Streaming по 100 рядків означає, що експорт мільйона записів використовує сталий об'єм пам'яті замість завантаження всього в буфер. Блок finally гарантує повернення з'єднання до пулу навіть якщо клієнт від'єднається в процесі передачі.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.