Skip to main content

Як оптимізувати продуктивність додатку 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 для повторних звернень.

Швидкий приклад

До/після - найбільші виграші в одному блоці:

js
// ДО: 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. Встановлюй змінну в менеджері процесів, а не лише локально.

bash
# 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 або Prisma include
  • Великий 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 для всього застосунку

js
// Неправильно - парсить великі тіла синхронно, вектор DoS app.use(express.json({ limit: '50mb' })); // Правильно - жорсткий ліміт, Nginx відхиляє надто великі запити app.use(express.json({ limit: '1mb', strict: false }));

Великий парсинг в Node.js синхронний і блокує event loop на 100ms+ на запит під навантаженням.

Помилка 2: Синхронне читання файлів в обробниках запитів

js
// Неправильно - блокує всі запити на 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 після роутерів

js
// Неправильно - роутери спрацьовують до compression, нічого не стискається app.use('/api', router); app.use(compression()); // Правильно - compression має бути першим app.use(compression()); app.use('/api', router);

Порядок middleware в Express - це порядок виконання. Якщо compression реєструється після роутера, відповіді вже надіслані до того, як compression може їх перехопити.

Помилка 4: Послідовні await для незалежних запитів

js
// Неправильно - кожен 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 без перезапуску воркерів після краша

js
// Неправильно - мертві воркери не замінюються, навантаження концентрується 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 режим

js
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 профілів користувачів

js
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

js
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 гарантує повернення з'єднання до пулу навіть якщо клієнт від'єднається в процесі передачі.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?