Як реалізувати стратегії кешування в NestJS?
Кешування в NestJS зберігає результати важких операцій (запити до БД, складні обчислення) у пам'яті або Redis, щоб повторні запити не чекали їх повторного виконання.
Теорія
TL;DR
- Аналогія: кеш - це дошка, куди пишеш відповіді на вже розв'язані задачі. Наступний запит читає з дошки, а не перераховує заново.
- Два основних сховища: in-memory (один інстанс, нульова конфігурація) і Redis (спільний між подами, зберігається після рестарту).
CacheInterceptorкешує GET-відповіді контролера автоматично.CACHE_MANAGERдає ручний контроль у сервісах: get, set, del.- Правило вибору: один под або dev-середовище - in-memory. Кілька подів або понад 1GB даних - Redis.
- Не кешуй дані реального часу (live-котирування, чат) і ендпоїнти де записів більше ніж читань.
Базове налаштування
Встанови пакети:
npm install @nestjs/cache-manager cache-manager
npm install cache-manager-redis-yet redis # тільки якщо використовуєш RedisIn-memory у app.module.ts:
import { CacheModule } from '@nestjs/cache-manager';
@Module({
imports: [
CacheModule.register({
isGlobal: true, // доступний у всіх модулях без повторного імпорту
ttl: 60000, // 60 секунд у мілісекундах
max: 100, // LRU-евікція після 100 елементів
}),
],
})
export class AppModule {}isGlobal: true - і більше не треба імпортувати CacheModule у кожен модуль окремо.
Автоматичне кешування через CacheInterceptor
CacheInterceptor вбудовується в ланцюг інтерсепторів NestJS і кешує GET-відповіді без жодного ручного коду. Ключ кешу формується з URL запиту і query-параметрів, тому /products?page=1 і /products?page=2 зберігаються окремо автоматично.
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheKey, CacheTTL } from '@nestjs/cache-manager';
@Controller('products')
export class ProductsController {
@Get()
@UseInterceptors(CacheInterceptor) // кешуємо тільки цей метод
@CacheTTL(30000) // перевизначаємо TTL: 30s для цього ендпоїнту
findAll() {
return this.productsService.findAll();
}
@Get(':id')
@UseInterceptors(CacheInterceptor)
@CacheKey('product-detail') // фіксований ключ - однаковий для всіх :id!
@CacheTTL(60000)
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
}@CacheKey('product-detail') на findOne - це пастка. Фіксований рядок означає, що всі ID продуктів повертають один і той самий кешований результат. Детальніше в розділі типових помилок.
Ручне управління кешем через CACHE_MANAGER
Коли потрібен контроль на рівні сервісу - кеш всередині бізнес-логіки або інвалідація після оновлення - інжектуй CACHE_MANAGER напряму:
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class ProductsService {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
async findOne(id: string) {
const key = `product:${id}`;
const cached = await this.cache.get(key);
if (cached) return cached; // hit: ~1ms
const product = await this.db.findById(id); // miss: запит до БД ~80ms
await this.cache.set(key, product, 60000);
return product;
}
async update(id: string, data: UpdateProductDto) {
const product = await this.db.update(id, data);
await this.cache.del(`product:${id}`); // інвалідація одразу після запису
return product;
}
}Це патерн cache-aside: читаємо з кешу, при промаху звертаємось до джерела, пишемо результат назад.
Redis для кількох інстансів
In-memory кеш зникає разом із процесом і не розподіляється між подами. Якщо запустити два NestJS інстанси за балансувальником, кожен матиме окремий кеш. Перший юзер потрапить на под 1 (кеш теплий), другий - на под 2 (кеш холодний), і той самий запит до БД виконається двічі. Redis розв'язує цю проблему:
import { redisStore } from 'cache-manager-redis-yet';
CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => ({
store: await redisStore({
socket: {
host: process.env.REDIS_HOST || 'localhost',
port: 6379,
pingInterval: 30000, // keep-alive
retryDelay: 100, // повтор після збою підключення
},
ttl: 300000,
}),
}),
})Без pingInterval і retryDelay мережевий збій призводить до тихих 500-помилок. Підключення падає, а додаток про це не знає до наступного рестарту.
Порівняння: in-memory, Redis, Memcached
| Характеристика | In-Memory | Redis | Memcached |
|---|---|---|---|
| Налаштування | CacheModule.register({ttl}) | redisStore({host, port}) | cache-manager-memcached |
| Персистентність | Немає | Так (AOF/RDB) | Немає |
| Розподілений | Ні (один процес) | Так (кластер/шардинг) | Так (consistent hashing) |
| Евікція | LRU (за замовчуванням max: 100) | allkeys-lru | LRU |
| Коли використовувати | Локальна розробка, один под | Мікросервіси, K8s | Висока пропускна здатність, legacy |
Як це працює всередині
CacheModule обгортає cache-manager v5+. При додаванні CacheInterceptor він реєструється як APP_INTERCEPTOR у DI-контейнері. На кожен запит інтерсептор формує ключ з URL і query-рядка, викликає store.get(key). При попаданні - повертає результат, минаючи хендлер. При промаху - виконує хендлер і зберігає через store.set(key, JSON.stringify(result), ttl). Для Redis під капотом використовується ioredis - асинхронний клієнт, що не блокує event loop.
Один момент, який неодноразово бачив у production: стандартне max: 100 для in-memory означає, що 101-й унікальний ключ витискає найстаріший запис. Якщо в додатку тисячі ID продуктів, більшість запитів будуть cache miss, хоча на перший погляд кеш ніби є. Піднімай max під реальний розмір робочого набору даних.
Типові помилки
Помилка 1: Фіксований ключ на динамічному маршруті
// Неправильно - всі ID продуктів отримують один кешований результат
@Get(':id')
@CacheKey('product-detail')
findOne(@Param('id') id: string) { ... }Всі юзери отримують той продукт, що закешувався першим. Використовуй динамічні ключі через CACHE_MANAGER у сервісі: `product:${id}`.
Помилка 2: Інтерсептор на рівні контролера кешує мутації
@Controller('orders')
@UseInterceptors(CacheInterceptor) // застосовується до ВСІХ методів
export class OrdersController {
@Post() // ця мутація тепер кешується
create(@Body() dto: CreateOrderDto) { ... }
}Застосовуй @UseInterceptors(CacheInterceptor) на рівні методу тільки для GET-хендлерів, не на рівні контролера де є мутації.
Помилка 3: Серіалізація складних об'єктів
await this.cache.set('data', { date: new Date(), count: 9007199254740993n });
// Викидає: Do not know how to serialize BigIntJSON.stringify не підтримує BigInt, об'єкти Date і кругові посилання. Серіалізуй вручну перед збереженням або використовуй instanceToPlain з class-transformer.
Помилка 4: Redis без health-check
store: await redisStore({ socket: { host: 'prod.redis.internal' } })
// Немає pingInterval, немає retryDelay - додаток 500-ить при мережевому збоїДодавай pingInterval: 30000 і retryDelay. Без них підключення падає тихо, і кожен виклик кешу кидає виняток до наступного рестарту процесу.
Помилка 5: TTL рівний 0 або не вказаний
В деяких версіях cache-manager ttl: 0 означає "ніколи не закінчується", а не "не кешувати". Елементи накопичуються в пам'яті до вичерпання RAM. Завжди встановлюй явний позитивний TTL. Для актуальних даних використовуй інвалідацію через події.
Де зустрічається в реальних проектах
- E-commerce списки товарів: кешуємо
findManyз TTL 60s, інвалідуємо при оновленні наявності черезcacheManager.del. - GraphQL-резолвери:
@CacheKeyна методах резолверів для федеративних схем де один запит виконується в кількох підграфах. - Мікросервіси: Redis pub/sub для крос-сервісної інвалідації - один сервіс публікує "user.updated", інший підписується і видаляє ключ
`user:${id}`. - Разом з
@nestjs/throttler: кешування зменшує навантаження на БД, throttler обмежує частоту запитів - разом покривають більшість проблем з навантаженням.
Питання на співбесіді
Q: Як CacheInterceptor формує ключі кешу?
A: Використовує URL запиту плюс query-рядок, з префіксом nest:. Метод і тіло запиту не включаються за замовчуванням. Можна перевизначити через власну реалізацію CacheKeyStrategy.
Q: В чому різниця між @CacheKey() і @CacheTTL()?
A: @CacheKey встановлює ідентифікатор запису в сховищі і замінює автогенерований ключ на основі URL. @CacheTTL перевизначає TTL з конфігурації CacheModule.register тільки для цього конкретного методу.
Q: Як запобігти cache stampede (thundering herd)?
A: Stampede виникає коли багато запитів одночасно потрапляють на холодний кеш і всі йдуть до БД. Рішення - mutex: встановити тимчасовий ключ-замок через SETNX у Redis перед вибіркою, звільнити після запису. Інші запити що знаходять замок - чекають або повертають застарілі дані.
Q: У K8s з 10 подами як інвалідувати кеш на всіх без Redis pub/sub?
A: Короткі TTL плюс write-through інвалідація через спільний Redis-інстанс до якого підключені всі поди. Якщо тільки in-memory - sticky sessions тримають юзера на одному поді, але дев'ять інших подів все одно матимуть застарілі дані. Реальна відповідь: в multi-pod середовищі завжди Redis.
Q: Коли cacheManager.del() не спрацьовує як очікується?
A: Коли ключ зберігався з префіксом (наприклад nest:users), але del('users') викликається без нього. Також delMany у версіях cache-manager до v7 не підтримує Redis pipelines, і масові видалення можуть завершитись без помилки, але і без результату. Для атомарного видалення кількох ключів використовуй явні pipeline-виклики.
Приклади
Базовий: in-memory кеш для ендпоїнту продуктів
// app.module.ts
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
@Module({
imports: [
CacheModule.register({
isGlobal: true,
ttl: 60000,
max: 200, // збільшуємо з дефолтного 100 для більшої кількості унікальних ключів
}),
],
})
export class AppModule {}
// products.controller.ts
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
@UseInterceptors(CacheInterceptor) // кешуємо тільки цей GET
@CacheTTL(30000) // 30s для цього ендпоїнту
findAll() {
return this.productsService.findAll();
// Перший виклик: запит до БД ~80ms. Наступні: ~1ms з кешу.
}
}Інтерсептор генерує ключ з /products плюс query-рядок. Додай ?category=shoes - окремий запис у кеші без жодного додаткового коду.
Середній: Redis з ручною інвалідацією при оновленні
Реальний сценарій: профіль юзера в e-commerce. Дані змінюються рідко, але читаються при кожному завантаженні сторінки.
// user-cache.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class UserCacheService {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
async getProfile(userId: string) {
const key = `user:${userId}`;
const cached = await this.cache.get(key);
if (cached) return cached; // hit: ~1ms
const profile = await this.db.users.findById(userId); // miss: ~80ms
await this.cache.set(key, profile, 300000); // 5 хвилин
return profile;
}
async invalidate(userId: string) {
await this.cache.del(`user:${userId}`);
}
}
// users.controller.ts
@Controller('users')
export class UsersController {
constructor(
private readonly userCache: UserCacheService,
private readonly usersService: UsersService,
) {}
@Get(':id')
getProfile(@Param('id') id: string) {
return this.userCache.getProfile(id);
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
const updated = await this.usersService.update(id, dto);
await this.userCache.invalidate(id); // кеш очищено, наступний GET отримає свіжі дані
return updated;
}
}PATCH спочатку оновлює БД, потім очищає кеш. Наступний GET перебудовує запис кешу зі свіжого стану БД.
Просунутий: повторно використовуваний хелпер cache-aside з TypeScript generics
Цей патерн з'являється в будь-якому сервісі з кількома кешованими ресурсами. Замість повторення get/check/set скрізь - виносимо в окремий хелпер:
// cache.helper.ts
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class CacheHelper {
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
async getOrSet<T>(
key: string,
factory: () => Promise<T>,
ttl: number,
): Promise<T> {
const cached = await this.cache.get<T>(key);
if (cached !== null && cached !== undefined) return cached;
const value = await factory();
await this.cache.set(key, value, ttl);
return value;
}
async invalidateMany(keys: string[]): Promise<void> {
await Promise.all(keys.map((k) => this.cache.del(k)));
}
}
// orders.service.ts
@Injectable()
export class OrdersService {
constructor(
private readonly cacheHelper: CacheHelper,
private readonly db: PrismaService,
) {}
async getOrderSummary(userId: string) {
return this.cacheHelper.getOrSet(
`orders:summary:${userId}`,
() => this.db.order.findMany({ where: { userId }, select: { id: true, total: true } }),
120000, // 2 хвилини
);
}
async cancelOrder(orderId: string, userId: string) {
await this.db.order.update({ where: { id: orderId }, data: { status: 'cancelled' } });
await this.cacheHelper.invalidateMany([
`order:${orderId}`,
`orders:summary:${userId}`, // пов'язаний агрегат теж очищаємо
]);
}
}invalidateMany використовує Promise.all для паралельного видалення замість послідовних await. На Redis це економить час на round-trip при очищенні кількох пов'язаних ключів одночасно.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.