Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як реалізувати стратегії кешування в NestJS?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Стратегії кешування в NestJS** реалізуються через `CacheModule` з пакету `@nestjs/cache-manager`. `CacheInterceptor` автоматично кешує GET-відповіді контролера. `CACHE_MANAGER` дає ручний контроль у сервісах: get, set, del. Один інстанс - in-memory. Кілька подів - Redis. Після кожного запису інвалідуй кеш.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Кешування в 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-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: Фіксований ключ на динамічному маршруті** ```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 при очищенні кількох пов'язаних ключів одночасно.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.