Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як реалізувати кешування в Express.js для покращення продуктивності?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Кешування в Express.js** зберігає результати дорогих операцій (запити до бази, зовнішні API) і повертає збережену копію на повторні запити, зменшуючи час відповіді і навантаження на базу. ```js const NodeCache = require('node-cache'); const cache = new NodeCache({ stdTTL: 300 }); const hit = cache.get(req.originalUrl); if (hit) return res.json(hit); // пропускаємо handler // при відповіді: cache.set(key, body, ttl) ``` **Ключове:** node-cache для одного сервера, Redis для кількох інстансів, Cache-Control для браузерів і CDN.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Кешування в Express.js** зберігає результат дорогих операцій (запити до бази, зовнішні API-виклики) і повертає збережену копію на повторні запити замість того, щоб виконувати роботу знову. ## Теорія ### TL;DR - Три шари: пам'ять процесу (node-cache), розподілений кеш (Redis) і HTTP-заголовки для браузерів і CDN - node-cache швидкий, але зникає при рестарті процесу і не ділить стан між серверними інстансами - Redis переживає рестарти і працює на всіх Node-інстансах за балансувальником навантаження - Інвалідація при записі - місце, де живе більшість продакшен-помилок - Починай з коротких TTL (30-60с) і збільшуй залежно від того, як часто дані реально змінюються ### Швидкий приклад ```js const NodeCache = require('node-cache'); const cache = new NodeCache({ stdTTL: 300 }); // TTL 5 хвилин за замовчуванням function cacheMiddleware(duration) { return (req, res, next) => { const key = req.originalUrl; const hit = cache.get(key); if (hit) return res.json(hit); // є в кеші - пропускаємо handler const originalJson = res.json.bind(res); res.json = (body) => { cache.set(key, body, duration); // зберігаємо перед відправкою originalJson(body); }; next(); }; } app.get('/api/products', cacheMiddleware(300), async (req, res) => { const products = await db.query('SELECT * FROM products'); res.json(products); // це також записує в кеш }); ``` Middleware перехоплює `res.json`, зберігає тіло відповіді до відправки, а на наступний запит до того ж URL повертає збережений результат одразу. ### Пам'ять процесу vs Redis node-cache зберігає дані всередині того ж Node.js-процесу. Мережевого виклику немає, тому це найшвидший варіант. Але є два жорстких обмеження: кеш зникає при рестарті процесу, і якщо запущено кілька інстансів (load balancer, PM2 cluster mode), кожен інстанс тримає свою окрему копію. Redis - окремий сервіс. Усі Node-інстанси звертаються до одного Redis, тому кеш спільний. Переживає рестарти, масштабується на будь-яку кількість інстансів, підтримує TTL нативно. Компроміс - мережевий round-trip: зазвичай 1-2мс локально і 5-15мс в хмарних середовищах. Багато команд запускають node-cache в розробці і Redis в production. Це нормально, якщо ти ховаєш кеш-шар за єдиним інтерфейсом, щоб перемикання було зміною одного рядка. ### Як працюють HTTP-заголовки кешування Третій шар - на боці клієнта. `Cache-Control` у відповідях повідомляє браузерам і CDN не робити запит взагалі, якщо є свіжа копія. ```js // Публічні дані - CDN і браузер можуть кешувати app.get('/api/products', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); // 5 хвилин res.json(products); }); // Дані конкретного юзера - тільки браузер, не CDN app.get('/api/profile', authMiddleware, (req, res) => { res.set('Cache-Control', 'private, no-cache'); res.json(user); }); ``` ETag йде далі. Сервер надсилає хеш тіла відповіді. Браузер повертає цей хеш при наступному запиті. Якщо нічого не змінилось, сервер відповідає 304 з порожнім тілом. ```js const etag = require('etag'); app.get('/api/config', (req, res) => { const data = getAppConfig(); const tag = etag(JSON.stringify(data)); if (req.headers['if-none-match'] === tag) { return res.status(304).end(); // тіло не відправляємо } res.set('ETag', tag); res.set('Cache-Control', 'public, max-age=60'); res.json(data); }); ``` Для великих відповідей - об'єкти конфігурації, довідкові дані - це суттєво зменшує трафік при повторних відвідуваннях. ### Інвалідація кешу Тут і починається безлад у продакшені. Будь-яка операція запису повинна одразу видаляти кешовану версію ресурсу. ```js app.post('/api/products', async (req, res) => { const product = await createProduct(req.body); // Видаляємо застарілі дані з обох кешів cache.del('/api/products'); await redis.del('cache:/api/products'); res.status(201).json(product); }); ``` Для інвалідації за шаблоном у Redis (пагіновані маршрути на кшталт `/api/products?page=1`): ```js async function invalidatePattern(pattern) { const keys = await redis.keys(pattern); // у production використовуй SCAN if (keys.length > 0) { await redis.del(...keys); } } await invalidatePattern('cache:/api/products*'); ``` `redis.keys()` блокує event loop при великій кількості ключів. У production із тисячами закешованих записів замінюй його на `redis.scanStream()`. ### Порівняння стратегій кешування | Стратегія | Швидкість | Спільна для кількох інстансів | Переживає рестарти | Найкраще для | |---|---|---|---|---| | node-cache | Найвища | Ні | Ні | Один сервер, розробка | | Redis | Дуже висока | Так | Так | Продакшен API | | HTTP-заголовки | На клієнті | Підходить для CDN | Браузер | Публічний контент | | CDN | Найвища для юзера | Глобальна | На edge | Статика, публічні API | ### Що кешувати, а що ні Кешуй результати запитів до бази, відповіді зовнішніх API, обчислені агрегації - все, що дорого перераховувати і не є унікальним для кожного юзера окремо. Не кешуй токени аутентифікації, дані реального часу (ціни, залишки на складі) і відповіді, специфічні для юзера - якщо тільки ID юзера не є частиною ключа кешу. ### Типові помилки **1. Використання `req.path` замість `req.originalUrl` як ключа кешу** `req.path` відрізає query params, тому `/api/products?category=shoes` і `/api/products?category=hats` дадуть один і той самий запис у кеші. `req.originalUrl` включає повний рядок запиту - використовуй його. **2. Кешування відповідей з помилками** ```js // Неправильно: кешує навіть відповіді з кодом 500 res.json = (body) => { cache.set(key, body, duration); originalJson(body); }; // Правильно: зберігаємо тільки успішні відповіді res.json = (body) => { if (res.statusCode >= 200 && res.statusCode < 300) { cache.set(key, body, duration); } originalJson(body); }; ``` **3. Відсутність fallback при недоступності Redis** Якщо Redis падає і навколо read немає try-catch, middleware кидає виняток і весь API лягає. Завжди перехоплюй помилки Redis і викликай `next()`, щоб запит пішов далі до handler. **4. Інвалідація тільки при POST, але не при PUT і PATCH** Більшість розробників пам'ятають про POST. Але оновлення через PUT і PATCH теж змінюють дані, і кеш потрібно очищати і після них. **5. Використання `redis.keys()` в production** `KEYS *pattern*` синхронно сканує весь keyspace Redis і блокує всі інші операції. Для будь-якого набору даних більше кількох сотень ключів використовуй `SCAN` з курсором. ### Де це використовують - Express API з публічними каталогами продуктів: Redis з TTL 5-10 хвилин - Rate limiting разом із кешуванням відповідей: Redis обробляє обидва під різними неймспейсами ключів - Next.js API routes (Node бекенд): ті самі патерни middleware, той самий Redis - GraphQL сервери на Express: кешування за хешем запиту, а не за URL - Endpoints з високим трафіком: HTTP-заголовки і CDN як перший шар, Redis як другий ### Follow-up питання **Q:** Як кешувати дані, специфічні для юзера, не змішуючи відповіді між різними юзерами? **A:** Включи ID юзера в ключ кешу: `` `cache:${req.originalUrl}:${req.user.id}` ``. Кожен юзер отримує свій окремий запис. Коштує більше пам'яті, але безпечно. **Q:** Яка різниця між `max-age` і `s-maxage` у Cache-Control? **A:** `max-age` стосується всіх кешів, включаючи браузер. `s-maxage` стосується тільки спільних кешів, наприклад CDN. Можна поєднувати: `public, max-age=60, s-maxage=300` означає, що браузер тримає копію 1 хвилину, а CDN - 5 хвилин. **Q:** Коли краще кешувати на рівні бази даних, а не на рівні застосунку? **A:** Якщо запити дуже різноманітні (багато комбінацій фільтрів), кешування на рівні застосунку породжує велику кількість ключів, більшість з яких звертаються один раз. Query result caching в базі або read replicas краще справляються з цим. Кешування на рівні застосунку найкраще працює для обмеженого набору дорогих, але часто повторюваних запитів. **Q:** Як прогріти кеш після рестарту Redis або нового деплою? **A:** Або приймаєш холодний старт - перші запити повільні, поки кеш наповнюється - або пишеш warmup-скрипт, який звертається до критичних endpoints одразу після деплою. Єдиного рецепту немає: залежить від того, наскільки болючим для тебе є той холодний проміжок під реальним трафіком. ## Приклади ### Кешування в пам'яті з заголовком для дебагінгу ```js const express = require('express'); const NodeCache = require('node-cache'); const app = express(); const cache = new NodeCache({ stdTTL: 300, checkperiod: 60 }); function cacheMiddleware(duration) { return (req, res, next) => { const key = req.originalUrl; const hit = cache.get(key); if (hit) { res.set('X-Cache', 'HIT'); // зручно для дебагінгу return res.json(hit); } res.set('X-Cache', 'MISS'); const originalJson = res.json.bind(res); res.json = (body) => { if (res.statusCode >= 200 && res.statusCode < 300) { cache.set(key, body, duration); } originalJson(body); }; next(); }; } app.get('/api/products', cacheMiddleware(300), async (req, res) => { const products = await db.query('SELECT * FROM products LIMIT 100'); res.json(products); }); ``` Заголовок `X-Cache` показує, звідки прийшла відповідь - з кешу чи з бази. Корисно під час навантажувального тестування або при пошуку причин застарілих даних. ### Redis кешування з деградацією при збої ```js const Redis = require('ioredis'); const redis = new Redis(process.env.REDIS_URL); function redisCacheMiddleware(ttl = 300) { return async (req, res, next) => { const key = `cache:${req.originalUrl}`; try { const cached = await redis.get(key); if (cached) { return res.json(JSON.parse(cached)); } } catch (err) { // Redis недоступний - пропускаємо кеш console.error('Redis read error:', err); return next(); } const originalJson = res.json.bind(res); res.json = async (body) => { try { if (res.statusCode >= 200 && res.statusCode < 300) { await redis.setex(key, ttl, JSON.stringify(body)); } } catch (err) { console.error('Redis write error:', err); // Не блокуємо відповідь } originalJson(body); }; next(); }; } // Інвалідація після запису app.post('/api/products', async (req, res) => { const product = await createProduct(req.body); await redis.del('cache:/api/products'); res.status(201).json(product); }); ``` Головне тут: якщо Redis недоступний, запит іде далі до handler. API залишається живим, просто без кеш-шару до відновлення Redis. ### Умовні запити на основі ETag ```js const etag = require('etag'); app.get('/api/config', (req, res) => { const config = getAppConfig(); // дороге завантаження конфігу const tag = etag(JSON.stringify(config)); // Браузер повертає ETag, який отримав минулого разу if (req.headers['if-none-match'] === tag) { return res.status(304).end(); // використовуй свою копію } res.set('ETag', tag); res.set('Cache-Control', 'public, max-age=60'); res.json(config); }); ``` 304 означає відсутність тіла у відповіді. Для великих конфігураційних об'єктів або довідкових даних, що рідко змінюються, це помітно зменшує трафік при повторних зверненнях.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.