Cours NestJS · Module 8: Cache et performances
Memory Management - optimiser le stockage du fort
Dans cette leçon5
Intendant des entrepôts ! Le consul Caesar.js a remarqué que notre fort est surchargé : après une semaine de travail, le processus occupe deux fois plus de mémoire qu'au démarrage, et la légion a de moins en moins de place pour manœuvrer. Au final, le conteneur atteint sa limite de mémoire et meurt en pleine journée. Il est temps d'apprendre la gestion de la mémoire dans Node.js.
Qu'est-ce que la gestion de la mémoire dans le monde des légionnaires ?
Imagine l'application comme un système d'entrepôts :
- heap - l'entrepôt principal, où l'on garde les tributs (les objets),
- stack - la formation où se tiennent temporairement les appels de fonctions et leurs variables locales,
- garbage collector - une unité qui débarrasse les objets que plus personne n'utilise,
- memory leaks - des tributs oubliés, dont quelqu'un garde pourtant encore la clé.
Une bonne gestion de la mémoire, c'est un fort efficace et rapide.
Surveillance de la mémoire
process.memoryUsage() renvoie cinq nombres : rss est toute la mémoire du processus, heapTotal le tas réservé, heapUsed sa partie occupée, external la mémoire des objets C++ liés à JavaScript, et arrayBuffers les buffers. Le service de surveillance les échantillonne toutes les 10 secondes :
1// memory-monitor.service.ts
2import { Injectable } from '@nestjs/common';
3import { Cron } from '@nestjs/schedule';
4import { getHeapStatistics } from 'node:v8';
5
6@Injectable()
7export class MemoryMonitorService {
8 private memoryHistory: Array<{
9 timestamp: Date;
10 usage: NodeJS.MemoryUsage;
11 }> = [];
12
13 private readonly MEMORY_THRESHOLD_MB = 512; // Alerte à 512MB
14 private readonly HISTORY_SIZE = 100;
15 // La vraie limite du tas : celle par défaut ou celle du flag --max-old-space-size
16 private readonly HEAP_LIMIT = getHeapStatistics().heap_size_limit;
17
18 @Cron('*/10 * * * * *') // Toutes les 10 secondes
19 checkMemoryUsage(): void {
20 const usage = process.memoryUsage();
21
22 this.memoryHistory.push({ timestamp: new Date(), usage });
23
24 // Ne garder que les 100 dernières mesures
25 if (this.memoryHistory.length > this.HISTORY_SIZE) {
26 this.memoryHistory.shift();
27 }
28
29 // Vérifier si la mémoire dépasse le seuil
30 const heapUsedMB = usage.heapUsed / 1024 / 1024;
31 if (heapUsedMB > this.MEMORY_THRESHOLD_MB) {
32 console.warn(`Utilisation mémoire élevée : ${heapUsedMB.toFixed(2)}MB`);
33 this.suggestOptimizations(usage);
34 }
35
36 // Journaliser une fois par minute
37 if (new Date().getSeconds() === 0) {
38 this.logMemoryStatus(usage);
39 }
40 }@Cron exige ScheduleModule.forRoot(). La vraie limite du tas vient de getHeapStatistics() de node:v8 : heapTotal n'est que la réservation actuelle, que V8 agrandit lui-même, donc la première version, en se comparant à elle, déclenchait des alarmes pour un processus en parfaite santé.
Une fois par minute, le service affiche l'état et calcule la tendance :
1 private logMemoryStatus(usage: NodeJS.MemoryUsage): void {
2 const formatMB = (bytes: number) => (bytes / 1024 / 1024).toFixed(2);
3
4 console.log('État des entrepôts du castrum :');
5 console.log(`Heap utilisé : ${formatMB(usage.heapUsed)}MB`);
6 console.log(`Heap total : ${formatMB(usage.heapTotal)}MB`);
7 console.log(`RSS (mémoire physique) : ${formatMB(usage.rss)}MB`);
8 console.log(`Externe : ${formatMB(usage.external)}MB`);
9
10 // Calculer la croissance de la mémoire
11 const growthInfo = this.calculateMemoryGrowth();
12 if (growthInfo.isGrowing) {
13 console.log(`Trend: +${growthInfo.growthRate.toFixed(2)}MB/min`);
14 }
15 }
16
17 private calculateMemoryGrowth(): { isGrowing: boolean; growthRate: number } {
18 if (this.memoryHistory.length < 10) return { isGrowing: false, growthRate: 0 };
19
20 const recent = this.memoryHistory.slice(-10);
21 const oldest = recent[0];
22 const newest = recent[recent.length - 1];
23
24 const timeDiffMin = (newest.timestamp.getTime() - oldest.timestamp.getTime()) / 60000;
25 const memoryDiffMB = (newest.usage.heapUsed - oldest.usage.heapUsed) / 1024 / 1024;
26
27 const growthRate = memoryDiffMB / timeDiffMin;
28
29 return {
30 isGrowing: growthRate > 1, // Plus de 1MB/min
31 growthRate,
32 };
33 }La tendance compare la plus ancienne et la plus récente des dix dernières mesures. Une croissance de plus de 1 MB par minute qui ne redescend jamais est le premier symptôme d'une fuite.
Les suggestions et les alertes vérifient les mêmes seuils :
1 private suggestOptimizations(usage: NodeJS.MemoryUsage): void {
2 console.log('Suggestions d’optimisation :');
3
4 if (usage.heapUsed / this.HEAP_LIMIT > 0.9) {
5 console.log(' - Tas proche de la limite : fais un heap snapshot et cherche une fuite');
6 }
7
8 if (usage.external > 50 * 1024 * 1024) {
9 console.log(' - Vérifie les buffers et les streams - peut-être trop de données en mémoire');
10 }
11
12 console.log(" - Vérifie les fuites mémoire potentielles dans les caches");
13 console.log(' - Envisage un TTL plus court pour les données temporaires');
14 }
15
16 getMemoryReport() {
17 const current = process.memoryUsage();
18 const formatMB = (bytes: number) => (bytes / 1024 / 1024).toFixed(2);
19
20 return {
21 current: {
22 heapUsed: formatMB(current.heapUsed) + 'MB',
23 heapTotal: formatMB(current.heapTotal) + 'MB',
24 heapLimit: formatMB(this.HEAP_LIMIT) + 'MB',
25 rss: formatMB(current.rss) + 'MB',
26 external: formatMB(current.external) + 'MB',
27 },
28 trend: this.calculateMemoryGrowth(),
29 history: this.memoryHistory.slice(-20), // Les 20 dernières mesures
30 alerts: this.generateAlerts(current),
31 };
32 }
33
34 private generateAlerts(usage: NodeJS.MemoryUsage): string[] {
35 const alerts = [];
36 const heapUsedMB = usage.heapUsed / 1024 / 1024;
37
38 if (heapUsedMB > this.MEMORY_THRESHOLD_MB) {
39 alerts.push(`Utilisation élevée du heap : ${heapUsedMB.toFixed(2)}MB`);
40 }
41
42 if (usage.heapUsed / this.HEAP_LIMIT > 0.85) {
43 alerts.push('Le tas occupe plus de 85 % de la limite - une erreur out of memory menace');
44 }
45
46 const growthInfo = this.calculateMemoryGrowth();
47 if (growthInfo.isGrowing && growthInfo.growthRate > 5) {
48 alerts.push(`Croissance rapide de la mémoire : ${growthInfo.growthRate.toFixed(2)}MB/min`);
49 }
50
51 return alerts;
52 }L'ancienne version conseillait ici de « lancer global.gc() ». C'est un mauvais conseil : V8 fait le ménage tout seul, et un GC manuel ne fait que figer l'application. Un nettoyage complet a un seul usage honnête, le diagnostic :
1 // Diagnostic : la mémoire revient-elle après un GC complet ? (exige le flag --expose-gc)
2 forceGarbageCollection(): { before: number; after: number; freed: number } {
3 const beforeMB = process.memoryUsage().heapUsed / 1024 / 1024;
4
5 if (global.gc) {
6 global.gc();
7 const afterMB = process.memoryUsage().heapUsed / 1024 / 1024;
8 const freedMB = beforeMB - afterMB;
9
10 console.log(`Garbage collection : ${freedMB.toFixed(2)}MB libérés`);
11
12 return {
13 before: beforeMB,
14 after: afterMB,
15 freed: freedMB,
16 };
17 } else {
18 console.warn('Garbage collection indisponible - lance avec le flag --expose-gc');
19 return { before: beforeMB, after: beforeMB, freed: 0 };
20 }
21 }
22}Si la mémoire ne baisse pas après un GC forcé, quelqu'un retient encore les objets : tu as une fuite. N'active le flag --expose-gc que pour ce genre d'enquête.
L'event loop - une seule route à travers le camp
JavaScript dans Node.js s'exécute dans un seul thread qui traite les événements en boucle. La documentation de Node.js cite ses phases dans cet ordre : timers (setTimeout, setInterval), pending callbacks (des callbacks d'E/S reportés, autrefois appelés I/O callbacks), idle et prepare, poll (de nouveaux événements d'E/S), check (setImmediate) et close callbacks. Quand une fonction calcule de façon synchrone pendant une seconde, l'event loop est bloquée et arrête le traitement de toutes les autres requêtes.
Le retard de la boucle se mesure avec monitorEventLoopDelay() de perf_hooks, qui rassemble les mesures dans un histogramme :
1// event-loop.monitor.ts
2import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
3import { Interval } from '@nestjs/schedule';
4import { monitorEventLoopDelay } from 'node:perf_hooks';
5
6@Injectable()
7export class EventLoopMonitor implements OnModuleDestroy {
8 private readonly logger = new Logger(EventLoopMonitor.name);
9 private readonly histogram = monitorEventLoopDelay({ resolution: 20 });
10
11 constructor() {
12 this.histogram.enable();
13 }
14
15 @Interval(10000) // Toutes les 10 secondes
16 check() {
17 const p99 = this.histogram.percentile(99) / 1e6; // nanosecondes en millisecondes
18 if (p99 > 100) {
19 this.logger.warn(`Event loop en retard : p99 = ${p99.toFixed(1)} ms`);
20 }
21 this.histogram.reset();
22 }
23
24 onModuleDestroy() {
25 this.histogram.disable();
26 }
27}L'histogramme donne des valeurs en nanosecondes, d'où la division par 1e6. Dans un test, un blocage de 200 ms a donné un p99 de 208 ms.
Les techniques pour éviter le blocage, de la plus simple :
- async/await au lieu d'opérations synchrones, par ex.
fs.promisesau lieu dereadFileSync, - déplacer les calculs lourds vers une file Bull (aujourd'hui BullMQ) traitée par un processus séparé,
- les worker threads de
node:worker_threadspour les tâches gourmandes en CPU, - le clustering avec PM2, c'est-à-dire plusieurs processus sur plusieurs cœurs.
Les fuites - des tributs oubliés
La cause la plus fréquente d'une fuite, ce sont des références non libérées : un listener ajouté à chaque requête et jamais retiré, une closure qui retient un gros objet, une map qui grandit sans fin ou un timer oublié. Le diagnostic se fait par étapes :
- surveille la croissance de l'utilisation mémoire (
process.memoryUsage), - fais un heap dump et analyse la rétention des objets,
- identifie les références et les listeners non libérés,
- corrige la fuite et vérifie la stabilité de la mémoire.
L'analyse d'un heap dump montre quels objets occupent la mémoire et qui les retient ; tu découvriras les outils de capture dans la leçon sur le profilage.
Optimiser le cache et les collections
Une fuite classique, c'est un cache sans limite. Le nôtre limite à la fois le nombre d'éléments et la mémoire :
1// memory-optimized-cache.service.ts
2import { Injectable } from '@nestjs/common';
3import { Cron } from '@nestjs/schedule';
4
5@Injectable()
6export class MemoryOptimizedCacheService {
7 private cache = new Map<string, {
8 data: any;
9 timestamp: number;
10 accessCount: number;
11 lastAccess: number;
12 size: number;
13 timer: NodeJS.Timeout;
14 }>();
15
16 private maxSize = 1000; // Nombre maximal d'éléments
17 private maxMemoryMB = 100; // Mémoire maximale du cache
18 private currentMemoryBytes = 0;
19
20 set(key: string, value: any, ttl: number = 300000): void {
21 const size = this.estimateSize(value);
22
23 // Supprimer l'ancien élément s'il existe - avec son timer
24 this.delete(key);
25
26 // Libérer de la place jusqu'à ce que le nouvel élément tienne
27 while (this.cache.size > 0 && (this.cache.size >= this.maxSize ||
28 (this.currentMemoryBytes + size) > this.maxMemoryMB * 1024 * 1024)) {
29 this.evictOldest();
30 }
31
32 // Définir le TTL ; unref() ne garde pas le processus en vie
33 const timer = setTimeout(() => this.delete(key), ttl);
34 timer.unref();
35
36 this.cache.set(key, {
37 data: value,
38 timestamp: Date.now(),
39 accessCount: 0,
40 lastAccess: Date.now(),
41 size,
42 timer,
43 });
44
45 this.currentMemoryBytes += size;
46 }
47
48 get(key: string): any {
49 const item = this.cache.get(key);
50 if (!item) return null;
51
52 // Mettre à jour les statistiques d'accès
53 item.accessCount++;
54 item.lastAccess = Date.now();
55
56 return item.data;
57 }
58
59 delete(key: string): boolean {
60 const item = this.cache.get(key);
61 if (item) {
62 clearTimeout(item.timer);
63 this.currentMemoryBytes -= item.size;
64 return this.cache.delete(key);
65 }
66 return false;
67 }C'est la version corrigée. La première créait un nouveau timer à chaque set() sans annuler l'ancien : l'ancien timer se déclenchait plus tard et supprimait la valeur toute fraîche. delete() nettoie maintenant le timer, et unref() fait qu'un timer en attente n'empêche pas le processus de se terminer. La boucle while libère autant de place que nécessaire, et pas un seul élément.
La stratégie d'éviction dépend de l'état de la mémoire :
1 // Éviction selon différentes stratégies
2 private evictOldest(): void {
3 if (this.cache.size === 0) return;
4
5 const strategy = this.getEvictionStrategy();
6
7 switch (strategy) {
8 case 'LRU': // Least Recently Used
9 this.evictLRU();
10 break;
11 case 'LFU': // Least Frequently Used
12 this.evictLFU();
13 break;
14 case 'SIZE': // Les plus gros éléments d'abord
15 this.evictLargest();
16 break;
17 default:
18 this.evictOldestByTime();
19 }
20 }
21
22 private getEvictionStrategy(): string {
23 // Choisir la stratégie selon l'état de la mémoire
24 const memoryPressure = this.currentMemoryBytes / (this.maxMemoryMB * 1024 * 1024);
25
26 if (memoryPressure > 0.9) {
27 return 'SIZE'; // Sous forte pression mémoire - supprimer les gros éléments
28 } else if (this.cache.size > this.maxSize * 0.8) {
29 return 'LRU'; // Beaucoup d'éléments - supprimer les moins récemment utilisés
30 } else {
31 return 'LFU'; // En temps normal - supprimer les moins populaires
32 }
33 }Sous pression mémoire, on supprime les plus gros éléments, avec beaucoup d'éléments, les moins récemment utilisés (LRU), et en temps normal, les moins fréquemment utilisés (LFU).
Les quatre stratégies ne diffèrent que par le critère :
1 private evictLRU(): void {
2 let oldestKey = '';
3 let oldestTime = Infinity;
4
5 this.cache.forEach((item, key) => {
6 if (item.lastAccess < oldestTime) {
7 oldestTime = item.lastAccess;
8 oldestKey = key;
9 }
10 });
11
12 if (oldestKey) {
13 console.log(`LRU eviction: ${oldestKey}`);
14 this.delete(oldestKey);
15 }
16 }
17
18 private evictLFU(): void {
19 let leastUsedKey = '';
20 let leastUsedCount = Infinity;
21
22 this.cache.forEach((item, key) => {
23 if (item.accessCount < leastUsedCount) {
24 leastUsedCount = item.accessCount;
25 leastUsedKey = key;
26 }
27 });
28
29 if (leastUsedKey) {
30 console.log(`LFU eviction: ${leastUsedKey}`);
31 this.delete(leastUsedKey);
32 }
33 }
34
35 private evictLargest(): void {
36 let largestKey = '';
37 let largestSize = 0;
38
39 this.cache.forEach((item, key) => {
40 if (item.size > largestSize) {
41 largestSize = item.size;
42 largestKey = key;
43 }
44 });
45
46 if (largestKey) {
47 console.log(`SIZE eviction: ${largestKey} (${largestSize} bytes)`);
48 this.delete(largestKey);
49 }
50 }
51
52 private evictOldestByTime(): void {
53 let oldestKey = '';
54 let oldestTime = Infinity;
55
56 this.cache.forEach((item, key) => {
57 if (item.timestamp < oldestTime) {
58 oldestTime = item.timestamp;
59 oldestKey = key;
60 }
61 });
62
63 if (oldestKey) {
64 console.log(`TIME eviction: ${oldestKey}`);
65 this.delete(oldestKey);
66 }
67 }Les recherches partent de Infinity. La première version partait de Date.now(), donc les éléments ajoutés dans la même milliseconde n'étaient jamais plus anciens et rien ne disparaissait : dans un test, un cache limité à 3 a grandi jusqu'à 10 éléments.
Enfin, l'estimation de la taille, les statistiques et le nettoyage :
1 private estimateSize(obj: any): number {
2 // Estimation approximative de la taille de l'objet en mémoire
3 const jsonString = JSON.stringify(obj);
4 return jsonString.length * 2; // UTF-16 = 2 bytes per character
5 }
6
7 getStats() {
8 const memoryUsageMB = this.currentMemoryBytes / 1024 / 1024;
9 const avgItemSize = this.cache.size > 0 ? this.currentMemoryBytes / this.cache.size : 0;
10
11 let totalAccesses = 0;
12 this.cache.forEach(item => {
13 totalAccesses += item.accessCount;
14 });
15
16 return {
17 size: this.cache.size,
18 maxSize: this.maxSize,
19 memoryUsageMB: memoryUsageMB.toFixed(2),
20 maxMemoryMB: this.maxMemoryMB,
21 avgItemSize: Math.round(avgItemSize),
22 totalAccesses,
23 fillRatio: (this.cache.size / this.maxSize * 100).toFixed(1) + '%',
24 memoryRatio: (memoryUsageMB / this.maxMemoryMB * 100).toFixed(1) + '%',
25 };
26 }
27
28 // Nettoyage des éléments expirés
29 @Cron('0 */5 * * * *') // Toutes les 5 minutes (le premier champ correspond aux secondes)
30 cleanupExpired(): void {
31 const now = Date.now();
32 const toDelete = [];
33
34 this.cache.forEach((item, key) => {
35 // Supprimer les éléments sans accès depuis plus d'une heure
36 if (now - item.lastAccess > 3600000) {
37 toDelete.push(key);
38 }
39 });
40
41 toDelete.forEach(key => {
42 this.delete(key);
43 });
44
45 if (toDelete.length > 0) {
46 console.log(`${toDelete.length} éléments obsolètes du cache nettoyés`);
47 }
48 }
49}JSON.stringify ne donne qu'une taille approximative, car un objet en mémoire occupe une place différente de son texte. Le motif 0 */5 * * * * signifie toutes les 5 minutes ; l'ancien */5 * * * * * se déclenchait toutes les 5 secondes, car le premier champ correspond aux secondes.
Je te recommande la bibliothèque toute faite lru-cache ou l'astuce simple de l'éditeur à côté : Map retient l'ordre d'insertion, tu supprimes donc l'entrée la plus ancienne en temps constant. N'écris ton propre cache que pour comprendre le mécanisme. Dans la prochaine leçon, nous limiterons le trafic avant qu'il n'inonde les entrepôts.
Retiens : dans un entrepôt bien tenu, chaque tribut a une date limite et une place sur l'étagère, au lieu de traîner oublié dans un coin.
Code de cette leçon : src/memory-management.ts
1// Memory Management - Optimisation des entrepôts du navire
2import { Injectable, Logger } from '@nestjs/common';
3
4// 1. Monitoring de la mémoire
5@Injectable()
6export class MemoryMonitor {
7 private readonly logger = new Logger('MemoryMonitor');
8
9 getMemoryUsage() {
10 const usage = process.memoryUsage();
11 return {
12 rss: Math.round(usage.rss / 1024 / 1024), // MB
13 heapTotal: Math.round(usage.heapTotal / 1024 / 1024),
14 heapUsed: Math.round(usage.heapUsed / 1024 / 1024),
15 external: Math.round(usage.external / 1024 / 1024),
16 heapPercent: Math.round(
17 (usage.heapUsed / usage.heapTotal) * 100
18 ),
19 };
20 }
21
22 // Vérifier si la mémoire est dans la norme
23 checkHealth(thresholdMB: number = 512): {
24 healthy: boolean;
25 message: string;
26 } {
27 const mem = this.getMemoryUsage();
28 const healthy = mem.heapUsed < thresholdMB;
29
30 return {
31 healthy,
32 message: healthy
33 ? `Memory OK: ${mem.heapUsed}MB / ${thresholdMB}MB`
34 : `WARNING: ${mem.heapUsed}MB exceeds ${thresholdMB}MB!`,
35 };
36 }
37}
38
39// 2. Éviter les fuites de mémoire
40
41// MAUVAIS - fuite de mémoire (le tableau grossit sans fin) :
42// class BadService {
43// private cache: any[] = [];
44// addToCache(data: any) {
45// this.cache.push(data); // JAMAIS nettoyé !
46// }
47// }
48
49// BON - cache avec limite :
50class BoundedCache<T> {
51 private items: Map<string, { data: T; addedAt: number }> = new Map();
52 private maxSize: number;
53 private ttlMs: number;
54
55 constructor(maxSize = 1000, ttlMs = 300000) {
56 this.maxSize = maxSize;
57 this.ttlMs = ttlMs;
58 }
59
60 set(key: string, data: T) {
61 // Remove oldest if too many
62 if (this.items.size >= this.maxSize) {
63 const oldest = this.items.keys().next().value;
64 this.items.delete(oldest);
65 }
66 this.items.set(key, { data, addedAt: Date.now() });
67 }
68
69 get(key: string): T | null {
70 const item = this.items.get(key);
71 if (!item) return null;
72
73 // Vérifier le TTL
74 if (Date.now() - item.addedAt > this.ttlMs) {
75 this.items.delete(key);
76 return null;
77 }
78 return item.data;
79 }
80
81 get size() { return this.items.size; }
82}
83
84// 3. Garbage Collector
85// Node.js gère automatiquement la mémoire (GC)
86// Mais on peut le forcer : global.gc() (avec le flag --expose-gc)
87
88// 4. Streams au lieu de charger des fichiers entiers en mémoire
89// MAUVAIS : const data = fs.readFileSync('huge-file.csv');
90// BON : const stream = fs.createReadStream('huge-file.csv');
91
92// 5. WeakMap pour des données temporaires
93// const cache = new WeakMap();
94// Les objets dans WeakMap peuvent être garbage collected !
95Tu 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 blocage de l'event loop dans Node.js provoque :
2. Les worker threads dans Node.js sont surtout utilisés pour :
Voici 2 des 4 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Écris un service qui surveille les délais de l'event loop et journalise des avertissements
- Tri vertical
Ordonne les phases de l'event loop de Node.js :
- Clique dans l'ordre
Ordonne les techniques pour éviter le blocage de l'event loop de la plus simple :
- Éditeur de code
Écris un service qui collecte des métriques mémoire (heapUsed, rss, external)
- Tri vertical
Ordonne les étapes de diagnostic d'une fuite mémoire :
- Tri vertical
Ordonne les couches de cache de la plus proche de l'utilisateur :