Cours NestJS · Module 8: Cache et performances
Caching - les casiers à provisions d'une application
Dans cette leçon7
La liste des provinces change une fois par trimestre. La requête qui la demande arrive mille fois par minute - et chaque fois la base accomplit le même travail pour renvoyer le même résultat. Le serveur est chargé non parce qu'il fait beaucoup, mais parce qu'il refait sans cesse la même chose.
Une légion n'envoyait pas un coureur à Rome pour chaque miche de pain. Elle gardait un casier à provisions près du camp : ce dont on avait souvent besoin restait sous la main, et pour le reste on descendait au dépôt. Un cache est un tel casier - le stockage temporaire de données pour un accès plus rapide.
Note le mot « temporaire ». Un cache n'est pas un magasin durable - la base est là pour cela. Ce n'est pas davantage un outil de compression ni de journalisation. Les données qu'il contient doivent disparaître, et c'est une qualité, non un défaut.
La configuration
CacheModule.register() configure le cache avec ses paramètres - ni le routage, ni l'autorisation, ni la journalisation :
1@Module({
2 imports: [
3 CacheModule.register({
4 isGlobal: true,
5 ttl: 300,
6 max: 1000,
7 }),
8 ],
9})
10export class AppModule {}ttl (Time To Live) désigne la durée de vie maximale d'une entrée, après laquelle elle est automatiquement supprimée - ici 300 secondes. Ce n'est ni un nombre de lectures autorisées ni un temps de réponse ; c'est une date de péremption.
max plafonne le nombre d'entrées. Une fois dépassé, le cache retire les moins récemment utilisées - car la mémoire est finie, et un cache sans limite finira par l'épuiser.
Lecture et écriture manuelles
Nous injectons le cache par token. L'ordre d'écriture : @Inject(CACHE_MANAGER), private cache:, Cache :
1@Injectable()
2export class ProvinceService {
3 constructor(
4 @Inject(CACHE_MANAGER) private cache: Cache,
5 private repo: ProvinceRepository,
6 ) {}
7
8 async findAll() {
9 const cached = await this.cache.get<Province[]>('provinces:all');
10 if (cached) {
11 return cached;
12 }
13
14 const provinces = await this.repo.findAll();
15 await this.cache.set('provinces:all', provinces, 600);
16
17 return provinces;
18 }
19}Ce schéma s'appelle cache-aside (ou lazy loading) et signifie que le code récupère les données du cache manuellement et les y écrit manuellement. Quatre étapes, toujours dans cet ordre :
- Vérifie le cache (
get). - S'il y a une entrée - renvoie-la au client.
- Sinon (miss) - récupère depuis la base.
- Enregistre le résultat dans le cache (
set) et renvoie-le.
Le mot « lazy » vient de la troisième étape : le cache se remplit paresseusement, seulement quand quelqu'un demande les données. La première requête va toujours en base.
Automatiquement, par les décorateurs
Pour de simples lectures, tu n'as pas à écrire ces quatre étapes :
1@Controller('provinces')
2export class ProvinceController {
3 @Get()
4 @UseInterceptors(CacheInterceptor)
5 @CacheKey('provinces:all')
6 @CacheTTL(600)
7 findAll() {
8 return this.provinceService.findAll();
9 }
10}@UseInterceptors(CacheInterceptor) met automatiquement en cache les réponses de l'endpoint - il ne les compresse pas, ne les journalise pas et ne les valide pas.
@CacheTTL(600) est le décorateur responsable de la durée de vie des données de cette méthode - six cents secondes, soit dix minutes, quelle que soit la valeur par défaut. À ne pas confondre avec @CacheKey, qui donne un nom à l'entrée, ni avec @UseInterceptors, qui ne fait qu'activer la mise en cache.
Par défaut, la clé provient de l'URL. @CacheKey sert quand tu veux la fixer toi-même - par exemple pour pouvoir supprimer plus tard cette même entrée depuis un service.
L'écriture - deux stratégies
Les approches ci-dessus remplissent le cache à la lecture. On peut aussi le remplir à l'écriture, et deux variantes s'y distinguent par un seul mot :
- Le write-through écrit dans le cache et dans la base de façon synchrone - l'application attend les deux. Le cache n'est jamais périmé, mais l'écriture dure plus longtemps.
- Le write-behind écrit dans le cache aussitôt et dans la base de façon asynchrone, plus tard. L'écriture est instantanée, mais une panne entre les deux peut perdre les données.
La différence tient donc à ceci : l'application attend-elle la base ? Choisis le write-through là où perdre une écriture est inacceptable ; le write-behind là où le débit prime et où une entrée perdue n'est pas un désastre - comme pour des compteurs de vues.
L'invalidation - trois stratégies
Les données d'un cache vieillissent. Les façons d'y remédier vont de la plus simple à la plus élaborée :
- Expiration (TTL) - l'entrée disparaît d'elle-même après un temps. Tu n'écris rien, mais tu montres un instant des données périmées.
- Manual - tu supprimes une clé précise quand les données changent. Exact, mais tu dois penser à chaque endroit qui écrit.
- Pattern-based - tu supprimes des groupes de clés correspondant à un motif, par exemple tout ce qui commence par
provinces:. Le plus puissant, et le plus facile pour en supprimer trop.
Le processus d'invalidation après une mise à jour compte trois étapes : mets à jour les données en base, identifie et supprime les clés concernées, et la requête suivante récupérera des données fraîches et les enregistrera de nouveau.
Le préchauffage du cache
Avec le cache-aside, le premier utilisateur après chaque redémarrage paie le prix entier d'une lecture en base. On peut l'éviter en remplissant le casier avant que quiconque ne demande :
1@Injectable()
2export class CacheWarmupService implements OnModuleInit {
3 constructor(
4 @Inject(CACHE_MANAGER) private cache: Cache,
5 private repo: ProvinceRepository,
6 ) {}
7
8 async onModuleInit() {
9 const provinces = await this.repo.findAll();
10 await this.cache.set('provinces:all', provinces, 3600);
11 }
12}onModuleInit est une méthode que NestJS appelle au démarrage de l'application, une fois le module construit. Le processus compte trois temps : identifie les données critiques, récupère-les et enregistre-les au démarrage, ajuste la liste d'après ce qui est réellement le plus demandé.
Ne préchauffe que ce qui est à la fois souvent nécessaire et rarement modifié. Tout préchauffer allonge le démarrage et remplit la mémoire de données que personne n'ira chercher.
Récapitulatif
Le casier près du camp fonctionne, et pour le reste on descend au dépôt :
- un cache est le stockage temporaire de données pour un accès plus rapide - ni magasin durable, ni compression, ni journalisation,
CacheModule.register()configure le cache avec ses paramètres ;maxplafonne le nombre d'entrées,- le TTL désigne la durée de vie maximale d'une entrée, après laquelle elle est automatiquement supprimée,
- injection dans l'ordre :
@Inject(CACHE_MANAGER),private cache:,Cache, - le cache-aside signifie récupérer et écrire manuellement ; quatre étapes : vérifie le cache → renvoie en cas de succès → récupère en base en cas d'échec → enregistre et renvoie,
@UseInterceptors(CacheInterceptor)met automatiquement en cache les réponses de l'endpoint,@CacheTTL(600)fixe la durée de vie ;@CacheKeynomme l'entrée,@UseInterceptorsactive le mécanisme,- le write-through écrit en base de façon synchrone, le write-behind de façon asynchrone, au risque d'une perte en cas de panne,
- stratégies d'invalidation de la plus simple : expiration (TTL) → manual (une clé) → pattern-based (groupes de clés),
- le processus après une mise à jour : mettre à jour la base → identifier et supprimer les clés → la requête suivante récupère des données fraîches,
- le cache warming par
onModuleInit: identifier les données critiques → les récupérer et les enregistrer au démarrage → ajuster la liste.
Dans la prochaine leçon, nous déplacerons le casier hors du processus - vers Redis, pour que toutes les instances de l'application le voient. En attendant, retiens ceci : un cache n'accélère pas le travail, il permet de ne pas le faire - et toute la difficulté consiste à savoir quand les provisions ont tourné.
Code de cette leçon : src/caching-intro.ts
1// Caching - Coffres de Provisions de l'Empire Romain
2import { Injectable, Inject, Module } from '@nestjs/common';
3import { CACHE_MANAGER } from '@nestjs/cache-manager';
4import { CacheModule } from '@nestjs/cache-manager';
5import { Cache } from 'cache-manager';
6
7// 1. Configuration de CacheModule dans NestJS
8@Module({
9 imports: [
10 CacheModule.register({
11 ttl: 300, // Time To Live: 300 secondes (5 minutes)
12 max: 100, // Maximum 100 éléments dans le cache
13 isGlobal: true // Disponible globalement dans toute l'application
14 }),
15 ],
16})
17export class AppModule {}
18
19// 2. Service avec mise en cache
20@Injectable()
21export class TributeCacheService {
22 constructor(
23 @Inject(CACHE_MANAGER) private cacheManager: Cache
24 ) {}
25
26 // GET - Récupérer depuis le cache ou depuis la base de données
27 async getTribute(id: number) {
28 const cacheKey = `tribute:${id}`;
29
30 // Vérifier le cache
31 let tribute = await this.cacheManager.get(cacheKey);
32 if (tribute) {
33 console.log('CACHE HIT - données du coffre !');
34 return tribute;
35 }
36
37 // CACHE MISS - récupérer depuis la base
38 console.log('CACHE MISS - direction les archives...');
39 tribute = await this.fetchFromDatabase(id);
40
41 // Save to cache for 5 minutes
42 if (tribute) {
43 await this.cacheManager.set(cacheKey, tribute, 300000);
44 }
45 return tribute;
46 }
47
48 // SET - Save to cache
49 async cacheTribute(id: number, data: any, ttl = 300000) {
50 const key = `tribute:${id}`;
51 await this.cacheManager.set(key, data, ttl);
52 console.log(`Tribut ${id} enregistré dans le coffre pendant ${ttl/1000}s`);
53 }
54
55 // DELETE - Remove from cache (invalidation)
56 async invalidate(id: number) {
57 await this.cacheManager.del(`tribute:${id}`);
58 console.log(`Tribut ${id} supprimé du coffre`);
59 }
60
61 // CLEAR - Vider tout le cache
62 async clearAll() {
63 await this.cacheManager.reset();
64 console.log('All caches cleared!');
65 }
66
67 private async fetchFromDatabase(id: number) {
68 // Simulating database query
69 const tributes: Record<number, any> = {
70 1: { id: 1, province: 'Gallia', amount: 1000, type: 'gold' },
71 2: { id: 2, province: 'Aegyptus', amount: 2000, type: 'silver' },
72 };
73 return tributes[id] || null;
74 }
75}
76
77// Modèles de mise en cache :
78// - Cache-Aside (Lazy) : Vérifier le cache -> si absent -> récupérer -> enregistrer
79// - Write-Through: Save to DB and cache simultaneously
80// - Write-Behind: Save to cache, later sync with DB
81// - Cache Warming : Préremplir le cache avec des données populaires
82Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Le cache dans une application NestJS, c'est :
2. CacheModule.register() dans NestJS configure :
Voici 2 des 8 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Construis un service de cache avec des méthodes get et set pour les tributs
- Clique dans l'ordre
Ordonne l'injection de CACHE_MANAGER dans un service :
- Tri vertical
Ordonne les étapes du pattern Cache-Aside (Lazy Loading) :
- Éditeur de code
Applique CacheInterceptor et CacheTTL sur l'endpoint GET /provinces
- Clique dans l'ordre
Ordonne le processus d'invalidation du cache après une mise à jour des données :
- Éditeur de code
Écris une méthode de mise à jour qui efface la clé de cache appropriée après un changement de données
- Tri vertical
Ordonne les stratégies d'invalidation de la plus simple à la plus complexe :
- Clique dans l'ordre
Ordonne le processus de préchauffage du cache (pré-chargement du cache au démarrage) :