Як реалізувати кешування в Express.js для покращення продуктивності?
Кешування в Express.js зберігає результат дорогих операцій (запити до бази, зовнішні API-виклики) і повертає збережену копію на повторні запити замість того, щоб виконувати роботу знову.
Теорія
TL;DR
- Три шари: пам'ять процесу (node-cache), розподілений кеш (Redis) і HTTP-заголовки для браузерів і CDN
- node-cache швидкий, але зникає при рестарті процесу і не ділить стан між серверними інстансами
- Redis переживає рестарти і працює на всіх Node-інстансах за балансувальником навантаження
- Інвалідація при записі - місце, де живе більшість продакшен-помилок
- Починай з коротких TTL (30-60с) і збільшуй залежно від того, як часто дані реально змінюються
Швидкий приклад
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 не робити запит взагалі, якщо є свіжа копія.
// Публічні дані - 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 з порожнім тілом.
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);
});Для великих відповідей - об'єкти конфігурації, довідкові дані - це суттєво зменшує трафік при повторних відвідуваннях.
Інвалідація кешу
Тут і починається безлад у продакшені. Будь-яка операція запису повинна одразу видаляти кешовану версію ресурсу.
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):
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. Кешування відповідей з помилками
// Неправильно: кешує навіть відповіді з кодом 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 одразу після деплою. Єдиного рецепту немає: залежить від того, наскільки болючим для тебе є той холодний проміжок під реальним трафіком.
Приклади
Кешування в пам'яті з заголовком для дебагінгу
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 кешування з деградацією при збої
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
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 означає відсутність тіла у відповіді. Для великих конфігураційних об'єктів або довідкових даних, що рідко змінюються, це помітно зменшує трафік при повторних зверненнях.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.