Skip to main content

Як реалізувати стратегії кешування в NestJS?

Кешування в NestJS зберігає результати важких операцій (запити до БД, складні обчислення) у пам'яті або Redis, щоб повторні запити не чекали їх повторного виконання.

Теорія

TL;DR

  • Аналогія: кеш - це дошка, куди пишеш відповіді на вже розв'язані задачі. Наступний запит читає з дошки, а не перераховує заново.
  • Два основних сховища: in-memory (один інстанс, нульова конфігурація) і Redis (спільний між подами, зберігається після рестарту).
  • CacheInterceptor кешує GET-відповіді контролера автоматично. CACHE_MANAGER дає ручний контроль у сервісах: get, set, del.
  • Правило вибору: один под або dev-середовище - in-memory. Кілька подів або понад 1GB даних - Redis.
  • Не кешуй дані реального часу (live-котирування, чат) і ендпоїнти де записів більше ніж читань.

Базове налаштування

Встанови пакети:

bash
npm install @nestjs/cache-manager cache-manager npm install cache-manager-redis-yet redis # тільки якщо використовуєш Redis

In-memory у app.module.ts:

typescript
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 зберігаються окремо автоматично.

typescript
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 напряму:

typescript
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 розв'язує цю проблему:

typescript
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-MemoryRedisMemcached
НалаштуванняCacheModule.register({ttl})redisStore({host, port})cache-manager-memcached
ПерсистентністьНемаєТак (AOF/RDB)Немає
РозподіленийНі (один процес)Так (кластер/шардинг)Так (consistent hashing)
ЕвікціяLRU (за замовчуванням max: 100)allkeys-lruLRU
Коли використовуватиЛокальна розробка, один подМікросервіси, 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: Фіксований ключ на динамічному маршруті

typescript
// Неправильно - всі ID продуктів отримують один кешований результат @Get(':id') @CacheKey('product-detail') findOne(@Param('id') id: string) { ... }

Всі юзери отримують той продукт, що закешувався першим. Використовуй динамічні ключі через CACHE_MANAGER у сервісі: `product:${id}`.

Помилка 2: Інтерсептор на рівні контролера кешує мутації

typescript
@Controller('orders') @UseInterceptors(CacheInterceptor) // застосовується до ВСІХ методів export class OrdersController { @Post() // ця мутація тепер кешується create(@Body() dto: CreateOrderDto) { ... } }

Застосовуй @UseInterceptors(CacheInterceptor) на рівні методу тільки для GET-хендлерів, не на рівні контролера де є мутації.

Помилка 3: Серіалізація складних об'єктів

typescript
await this.cache.set('data', { date: new Date(), count: 9007199254740993n }); // Викидає: Do not know how to serialize BigInt

JSON.stringify не підтримує BigInt, об'єкти Date і кругові посилання. Серіалізуй вручну перед збереженням або використовуй instanceToPlain з class-transformer.

Помилка 4: Redis без health-check

typescript
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 кеш для ендпоїнту продуктів

typescript
// 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. Дані змінюються рідко, але читаються при кожному завантаженні сторінки.

typescript
// 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 скрізь - виносимо в окремий хелпер:

typescript
// 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 при очищенні кількох пов'язаних ключів одночасно.

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

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

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

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