Cours NestJS · Module 8: Cache et performances

PROJET - un système d'optimisation des performances

6 min de lecture
Dans cette leçon6

Ce module a présenté plusieurs outils : un cache dans la mémoire du processus et dans Redis, des index en base, la compression des réponses, la limitation du nombre de requêtes. Le projet est l'endroit où il faut décider lequel employer - et c'est une compétence tout autre que la connaissance de la syntaxe.

Tu vas construire une API de gestion des légions capable d'absorber un trafic supérieur à ce que supporterait une version naïve, et de montrer pourquoi elle y parvient.

Une règle avant toutes les autres

Optimiser sans mesurer, c'est deviner. Cela paraît banal, et c'est la règle la plus souvent enfreinte du domaine : on ajoute un cache là où l'idée est venue, et l'on consigne une amélioration que personne n'a mesurée.

L'ordre du travail est toujours le même :

  1. Mesure - combien de temps prend une requête, combien de mémoire elle consomme, combien de requêtes partent vers la base.
  2. Trouve le goulot d'étranglement - un seul, le plus coûteux.
  3. Applique le correctif le moins cher qui le supprime.
  4. Mesure de nouveau - et vérifie que le correctif a agi, plutôt que d'avoir seulement déplacé le problème.

Voilà pourquoi le projet commence par la mesure, non par un cache.

Étape 1 - l'endpoint des métriques

La première chose que tu exposes est une vue de l'état du processus :

1@Controller('metrics')
2export class MetricsController {
3  @Get()
4  getMetrics() {
5    const memory = process.memoryUsage();
6
7    return {
8      heapUsed: memory.heapUsed,
9      heapTotal: memory.heapTotal,
10      rss: memory.rss,
11      uptime: process.uptime(),
12    };
13  }
14}

process.memoryUsage() renvoie plusieurs nombres, et il vaut la peine de savoir en quoi ils diffèrent. heapUsed est la mémoire réellement occupée par les objets de ton application - elle croît quand tu gardes trop de choses dans un cache. heapTotal est la mémoire que le moteur V8 a réservée pour le tas ; elle est toujours supérieure et varie par paliers. rss (Resident Set Size) est la mémoire totale du processus telle que la voit le système d'exploitation - tas, pile, code et tampons réunis.

C'est l'écart entre heapUsed et rss qui importe le plus. Quand heapUsed seul croît, tu accumules des objets - d'ordinaire dans un cache sans limite. Quand rss croît alors que heapUsed stagne, la fuite est hors du tas : tampons, connexions ouvertes, bibliothèques natives.

process.uptime() donne le nombre de secondes d'exécution du processus. Seul, il dit peu ; rapproché du reste, beaucoup : une consommation de mémoire croissant linéairement avec la durée d'exécution est une fuite, non une charge.

Étape 2 - un cache dans Redis

Le cache en mémoire de processus rencontré plus tôt a un défaut : chaque instance de l'application possède le sien. Avec deux instances, la même lecture atteint la base deux fois, et une invalidation dans l'une n'atteint jamais l'autre. Redis y remédie :

1CacheModule.register({
2  store: redisStore,
3  host: 'localhost',
4  port: 6379,
5  ttl: 300 })

L'ordre d'écriture est fixe : CacheModule.register({ ouvre la configuration, store: redisStore, troque le magasin de la mémoire du processus contre Redis, host: 'localhost', et port: 6379, indiquent où se tient ce Redis, et ttl: 300 }) fixe la durée de vie par défaut d'une entrée et referme.

store est la ligne décisive. Sans elle, CacheModule fonctionne encore, mais en mémoire de processus - et c'est traître, car en local tout semble bien et l'ennui n'apparaît qu'une fois une seconde instance lancée en production.

Remarque qu'ici host et port siègent directement dans l'objet de configuration. Les versions récentes de cache-manager les imbriquent dans un champ de connexion distinct - vérifie la version dans ton package.json avant de recopier l'exemple d'autrui.

Étape 3 - la base de données

Un cache accélère les lectures répétées. Les requêtes qu'on ne peut mettre en cache doivent être corrigées à la source :

1@Entity('legionaries')
2@Index(['cohortId', 'isActive'])
3export class Legionary {
4  @Column()
5  cohortId: number;
6
7  @Column({ default: true })
8  isActive: boolean;
9}
10
11// dans le depot
12findActiveByCohort(cohortId: number) {
13  return this.repo.find({
14    where: { cohortId, isActive: true },
15    select: ['id', 'name', 'rank'],
16    take: 50,
17  });
18}

Trois correctifs à la fois, chacun agissant autrement. Un index composite sur les colonnes que tu filtres réellement change le parcours de toute la table en un saut vers des lignes précises. select ne récupère que les colonnes nécessaires - sans lui, tu tires de la base des champs dont personne ne se servira, avec tout leur volume. take borne le nombre de lignes ; un endpoint sans limite fonctionne à merveille jusqu'au jour où quelqu'un en compte cent mille.

Avant d'ajouter un index, examine le plan de la requête par EXPLAIN. Un index sur une colonne que personne ne filtre n'accélère aucune lecture et ralentit chaque écriture.

Étape 4 - la couche de sortie

Restent deux choses agissant à la lisière de l'application :

1async function bootstrap() {
2  const app = await NestFactory.create(AppModule);
3
4  app.use(compression());
5
6  await app.listen(3000);
7}

compression() emballe les réponses en gzip - pour des listes au format JSON, cela signifie d'ordinaire plusieurs fois moins de trafic, au prix d'un peu de processeur. La limitation du débit par ThrottlerModule agit de l'autre côté : elle n'accélère rien, elle borne le nombre de requêtes que tu acceptes.

Ces deux mécanismes gagnent à être compris comme un couple d'opposés. La compression et le cache font qu'une requête coûte moins. La limitation fait qu'il y a moins de requêtes. Quand un système s'étrangle, ces deux voies s'offrent toujours à toi - et il t'en faut d'ordinaire les deux.

Ce que tu rends

Le projet est achevé lorsqu'il comporte :

  1. Un endpoint /metrics avec heapUsed, heapTotal, rss et uptime.
  2. Un cache dans Redis par CacheModule.register avec store: redisStore, invalidé à l'écriture.
  3. Des index sur les colonnes filtrées, ainsi que select et take dans les requêtes de liste.
  4. La compression des réponses et la limitation du débit sur les endpoints d'écriture.
  5. Une mesure avant et après - des chiffres pour au moins un endpoint, avec une note sur ce qui a changé.

Le cinquième point est le plus important et le plus souvent négligé. Sans lui, tu rends une collection de techniques et non une optimisation - car une optimisation est l'écart entre deux mesures non une liste d'outils employés.

Envoie le lien de ton dépôt une fois terminé.

Code de cette leçon : src/performance-project.ts
1// PROJET : Système d'optimisation des performances de la flotte
2import { Injectable, Inject, Module, Logger } from '@nestjs/common';
3import { CACHE_MANAGER } from '@nestjs/cache-manager';
4import { Cache } from 'cache-manager';
5
6// === SYSTÈME DE PERFORMANCE COMPLET ===
7
8// 1. Cache Service avec métriques
9@Injectable()
10export class FleetCacheService {
11  private hits = 0;
12  private misses = 0;
13
14  constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
15
16  async getOrFetch<T>(
17    key: string,
18    fetcher: () => Promise<T>,
19    ttl = 300
20  ): Promise<T> {
21    const cached = await this.cache.get<T>(key);
22    if (cached) {
23      this.hits++;
24      return cached;
25    }
26    this.misses++;
27    const data = await fetcher();
28    await this.cache.set(key, data, ttl * 1000);
29    return data;
30  }
31
32  getCacheStats() {
33    const total = this.hits + this.misses;
34    return {
35      hits: this.hits,
36      misses: this.misses,
37      hitRate: total > 0
38        ? `${((this.hits / total) * 100).toFixed(1)}%`
39        : 'N/A',
40    };
41  }
42}
43
44// 2. Performance Monitor
45@Injectable()
46export class PerformanceMonitor {
47  private readonly logger = new Logger('PerformanceMonitor');
48  private requestTimes: number[] = [];
49
50  recordRequest(durationMs: number) {
51    this.requestTimes.push(durationMs);
52    if (this.requestTimes.length > 1000) {
53      this.requestTimes = this.requestTimes.slice(-1000);
54    }
55  }
56
57  getMetrics() {
58    if (this.requestTimes.length === 0) {
59      return { avg: 0, p95: 0, p99: 0, max: 0 };
60    }
61
62    const sorted = [...this.requestTimes].sort((a, b) => a - b);
63    const len = sorted.length;
64
65    return {
66      avg: Math.round(
67        sorted.reduce((a, b) => a + b, 0) / len
68      ),
69      p95: sorted[Math.floor(len * 0.95)],
70      p99: sorted[Math.floor(len * 0.99)],
71      max: sorted[len - 1],
72      totalRequests: len,
73    };
74  }
75
76  getSystemMetrics() {
77    const mem = process.memoryUsage();
78    return {
79      uptime: Math.round(process.uptime()),
80      memory: {
81        heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
82        heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
83        rss: Math.round(mem.rss / 1024 / 1024),
84      },
85      cpu: process.cpuUsage(),
86      pid: process.pid,
87    };
88  }
89}
90
91// 3. Health Check agrégeant toutes les métriques
92@Injectable()
93export class FleetHealthService {
94  constructor(
95    private cacheService: FleetCacheService,
96    private perfMonitor: PerformanceMonitor,
97  ) {}
98
99  getHealthReport() {
100    const perf = this.perfMonitor.getMetrics();
101    const system = this.perfMonitor.getSystemMetrics();
102    const cache = this.cacheService.getCacheStats();
103
104    return {
105      status: perf.p95 < 500 ? 'healthy' : 'degraded',
106      performance: perf,
107      system,
108      cache,
109      timestamp: new Date().toISOString(),
110    };
111  }
112}
113

Tu as repéré une erreur dans cette leçon ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Crée un endpoint /metrics retournant heapUsed, heapTotal, rss et uptime

  • Tri vertical

    Ordonne la configuration correcte de CacheModule avec le store Redis :