Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як оптимізувати продуктивність додатку Express.js?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Оптимізація продуктивності Express.js** - скорочення затримки відповідей і збільшення пропускної здатності production Node.js серверів. ```js app.set('env', 'production'); app.use(compression({ level: 6, threshold: 1024 })); app.get('/users', async (req, res) => { const users = await db.query('SELECT * FROM users'); // async, не блокує event loop res.json(users); }); ``` **Головне:** більшість проблем зі швидкодією Express виникає через DB і синхронний I/O. Встанови `NODE_ENV=production`, увімкни compression, виправ N+1 запити через eager loading, використовуй `Promise.all` для паралельних викликів. Clustering збільшує пропускну здатність на кожне ядро CPU.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Оптимізація продуктивності 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` гарантує повернення з'єднання до пулу навіть якщо клієнт від'єднається в процесі передачі.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.