Caching dans NestJS
TL;DR —
@nestjs/cache-managerest l'abstraction officielle Nest au-dessus decache-manager. Tu choisis un store (memory, Redis, Memcached) et tu usesCacheInterceptor,CacheModule.register()ou directementCACHE_MANAGER. Le vrai travail senior n'est pas l'installation : c'est la stratégie de clé, l'invalidation, la prévention du cache stampede, et la mesure du hit ratio en production.
🧠 Mental model
Un cache n'est pas un store de données. C'est un index secondaire opportuniste et oubliable. Si tu commences à raisonner comme si "la donnée est dans le cache", tu construis des bugs de cohérence. Le bon modèle mental :
+----------------+
request ---> | controller |
+-------+--------+
|
v
+----------------+ miss
| cache layer +-------------------+
+-------+--------+ |
| hit v
v +----------------+
response | source of truth|
^ | (DB / API) |
| +-------+--------+
| |
+----------------------------+
populate (set + ttl)Analogie : le cache est un post-it sur ton écran. La source de vérité, c'est le classeur dans l'armoire. Le post-it peut tomber, être périmé ou disparaître. Tu n'écris jamais quelque chose seulement sur le post-it.
Trois axes à toujours expliciter avant d'écrire du code :
- Quoi cacher ? — Output HTTP entier, fragment de réponse, résultat d'une query SQL coûteuse, résultat d'un calcul pur, résultat d'un appel HTTP sortant.
- Combien de temps ? — TTL court (secondes) pour rafraîchir vite, TTL long (heures) pour de la donnée stable, TTL infini avec invalidation explicite pour de la donnée immutable.
- Comment invalider ? — TTL seul, événement métier, tag-based, versioning de clé.
🛠️ Code minimal
Installation :
npm i @nestjs/cache-manager cache-manager
npm i cache-manager-ioredis-yet ioredis # store Redis moderneModule racine avec store mémoire pour le développement et Redis en production :
// src/cache/app-cache.module.ts
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { redisStore } from 'cache-manager-ioredis-yet';
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (config: ConfigService) => {
const useRedis = config.get<string>('CACHE_DRIVER') === 'redis';
if (!useRedis) {
return {
ttl: 30_000, // ms en v5 cache-manager
max: 1000,
};
}
return {
store: await redisStore({
host: config.getOrThrow('REDIS_HOST'),
port: config.get<number>('REDIS_PORT', 6379),
password: config.get('REDIS_PASSWORD'),
ttl: 60_000,
}),
};
},
}),
],
})
export class AppCacheModule {}Service consommateur direct (recommandé pour la majorité des cas) :
// src/catalog/product.service.ts
import { Inject, Injectable, Logger } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class ProductService {
private readonly logger = new Logger(ProductService.name);
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly repo: ProductRepository,
) {}
async findOne(id: string): Promise<Product> {
const key = `product:v2:${id}`;
const cached = await this.cache.get<Product>(key);
if (cached) {
this.logger.debug(`cache hit ${key}`);
return cached;
}
const product = await this.repo.findOneOrFail(id);
await this.cache.set(key, product, 60_000); // 60 s
return product;
}
async invalidate(id: string): Promise<void> {
await this.cache.del(`product:v2:${id}`);
}
}Intercepteur HTTP pour cacher un endpoint GET complet :
// src/catalog/product.controller.ts
import { CacheInterceptor, CacheTTL, CacheKey } from '@nestjs/cache-manager';
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common';
@Controller('products')
@UseInterceptors(CacheInterceptor)
export class ProductController {
constructor(private readonly service: ProductService) {}
@Get(':id')
@CacheKey('product:show') // préfixe de clé
@CacheTTL(30_000) // 30 s
show(@Param('id') id: string) {
return this.service.findOne(id);
}
}🎯 Patterns courants
1. Cache-aside (lazy loading)
C'est le pattern par défaut, montré ci-dessus : get -> miss -> load -> set. Avantages : simple, robuste si Redis tombe (on dégrade en hitting la DB). Inconvénients : première requête après expiration toujours lente, risque de stampede.
async get<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> {
const hit = await this.cache.get<T>(key);
if (hit !== undefined && hit !== null) return hit;
const value = await loader();
await this.cache.set(key, value, ttl);
return value;
}2. Write-through
Tu écris dans la DB et dans le cache dans la même transaction logique. Tu garantis que la prochaine lecture sera un hit. Coût : chaque write paie le prix du cache.
async update(id: string, dto: UpdateProductDto): Promise<Product> {
const product = await this.repo.update(id, dto);
await this.cache.set(`product:v2:${id}`, product, 60_000);
return product;
}3. Invalidation par événement
Pour les caches partagés entre services, l'invalidation TTL seule ne suffit pas. Tu publies un événement (RabbitMQ, Kafka, Redis pub/sub) et chaque instance invalide.
@OnEvent('product.updated')
async onProductUpdated(payload: { id: string }) {
await this.cache.del(`product:v2:${payload.id}`);
await this.cache.del(`product:list:*`); // pseudo : voir tag-based
}4. Tag-based invalidation
cache-manager ne supporte pas nativement les tags. On les simule avec un index secondaire dans Redis : pour chaque entrée, on stocke la liste des tags dans un Set, et pour chaque tag, on stocke la liste des clés.
async tagSet(key: string, value: unknown, ttl: number, tags: string[]) {
await this.cache.set(key, value, ttl);
await Promise.all(
tags.map((tag) => this.redis.sadd(`tag:${tag}`, key)),
);
}
async invalidateTag(tag: string) {
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length) {
await this.cache.store.mdel?.(...keys);
}
await this.redis.del(`tag:${tag}`);
}5. Cache versioning
Plutôt qu'un flush global après un déploiement qui change le schéma, tu incrémentes un préfixe : product:v2:${id} devient product:v3:${id}. L'ancien set expire seul via son TTL. Zéro downtime, zéro flush coûteux.
const CACHE_VERSION = process.env.CACHE_VERSION ?? 'v1';
const key = `product:${CACHE_VERSION}:${id}`;6. Negative caching
Cacher un "non-trouvé" évite de marteler la DB quand un attaquant scanne des IDs. Attention : TTL court (5-30s) et flag explicite pour ne pas confondre avec une vraie donnée.
const SENTINEL = Symbol.for('cache.miss');
const hit = await this.cache.get(key);
if (hit === 'NOT_FOUND') throw new NotFoundException();
if (hit) return hit;
try {
const value = await this.repo.findOneOrFail(id);
await this.cache.set(key, value, 60_000);
return value;
} catch (e) {
if (e instanceof EntityNotFoundError) {
await this.cache.set(key, 'NOT_FOUND', 10_000);
}
throw e;
}🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- Nest 7 :
CacheModulevivait dans@nestjs/common. API basée surcache-manager@3. TTL en secondes. Signature :cache.set(key, value, { ttl: 30 }). - Nest 8 : Toujours dans
@nestjs/common. Support amélioré des stores async.cache-manager@4(toujours secondes). - Nest 9 : Le module est extrait dans
@nestjs/cache-manager(package séparé). Tu dois migrer les imports :import { CacheModule, CacheInterceptor } from '@nestjs/cache-manager'. La cible restecache-manager@4. - Nest 10 : Bascule officielle vers
cache-manager@5. Breaking change majeur : TTL exprimé en millisecondes et signature simplifiéecache.set(key, value, 30_000). Les anciens stores (cache-manager-redis-store) ne sont plus compatibles ; bascule verscache-manager-ioredis-yetou@keyv/redis.CacheTTL()accepte aussi des ms. - Nest 11 : Continuité avec
cache-manager@5/@6. Support officiel du store Keyv (@nestjs/cache-manageraccepte un objet Keyv directement). Les decoratorsCacheKey/CacheTTLsont stables. LeCacheInterceptorne traite plus les requêtes non-GETpar défaut (déjà le cas avant mais maintenant documenté).
Côté libs tierces, surveille :
cache-manager-redis-store(legacy, éviter) vscache-manager-ioredis-yet(recommandé sur cache-manager v5+).@keyv/redisqui devient le standard via Keyv pour la v6.ioredisv5.x stable, compatible cluster et sentinel.
⚠️ Pitfalls
- Confondre secondes et millisecondes au passage de Nest 9 -> 10. Un TTL
30après upgrade = 30 ms et tu invalides en permanence. Toujours expliciter30_000et constantifier. - Cacher un objet mutable.
cache.set('k', user)puis tu modifiesuser.role = 'admin'. En mémoire, tu mutes la valeur cachée. En Redis, tu mutes une copie déjà sérialisée mais le caller suivant en mémoire est piégé. Toujours retourner des objets figés ou clonés (structuredClone). - Clé non déterministe. Hash d'objet avec
Object.keysnon triés ouJSON.stringifyd'un objet avec ordre de clés instable. Usesafe-stable-stringifyou un builder de clé explicite. - Cache stampede. Au moment de l'expiration d'une clé chaude, 5000 requêtes ratent en même temps et tapent la DB. Voir patterns anti-thundering-herd plus bas.
CacheInterceptorglobal non scoped par utilisateur. Tu caches/meau niveau interceptor et tous les users voient les données du premier. OverridetrackBy(context)ou désactive le cache sur les routes authentifiées.- Oublier d'invalider après un
bulk update. Tu fais unUPDATE products SET ... WHERE category_id = ?et tu nettoies juste un produit. La liste reste périmée. Soit tu listes les IDs et invalides un à un, soit tu bumps la version d'un namespace. - Cache de réponse HTTP avec headers d'authent dans le body. Tu sers à un user A le CSRF token d'un user B. Vérifier que la route n'embarque rien d'user-specific avant
CacheInterceptor. - Trop de petites clés sur Redis. Chaque clé consomme de la mémoire pour son entrée dans la table de hash interne. Préférer un hash Redis (
HSET) quand tu caches 50 sous-champs d'un même objet. cache.reset()en production. Sur Redis partagé, çaFLUSHDBet nuque tout, y compris d'autres apps. Préférer une stratégie de versioning.ttl: 0. Selon le store, ça veut dire "jamais expirer" ou "expirer immédiatement". Toujours valider par un test, et bannir0au niveau lint.
🧪 Testing
Test unitaire d'un service qui consomme le cache :
// product.service.spec.ts
import { Test } from '@nestjs/testing';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { ProductService } from './product.service';
import { ProductRepository } from './product.repository';
describe('ProductService caching', () => {
let service: ProductService;
let repo: jest.Mocked<ProductRepository>;
let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock };
beforeEach(async () => {
cache = { get: jest.fn(), set: jest.fn(), del: jest.fn() };
repo = { findOneOrFail: jest.fn() } as any;
const moduleRef = await Test.createTestingModule({
providers: [
ProductService,
{ provide: ProductRepository, useValue: repo },
{ provide: CACHE_MANAGER, useValue: cache },
],
}).compile();
service = moduleRef.get(ProductService);
});
it('returns cached value without hitting repo', async () => {
cache.get.mockResolvedValue({ id: '1', name: 'cached' });
const result = await service.findOne('1');
expect(result.name).toBe('cached');
expect(repo.findOneOrFail).not.toHaveBeenCalled();
});
it('populates cache on miss', async () => {
cache.get.mockResolvedValue(undefined);
repo.findOneOrFail.mockResolvedValue({ id: '1', name: 'fresh' } as any);
await service.findOne('1');
expect(cache.set).toHaveBeenCalledWith('product:v2:1', expect.any(Object), 60_000);
});
});Test e2e du CacheInterceptor :
// product.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('ProductController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, CacheModule.register({ ttl: 60_000, isGlobal: true })],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(() => app.close());
it('second call should be served from cache', async () => {
const first = await request(app.getHttpServer()).get('/products/42').expect(200);
const second = await request(app.getHttpServer()).get('/products/42').expect(200);
expect(second.body).toEqual(first.body);
expect(second.headers['x-cache']).toBe('HIT'); // si tu set ce header dans l'interceptor
});
});Pour les tests, ne te branche pas sur un vrai Redis sauf dans les tests d'intégration dédiés. En unitaire, mocker CACHE_MANAGER. En e2e, soit utiliser le store memory (CacheModule.register({})), soit lancer un Redis éphémère via Testcontainers.
Stampede prevention (anti-thundering-herd)
Trois techniques selon ton SLA :
Single-flight (request coalescing)
Une seule requête remplit le cache, les autres attendent.
@Injectable()
export class SingleFlightCache {
private inflight = new Map<string, Promise<unknown>>();
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
async get<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> {
const hit = await this.cache.get<T>(key);
if (hit !== undefined && hit !== null) return hit;
const existing = this.inflight.get(key);
if (existing) return existing as Promise<T>;
const promise = loader()
.then(async (value) => {
await this.cache.set(key, value, ttl);
return value;
})
.finally(() => this.inflight.delete(key));
this.inflight.set(key, promise);
return promise;
}
}Note : inflight est local au process. Sur 10 pods, tu as 10 requêtes au lieu de 5000. C'est déjà énorme. Pour mutualiser entre pods, il faut un lock distribué (Redlock).
Probabilistic early expiration
Quelques requêtes recalculent le cache un peu avant l'expiration réelle, lissant la charge.
const beta = 1.0;
const now = Date.now();
const xfetchExpiry = entry.computedAt + entry.delta * beta * Math.log(Math.random());
if (now >= xfetchExpiry) {
// recompute even if still valid
}Stale-while-revalidate
Tu retournes la valeur périmée immédiatement et tu déclenches un refresh async.
const entry = await this.cache.get<{ value: T; staleAt: number }>(key);
if (entry && entry.staleAt > Date.now()) return entry.value;
if (entry) {
this.refreshInBackground(key, loader, ttl).catch(() => {});
return entry.value;
}
return this.fetchAndStore(key, loader, ttl);Mesurer le hit ratio
Sans mesure, tu codes à l'aveugle. Branche un compteur Prometheus :
// src/cache/observable-cache.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Counter } from 'prom-client';
const hits = new Counter({ name: 'cache_hits_total', help: 'Cache hits', labelNames: ['ns'] });
const misses = new Counter({ name: 'cache_misses_total', help: 'Cache misses', labelNames: ['ns'] });
@Injectable()
export class ObservableCache {
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
async get<T>(ns: string, key: string): Promise<T | null> {
const value = await this.cache.get<T>(`${ns}:${key}`);
if (value === undefined || value === null) {
misses.inc({ ns });
return null;
}
hits.inc({ ns });
return value;
}
}Tu calcules ensuite rate(cache_hits_total[5m]) / (rate(cache_hits_total[5m]) + rate(cache_misses_total[5m])) dans Prometheus / Grafana. Sous 80% de hit ratio sur une donnée chaude, ta stratégie est probablement mauvaise (TTL trop court, clé trop spécifique, invalidation trop agressive).
Cache d'output vs cache de donnée
| Aspect | Cache d'output (HTTP/page) | Cache de donnée (objet métier) |
|---|---|---|
| Granularité | Une URL/un payload complet | Une entité ou un agrégat |
| Sérialisation | Déjà JSON | Brut, libre |
| Composabilité | Faible (tout ou rien) | Forte (réutilisable entre routes) |
| Auth-aware | Difficile (clé par user) | Facile |
| Invalidation | Par URL ou tag | Par ID + tag |
En général, en API REST sous Nest, 80% de la valeur vient du cache de donnée dans le service. Le cache d'output (CacheInterceptor) est utile pour des endpoints publics, déterministes, sans Authorization.
Integration avec ConfigService
Ne hardcode jamais redisStore({ host: 'localhost' }) dans le module. Passe par ConfigService et un schéma de validation Joi/Zod :
// src/config/cache.config.ts
import { registerAs } from '@nestjs/config';
export const cacheConfig = registerAs('cache', () => ({
driver: process.env.CACHE_DRIVER ?? 'memory',
defaultTtlMs: Number(process.env.CACHE_DEFAULT_TTL_MS ?? 60_000),
redis: {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT ?? 6379),
password: process.env.REDIS_PASSWORD,
tls: process.env.REDIS_TLS === 'true',
},
}));CacheModule.registerAsync({
isGlobal: true,
inject: [cacheConfig.KEY],
useFactory: async (cfg: ConfigType<typeof cacheConfig>) => {
if (cfg.driver === 'memory') return { ttl: cfg.defaultTtlMs, max: 1000 };
return {
store: await redisStore({ ...cfg.redis, ttl: cfg.defaultTtlMs }),
};
},
});🎬 Cas d'usage concrets
Catalog Cdiscount — cache produit avec invalidation par événement
Qui : équipe catalog d'un pure player e-commerce français servant 8M visites/jour. Les fiches produits sont lues 200x plus qu'elles ne sont écrites (sync ERP nocturne + mises à jour prix temps réel).
Problème : la DB Postgres encaisse 40 000 reads/s en heure de pointe, surtout sur les top-1000 SKU. La latence p95 grimpe à 800 ms quand le merchandising lance une opération promo. On veut redescendre sous 80 ms sans casser la fraîcheur sur les changements de prix.
@Injectable()
export class ProductCatalogService {
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly repo: ProductRepository,
private readonly events: EventEmitter2,
) {}
async getBySku(sku: string): Promise<Product> {
const key = `cdsc:product:v4:${sku}`;
const hit = await this.cache.get<Product>(key);
if (hit) return hit;
const product = await this.repo.findBySkuOrFail(sku);
await this.cache.set(key, product, 300_000); // 5 min
return product;
}
@OnEvent('catalog.price.updated')
async onPriceUpdated(payload: { sku: string }) {
await this.cache.del(`cdsc:product:v4:${payload.sku}`);
await this.cache.del(`cdsc:list:category:${payload.sku.split('-')[0]}`);
}
}Gains : p95 catalog passe à 45 ms, hit ratio mesuré à 94% sur les top-1000 SKU. La charge Postgres baisse de 70%, les opés promos n'écroulent plus la lecture car l'invalidation ciblée par SKU repropage en moins de 200 ms via Redis pub/sub.
Plateforme RAG juridique — semantic cache des requêtes LLM
Qui : éditeur SaaS legaltech servant 600 cabinets d'avocats. Chaque consultation déclenche un appel OpenAI à 0,03 € avec une latence de 4-8 s. 30% des questions sont des reformulations triviales de la même intention.
Problème : la facture LLM dépasse 80 k€/mois et les avocats se plaignent de la latence. On veut un cache qui matche aussi sur la similarité sémantique, pas juste sur l'égalité stricte de prompt.
@Injectable()
export class SemanticCacheService {
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly embedder: EmbeddingsService,
private readonly vector: VectorStoreService,
) {}
async lookupOrCompute(question: string, compute: () => Promise<string>): Promise<string> {
const embedding = await this.embedder.embed(question);
const nearest = await this.vector.searchTopK('rag:cache', embedding, 1);
if (nearest[0]?.score >= 0.93) {
return this.cache.get<string>(`rag:answer:${nearest[0].id}`) ?? compute();
}
const answer = await compute();
const id = randomUUID();
await this.vector.upsert('rag:cache', { id, embedding });
await this.cache.set(`rag:answer:${id}`, answer, 86_400_000); // 24h
return answer;
}
}Gains : 38% des questions servies depuis le cache sémantique, économie de 30 k€/mois sur OpenAI. Latence p50 sur les hits sémantiques tombe à 90 ms (embedding + vector search) contre 5 s en cold path.
SaaS RH offres d'emploi — cache TTL court multi-tenant
Qui : éditeur ATS multi-tenant servant 1 200 entreprises. Le widget "offres ouvertes" est embed sur les sites carrière clients avec un trafic anonyme important.
Problème : chaque chargement de page carrière fait 3-5 requêtes API pour lister les offres, filtrer par localisation, paginer. On veut cacher par tenant sans risquer de leak inter-tenant.
@Controller('public/jobs')
export class PublicJobsController {
@Get(':tenantSlug')
async list(@Param('tenantSlug') slug: string, @Query() q: ListJobsDto) {
const key = `jobs:list:${slug}:${stableHash(q)}`;
return this.cacheService.wrap(key, 120_000, () => this.jobsService.publicList(slug, q));
}
}Gains : 91% de hit ratio sur les pages carrière, division par 8 de la charge sur le service de recherche. Le préfixe tenant dans la clé garantit l'isolation, le TTL de 2 min reste acceptable pour des offres qui s'ouvrent et se ferment à la journée.
🛠️ Exemple end-to-end
Contexte : marketplace B2B de matériel industriel. La fiche produit agrège prix négocié par compte client, stock multi-entrepôt, et fiche technique stable. Trois TTL différents, invalidation événementielle sur prix et stock, observabilité Prometheus.
// src/catalog/product-page.service.ts
import { Inject, Injectable, Logger } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { Counter, Histogram } from 'prom-client';
const hits = new Counter({ name: 'catalog_cache_hits_total', help: 'h', labelNames: ['layer'] });
const misses = new Counter({ name: 'catalog_cache_misses_total', help: 'm', labelNames: ['layer'] });
const dur = new Histogram({ name: 'catalog_page_seconds', help: 'd', labelNames: ['source'] });
export interface ProductPage {
sku: string;
spec: ProductSpec;
price: NegotiatedPrice;
stock: StockSnapshot[];
}
@Injectable()
export class ProductPageService {
private readonly log = new Logger(ProductPageService.name);
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
private readonly specRepo: ProductSpecRepository,
private readonly priceRepo: PriceRepository,
private readonly stockRepo: StockRepository,
) {}
async getProductPage(sku: string, accountId: string): Promise<ProductPage> {
const end = dur.startTimer();
const [spec, price, stock] = await Promise.all([
this.getSpec(sku),
this.getPrice(sku, accountId),
this.getStock(sku),
]);
end({ source: 'composed' });
return { sku, spec, price, stock };
}
// Fiche technique : stable, TTL long, invalidé seulement par l'événement métier
private async getSpec(sku: string): Promise<ProductSpec> {
const key = `cat:spec:v3:${sku}`;
const hit = await this.cache.get<ProductSpec>(key);
if (hit) { hits.inc({ layer: 'spec' }); return hit; }
misses.inc({ layer: 'spec' });
const spec = await this.specRepo.findBySkuOrFail(sku);
await this.cache.set(key, spec, 6 * 60 * 60 * 1000); // 6h
return spec;
}
// Prix négocié : par compte client, TTL moyen
private async getPrice(sku: string, accountId: string): Promise<NegotiatedPrice> {
const key = `cat:price:v2:${accountId}:${sku}`;
const hit = await this.cache.get<NegotiatedPrice>(key);
if (hit) { hits.inc({ layer: 'price' }); return hit; }
misses.inc({ layer: 'price' });
const price = await this.priceRepo.findNegotiated(sku, accountId);
await this.cache.set(key, price, 10 * 60 * 1000); // 10 min
return price;
}
// Stock : TTL très court (volatile), stale-while-revalidate
private async getStock(sku: string): Promise<StockSnapshot[]> {
const key = `cat:stock:v1:${sku}`;
const entry = await this.cache.get<{ value: StockSnapshot[]; staleAt: number }>(key);
if (entry && entry.staleAt > Date.now()) {
hits.inc({ layer: 'stock' });
return entry.value;
}
if (entry) {
this.refreshStockAsync(sku).catch((e) => this.log.warn(e));
hits.inc({ layer: 'stock' });
return entry.value;
}
misses.inc({ layer: 'stock' });
return this.refreshStockAsync(sku);
}
private async refreshStockAsync(sku: string): Promise<StockSnapshot[]> {
const fresh = await this.stockRepo.snapshot(sku);
await this.cache.set(`cat:stock:v1:${sku}`, { value: fresh, staleAt: Date.now() + 30_000 }, 120_000);
return fresh;
}
@OnEvent('catalog.price.updated')
async onPrice(p: { sku: string; accountIds: string[] }) {
await Promise.all(p.accountIds.map((a) => this.cache.del(`cat:price:v2:${a}:${p.sku}`)));
}
@OnEvent('warehouse.stock.changed')
async onStock(p: { sku: string }) {
await this.cache.del(`cat:stock:v1:${p.sku}`);
}
@OnEvent('catalog.spec.updated')
async onSpec(p: { sku: string }) {
await this.cache.del(`cat:spec:v3:${p.sku}`);
}
}Trois TTL alignés sur la volatilité réelle, invalidation événementielle déclenchée par les flux ERP/WMS, métriques par couche pour repérer celle qui sous-performe. En prod, le hit ratio atteint 97% sur spec, 82% sur price (TTL plus court), 60% sur stock (volatile mais le stale-while-revalidate absorbe les pics).
🤖 Cacher du LLM depuis NestJS (servir des agents IA)
C'est le cas où le cache fait basculer la rentabilité d'un produit IA. Un appel à un modèle comme claude-opus-4-8 coûte ~5 $/M tokens en entrée et ~25 $/M en sortie, et prend de 2 à 60 s selon le effort. Tu as trois couches de cache distinctes à raisonner séparément — confondre les trois est l'erreur de débutant.
| Couche | Où | Ce qu'elle cache | Économie | Invalidation |
|---|---|---|---|---|
| Prompt caching (côté Anthropic) | Dans l'appel API (cache_control) | Le préfixe stable du prompt (system, tools, RAG context) | ~90% sur les tokens d'entrée cachés (lecture à ~0,1×) | TTL 5 min / 1 h, géré par l'API |
| Réponse exacte (côté toi, Redis) | Ton service NestJS | La réponse complète pour un prompt identique au byte près | 100% (zéro appel) | Versioning de clé + TTL |
| Cache sémantique (côté toi, vecteurs) | Ton service NestJS | La réponse pour un prompt sémantiquement proche | 100% sur les hits, mais coût d'embedding + recherche | Seuil de similarité + TTL |
Couche 1 — Prompt caching côté provider
C'est le cache que les juniors oublient. Anthropic cache le préfixe du prompt (ordre de rendu : tools → system → messages). Tout changement d'un seul byte dans le préfixe invalide tout ce qui suit. Règle senior : contenu stable d'abord (system prompt figé, liste d'outils triée déterministe), contenu volatile en dernier (timestamp, question de l'utilisateur, ID de requête).
// Mauvais : datetime dans le system prompt → cache invalidé à CHAQUE requête
const system = `Tu es un assistant juridique. Date: ${new Date().toISOString()}`;
// Bon : system figé caché, le volatile va dans messages
const response = await this.anthropic.messages.create({
model: 'claude-opus-4-8',
max_tokens: 4096,
system: [
{
type: 'text',
text: FROZEN_LEGAL_SYSTEM_PROMPT, // jamais interpolé
cache_control: { type: 'ephemeral' }, // breakpoint après le préfixe stable
},
],
messages: [{ role: 'user', content: userQuestion }], // volatile, après le breakpoint
});
// Vérifie que ça cache vraiment :
this.logger.debug({
cacheWrite: response.usage.cache_creation_input_tokens, // payé ~1,25×
cacheRead: response.usage.cache_read_input_tokens, // payé ~0,1×
uncached: response.usage.input_tokens,
});Si cache_read_input_tokens reste à 0 sur des requêtes au préfixe identique, un invalidateur silencieux est à l'œuvre : Date.now() dans le system prompt, JSON d'outils non trié, set de tools qui varie par utilisateur. Le diagnostic se fait en diffant les bytes de deux prompts rendus. Le préfixe minimum cacheable sur Opus 4.8 est de 1024 tokens (le seuil varie selon le modèle : ~2048 sur Opus 4.7, ~4096 sur Opus 4.5/4.6) — un préfixe plus court ne cache pas silencieusement, sans erreur (cache_creation_input_tokens et cache_read_input_tokens restent à 0).
Couche 2 — Le client LLM en DI, pas new Anthropic() dans un champ
L'anti-pattern le plus courant : instancier le SDK dans le service. Tu casses la testabilité, tu disperses la config, tu ne peux pas mocker. Injecte-le via forRootAsync.
// src/ai/anthropic.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC_CLIENT = Symbol('ANTHROPIC_CLIENT');
@Global()
@Module({
imports: [ConfigModule],
providers: [
{
provide: ANTHROPIC_CLIENT,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new Anthropic({
apiKey: config.getOrThrow('ANTHROPIC_API_KEY'),
maxRetries: 3, // le SDK retry 429/5xx avec backoff exponentiel
timeout: 60_000, // au-delà de ~16K tokens de sortie, passe en streaming
}),
},
],
exports: [ANTHROPIC_CLIENT],
})
export class AnthropicModule {}Note senior sur les retries : le SDK Anthropic retry automatiquement les 429 (rate limit) et les 5xx avec backoff exponentiel (défaut maxRetries: 2). Ne réimplémente pas ta propre boucle de retry par-dessus — tu doublerais le backoff. Pour le rate limit côté toi (protéger ton budget), pose un guard à l'edge, pas un retry.
Couche 3 — Cache de réponse exacte + cache sémantique
Le cache de réponse exacte est trivial mais piégeux sur la clé : un prompt LLM doit produire une clé déterministe. JSON.stringify d'un objet aux clés non triées casse tout. Utilise un hash stable.
// src/ai/llm-cache.service.ts
import { Inject, Injectable, Logger } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import Anthropic from '@anthropic-ai/sdk';
import { createHash } from 'node:crypto';
import { Counter } from 'prom-client';
import { ANTHROPIC_CLIENT } from './anthropic.module';
const llmHits = new Counter({ name: 'llm_cache_hits_total', help: 'h', labelNames: ['kind'] });
const llmCost = new Counter({ name: 'llm_cost_usd_total', help: 'c' });
@Injectable()
export class LlmCacheService {
private readonly logger = new Logger(LlmCacheService.name);
private static readonly PROMPT_VERSION = 'v3'; // bump = invalidation de masse propre
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
@Inject(ANTHROPIC_CLIENT) private readonly anthropic: Anthropic,
) {}
private key(model: string, system: string, question: string): string {
// hash déterministe : ordre des champs figé, pas de JSON.stringify naïf
const h = createHash('sha256')
.update(model).update('\0')
.update(system).update('\0')
.update(question)
.digest('hex')
.slice(0, 32);
return `llm:${LlmCacheService.PROMPT_VERSION}:${model}:${h}`;
}
async ask(model: string, system: string, question: string): Promise<string> {
const cacheKey = this.key(model, system, question);
const cached = await this.cache.get<string>(cacheKey);
if (cached) {
llmHits.inc({ kind: 'exact' });
return cached;
}
const res = await this.anthropic.messages.create({
model,
max_tokens: 4096,
system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }],
messages: [{ role: 'user', content: question }],
});
// Toujours vérifier stop_reason AVANT de lire content (refus, max_tokens…)
if (res.stop_reason === 'refusal') {
throw new Error('LLM refusal'); // ne pas cacher un refus comme une réponse valide
}
const text = res.content.filter((b) => b.type === 'text').map((b) => b.text).join('');
llmCost.inc((res.usage.input_tokens * 5 + res.usage.output_tokens * 25) / 1_000_000);
await this.cache.set(cacheKey, text, 24 * 60 * 60 * 1000); // 24h
return text;
}
}Le cache sémantique (montré plus haut dans le cas RAG juridique) ajoute une couche : on embed la question, on cherche le plus proche voisin dans un vector store, et si la similarité dépasse un seuil (typiquement 0,93–0,95), on sert la réponse cachée. Attention senior : un seuil trop bas sert des réponses fausses (deux questions proches en surface, divergentes en intention). Mesure le taux de faux hits en échantillonnant, pas seulement le hit ratio.
Streaming SSE + cache : le piège de la sortie partielle
Quand tu streames les tokens au client par SSE, tu ne peux cacher qu'une réponse complète. Accumule dans un buffer, et ne caches qu'au message_stop. Si le client se déconnecte en cours de route, tu dois abort côté serveur (sinon tu paies une génération que personne ne lit) et ne rien cacher.
// src/ai/ai.controller.ts
import { Controller, Post, Body, Res, Req, Inject } from '@nestjs/common';
import { Response, Request } from 'express';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC_CLIENT } from './anthropic.module';
@Controller('ai')
export class AiController {
constructor(@Inject(ANTHROPIC_CLIENT) private readonly anthropic: Anthropic) {}
@Post('stream')
async stream(@Body('question') question: string, @Req() req: Request, @Res() res: Response) {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const controller = new AbortController();
// Déconnexion client → on annule l'appel LLM (économie de coût, pas de génération orpheline)
req.on('close', () => controller.abort());
let buffer = '';
try {
const stream = this.anthropic.messages.stream(
{
model: 'claude-opus-4-8',
max_tokens: 16_000,
messages: [{ role: 'user', content: question }],
},
{ signal: controller.signal },
);
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
buffer += event.delta.text;
res.write(`data: ${JSON.stringify({ token: event.delta.text })}\n\n`);
}
}
const final = await stream.finalMessage();
if (final.stop_reason !== 'refusal') {
// SEULEMENT ici on cache : génération complète et valide
// await this.llmCache.persist(question, buffer);
}
res.write('data: [DONE]\n\n');
res.end();
} catch (e) {
if (!controller.signal.aborted) {
res.write(`data: ${JSON.stringify({ error: 'generation_failed' })}\n\n`);
}
res.end();
}
}
}Jobs IA en BullMQ : idempotence et cache anti-rejeu
Pour les générations longues (rapport multi-pages, batch), tu pousses en BullMQ. Trois règles senior :
- Idempotence keyée sur un
generationId: lejobIdBullMQ = legenerationId. Un retry ne relance pas une génération déjà faite — il lit le cache de sortie partielle. - Retry cost-aware : un job IA qui échoue après avoir généré 80% de la sortie a déjà coûté de l'argent. Persiste la sortie partielle sous
llm:partial:${generationId}et reprends depuis là, plutôt que de tout régénérer. - Cache de la sortie finale keyé sur
generationId, pas sur le prompt — deux utilisateurs peuvent demander le même rapport, mais tu veux tracer qui a payé quoi.
@Processor('ai-generation')
export class AiGenerationProcessor {
constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
@Inject(ANTHROPIC_CLIENT) private readonly anthropic: Anthropic,
) {}
@Process()
async handle(job: Job<{ generationId: string; prompt: string }>) {
const { generationId, prompt } = job.data;
// Idempotence : déjà généré ? on ne repaie pas
const done = await this.cache.get<string>(`llm:result:${generationId}`);
if (done) return done;
const res = await this.anthropic.messages.create({
model: 'claude-opus-4-8',
max_tokens: 64_000,
messages: [{ role: 'user', content: prompt }],
});
if (res.stop_reason === 'refusal') throw new Error('refusal'); // BullMQ retry, mais voir règle 2
const text = res.content.filter((b) => b.type === 'text').map((b) => b.text).join('');
await this.cache.set(`llm:result:${generationId}`, text, 7 * 24 * 60 * 60 * 1000);
return text;
}
}Cost-guard à l'edge
Le cache protège ton p95 et ta facture, mais il ne protège pas contre un utilisateur qui martèle des prompts uniques (cache miss systématique). Pose un guard de coût à l'edge : rate limit par utilisateur + budget token quotidien. Le cache et le cost-guard sont complémentaires — l'un réduit le coût des requêtes répétées, l'autre plafonne le coût des requêtes uniques.
🔁 Quand utiliser / éviter
| Use cache when | Avoid cache when |
|---|---|
| Lecture >> écriture (catalog, config) | Donnée critique (solde bancaire, stock temps réel) |
| Calcul coûteux et reproductible | Donnée hautement personnalisée par user |
| API tierce lente et idempotente | Donnée qui change à chaque requête |
| Endpoint public et anonyme | Endpoint où la cohérence prime sur la latence |
| Tu peux supporter quelques secondes de staleness | Tu dois invalider en <100ms partout |
En cas de doute, commence sans cache. Mesure d'abord la latence et le coût. Ajoute le cache uniquement quand tu as une métrique à améliorer et un budget de staleness explicite.
🏋️ Exercices
Chaque exercice escalade : implémenter → rendre production-grade → casser puis réparer. Fais-les dans l'ordre, ils s'enchaînent.
1. Cache-aside générique avec instrumentation — implémenter
Objectif : écrire un CacheService.wrap<T>(key, ttl, loader) réutilisable qui mesure hit/miss par namespace via Prometheus et expose /metrics.
Indice/Solution : factorise le pattern get→miss→load→set. Extrais le namespace du préfixe de clé (split(':')[0]) pour le label Prometheus. Le piège : ne compte pas un null caché comme un miss — distingue "absent du cache" de "caché comme null" avec un sentinel. Vérifie le hit ratio sous charge avec autocannon.
2. Tag-based invalidation distribuée — production-grade
Objectif : étendre l'exercice 1 avec une invalidation par tag qui marche sur plusieurs pods (l'index de tags doit vivre dans Redis, pas en mémoire process).
Indice/Solution : pour chaque set, SADD tag:${tag} ${key} ; pour invalider, SMEMBERS tag:${tag} puis mdel des clés + DEL tag:${tag}. Le piège production : les Sets de tags grossissent sans limite si tu n'expires jamais les clés mortes. Ajoute un TTL sur les Sets de tags et un job de compaction qui nettoie les références à des clés expirées. Teste avec 3 pods (docker-compose) que l'invalidation d'un pod purge bien les autres.
3. Anti-stampede single-flight + stale-while-revalidate — production-grade
Objectif : sous 5000 requêtes concurrentes sur une clé chaude expirée, garantir un seul appel à la source de vérité par pod, et servir la valeur périmée pendant le refresh.
Indice/Solution : combine la Map<string, Promise> d'in-flight (single-flight local) avec une entrée { value, staleAt, hardExpireAt }. Si now < staleAt → frais. Si staleAt < now < hardExpireAt → sers le périmé + déclenche un refresh async non bloquant. Si now > hardExpireAt → bloque sur le single-flight. Le piège : le refresh async qui throw doit être catché (.catch(() => {})) sinon unhandled rejection. Mesure avec un compteur d'appels au loader que tu n'as bien qu'un seul appel pour 5000 requêtes simultanées.
4. Casser puis réparer : la fuite inter-tenant — casser puis réparer
Objectif : on te donne un CacheInterceptor global qui cache /me au niveau output. Reproduis le bug où le user B voit les données du user A, puis répare-le sans désactiver le cache.
Indice/Solution : la cause est trackBy qui ne prend que l'URL. Reproduis avec deux JWT différents sur la même route. Répare en overridant trackBy(context) pour inclure le sub du JWT dans la clé (${url}:${userId}) — ou mieux, exclus les routes authentifiées du cache d'output et caches au niveau donnée. Écris un test e2e qui échoue avant le fix et passe après.
5. Casser puis réparer : l'invalidateur silencieux du prompt cache — casser puis réparer (IA)
Objectif : un service LLM a un cache_read_input_tokens toujours à 0 malgré des prompts "identiques". Trouve et répare l'invalidateur silencieux.
Indice/Solution : interpole quelque chose de volatile dans le préfixe (un Date.now() dans le system prompt, ou un set d'outils non trié). Diagnostique en diffant les bytes de deux prompts rendus consécutifs. Répare en figeant le system prompt et en triant les outils déterministe ; déplace le volatile après le dernier cache_control. Vérifie que cache_read_input_tokens > 0 à la deuxième requête. Bonus : ajoute un test qui assert que le préfixe rendu est byte-identique entre deux appels.
6. Idempotence cost-aware d'un job IA BullMQ — casser puis réparer (IA)
Objectif : un job de génération qui retry après un échec à 80% régénère tout depuis zéro, doublant le coût. Rends-le idempotent et reprends depuis la sortie partielle.
Indice/Solution : keye le jobId sur le generationId. Persiste la sortie partielle sous llm:partial:${generationId} au fil du streaming. Au retry, lis d'abord llm:result:${generationId} (déjà fini ?) puis llm:partial (reprendre où ?). Le piège : un refus (stop_reason: 'refusal') ne doit pas être retryé indéfiniment — distingue erreur transitoire (5xx, à retry) et refus (à dead-letter). Simule un échec mid-génération et mesure que le coût total du retry est < 50% d'une régénération complète.
🎤 En entretien
Q : Comment empêches-tu un cache stampede sur une clé chaude qui expire ? R : Trois leviers selon le SLA : single-flight (request coalescing, un seul appel à la source par pod via une Map d'in-flight), probabilistic early expiration (XFetch — quelques requêtes recalculent juste avant l'expiration pour lisser), et stale-while-revalidate (sers le périmé immédiatement, refresh async). Pour mutualiser entre pods, il faut un lock distribué (Redlock) — mais commence local, 10 pods × 1 appel ≫ 5000 appels c'est déjà la victoire.
Q : Quelle est la différence entre le prompt caching d'Anthropic et un cache de réponse Redis ? R : Ce sont deux couches orthogonales. Le prompt caching cache le préfixe du prompt côté provider (system + tools + RAG context) et facture les tokens cachés à ~0,1× — il accélère et réduit le coût même quand la réponse diffère, tant que le préfixe est stable. Le cache Redis cache la réponse complète pour un prompt identique au byte près et économise 100% (zéro appel). On utilise les deux : prompt caching pour le préfixe partagé entre toutes les requêtes, Redis pour les répétitions exactes, et cache sémantique pour les reformulations proches.
Q : Pourquoi new Anthropic() dans un champ de service est un anti-pattern, et comment réintroduit-on les retries ? R : Instancier le SDK dans un champ casse la testabilité (pas de mock), disperse la config et empêche le partage du pool de connexions. On l'injecte via forRootAsync avec un token DI. Les retries ne sont pas à réimplémenter : le SDK retry déjà les 429 et 5xx avec backoff exponentiel (maxRetries). Réimplémenter une boucle par-dessus double le backoff. Le rate limit côté toi (protéger ton budget) est un guard à l'edge, pas un retry.
Q : Tu caches un objet métier en mémoire, le caller le mute. Que se passe-t-il et comment tu blindes ? R : Avec un store mémoire, cache.set('k', obj) stocke la référence — muter obj mute la valeur cachée, et le prochain caller reçoit l'objet corrompu. Avec Redis c'est une copie sérialisée, donc safe à la lecture suivante, mais le caller mémoire courant est piégé. Blindage : retourne des objets figés (structuredClone ou Object.freeze) à la frontière du cache, ou caches des DTO immutables, jamais des entités vivantes.
🔗 Liens
- Doc officielle : https://docs.nestjs.com/techniques/caching
cache-managerGitHub : https://github.com/node-cache-manager/node-cache-managercache-manager-ioredis-yet: https://github.com/node-cache-manager/node-cache-manager-ioredis-yet- Keyv (cache-manager v6+) : https://keyv.org
- Article "Caching at Netflix" sur les patterns stampede
- "Probabilistic Early Expiration" (XFetch) — Vattani, Chierichetti, Lowenstein
- Redlock pour locks distribués : https://github.com/mike-marcacci/node-redlock
- Cache patterns AWS : https://aws.amazon.com/caching/best-practices/
prom-clientpour exposer le hit ratio : https://github.com/siimon/prom-client- Prompt caching Anthropic (préfixe, breakpoints, invalidateurs silencieux) : https://platform.claude.com/docs/en/build-with-claude/prompt-caching
- SDK TypeScript Anthropic (
maxRetries, streaming,finalMessage) : https://github.com/anthropics/anthropic-sdk-typescript - BullMQ (jobs IA, idempotence par
jobId) : https://docs.bullmq.io