Cours NestJS · Module 8: Cache et performances

Optimisation des performances - augmenter la vitesse du fort

10 min de lecture
Dans cette leçon6

Le soir, quand les cohortes rendent leurs rapports, le fort ralentit : une réponse qui prend 80 ms le matin traîne pendant des secondes une fois la nuit tombée. Le consul Caesar.js veut accélérer, mais personne ne sait ce qui ralentit - la base de données, le processeur ou la mémoire. La première règle de l'ingénieur de la légion est donc : mesurer d'abord, optimiser ensuite.

Qu'est-ce que l'optimisation des performances ?

Imagine l'application comme un camp de légion :

  • la force des légionnaires (CPU) - doit suffire pour toutes les tâches,
  • les entrepôts (mémoire) - doivent être bien gérés,
  • le tabularium (base de données) - doit être bien rangé,
  • les cohortes (processus) - doivent bien coopérer,
  • les routes et les messagers (opérations d'E/S) - doivent rester praticables.

L'optimisation est l'art d'accorder harmonieusement tous ces éléments.

Quatre signaux

Avant de changer quoi que ce soit, décide de ce que tu mesures. Le livre de Google sur le SRE cite quatre signaux d'or, dans cet ordre :

  • la latence - le temps de réponse, exprimé par les percentiles p95 et p99,
  • le trafic - le débit, par exemple les requêtes par seconde,
  • les erreurs - la part des requêtes échouées,
  • la saturation - l'utilisation des ressources, c'est-à-dire du CPU et de la mémoire.

Un p95 de 200 ms signifie que 95 % des requêtes se terminent en moins de 200 ms. Une moyenne cache les malchanceux de la queue : l'utilisateur qui attend 10 secondes y disparaît parmi mille réponses rapides.

Quatre épreuves de charge

Tu lis les signaux pendant les tests de charge, du plus léger au plus lourd :

  • smoke test - une charge minimale, qui vérifie que le système fonctionne tout court,
  • load test - le trafic attendu, habituel,
  • stress test - une charge au-dessus de l'attendu, à la recherche du point de rupture,
  • spike test - un bond soudain, bref et énorme du trafic.

C'est ainsi que les classe la documentation de l'outil k6 ; tu découvriras le tir de requêtes lui-même dans la leçon sur le profilage.

Profiling - diagnostiquer les problèmes

À l'intérieur de l'application, le temps se mesure avec le module perf_hooks de Node.js : performance.mark() pose une marque sur la timeline, performance.measure() calcule l'écart entre deux marques, et PerformanceObserver reçoit chaque nouvelle mesure. On construit dessus un service de profilage :

1// performance-profiler.service.ts
2import { Injectable } from '@nestjs/common';
3import { performance, PerformanceObserver } from 'node:perf_hooks';
4
5@Injectable()
6export class PerformanceProfilerService {
7  private measurements = new Map<string, number[]>();
8  private observer: PerformanceObserver;
9
10  constructor() {
11    this.setupPerformanceObserver();
12  }
13
14  private setupPerformanceObserver(): void {
15    this.observer = new PerformanceObserver((list) => {
16      list.getEntries().forEach((entry) => {
17        if (entry.entryType === 'measure') {
18          this.recordMeasurement(entry.name, entry.duration);
19        }
20      });
21    });
22
23    this.observer.observe({ entryTypes: ['measure'] });
24  }
25
26  private recordMeasurement(name: string, duration: number): void {
27    if (!this.measurements.has(name)) {
28      this.measurements.set(name, []);
29    }
30
31    const measurements = this.measurements.get(name)!;
32    measurements.push(duration);
33
34    // Ne garder que les 100 dernières mesures
35    if (measurements.length > 100) {
36      measurements.shift();
37    }
38
39    // Journaliser les opérations lentes
40    if (duration > 1000) {
41      console.warn(`Opération lente : ${name} - ${duration.toFixed(2)}ms`);
42    }
43  }

L'observateur transmet les mesures à recordMeasurement(), qui garde les 100 derniers résultats de chaque opération et signale celles qui dépassent une seconde.

Le plus pratique est de mesurer avec un décorateur, une méthode statique qui enveloppe la méthode d'origine d'une mesure :

1  // Décorateur qui mesure le temps d'exécution
2  static Measure(operationName?: string) {
3    return function (target: any, propertyName: string, descriptor: PropertyDescriptor) {
4      const originalMethod = descriptor.value;
5      const measureName = operationName || `${target.constructor.name}.${propertyName}`;
6      let callId = 0;
7
8      descriptor.value = async function (...args: any[]) {
9        const id = ++callId; // des marques séparées pour les appels parallèles
10        const startMark = `${measureName}-start-${id}`;
11        const endMark = `${measureName}-end-${id}`;
12
13        performance.mark(startMark);
14
15        try {
16          const result = await originalMethod.apply(this, args);
17          performance.mark(endMark);
18          performance.measure(measureName, startMark, endMark);
19          return result;
20        } catch (error) {
21          performance.mark(endMark);
22          performance.measure(`${measureName}-error`, startMark, endMark);
23          throw error;
24        } finally {
25          // Les marques restent dans la timeline globale tant qu'on ne les supprime pas
26          performance.clearMarks(startMark);
27          performance.clearMarks(endMark);
28          performance.clearMeasures(measureName);
29          performance.clearMeasures(`${measureName}-error`);
30        }
31      };
32    };
33  }

La première version construisait les noms des marques avec Date.now(), donc deux appels dans la même milliseconde écrasaient mutuellement leurs marques ; le compteur callId corrige cela. Le bloc finally fait le ménage, car sans clearMarks() les marques s'accumuleraient dans un serveur qui tourne des semaines, comme une fuite mémoire. L'observateur a déjà reçu la mesure, on ne perd donc rien.

Une mesure manuelle est utile quand tu ne mesures qu'une partie d'une méthode :

1  startMeasurement(name: string): void {
2    performance.mark(`${name}-start`);
3  }
4
5  endMeasurement(name: string): number {
6    const endMark = `${name}-end`;
7    performance.mark(endMark);
8    // measure() renvoie la mesure toute fraîche - pas besoin de la chercher dans l'historique
9    const measurement = performance.measure(name, `${name}-start`, endMark);
10
11    performance.clearMarks(`${name}-start`);
12    performance.clearMarks(endMark);
13    performance.clearMeasures(name);
14    return measurement.duration;
15  }

Avant, le service cherchait la mesure avec getEntriesByName(name)[0], qui renvoie la plus ancienne mesure de ce nom : à partir du deuxième appel, le résultat était toujours le même.

À partir des chiffres collectés, on calcule des statistiques :

1  getStatistics(operationName: string) {
2    const measurements = this.measurements.get(operationName) || [];
3    if (measurements.length === 0) return null;
4
5    const sorted = [...measurements].sort((a, b) => a - b);
6    const avg = measurements.reduce((a, b) => a + b, 0) / measurements.length;
7
8    return {
9      count: measurements.length,
10      average: avg.toFixed(2),
11      median: sorted[Math.floor(sorted.length / 2)].toFixed(2),
12      min: sorted[0].toFixed(2),
13      max: sorted[sorted.length - 1].toFixed(2),
14      p95: sorted[Math.ceil(sorted.length * 0.95) - 1].toFixed(2),
15    };
16  }
17
18  getAllStatistics() {
19    const stats = {};
20    this.measurements.forEach((_, name) => {
21      stats[name] = this.getStatistics(name);
22    });
23    return stats;
24  }
25}

On calcule le p95 par la méthode du rang le plus proche : avec 100 mesures, c'est le 95e résultat après le tri.

Dans un service, le décorateur se place au-dessus d'une méthode, et la mesure manuelle entoure le fragment choisi :

1// Utilisation dans un service
2@Injectable()
3export class TributeService {
4  constructor(private profiler: PerformanceProfilerService) {}
5
6  @PerformanceProfilerService.Measure('tribute-search')
7  async findTributes(criteria: any) {
8    // Recherche complexe de tributs
9    await this.complexDatabaseQuery(criteria);
10    return [];
11  }
12
13  async manualMeasurement() {
14    this.profiler.startMeasurement('manual-operation');
15
16    // Une opération quelconque
17    await this.doSomething();
18
19    const duration = this.profiler.endMeasurement('manual-operation');
20    console.log(`Opération terminée en ${duration}ms`);
21  }
22
23  private async complexDatabaseQuery(criteria: any) {
24    await new Promise((r) => setTimeout(r, 20)); // bouchon de requête
25  }
26
27  private async doSomething() {
28    await new Promise((r) => setTimeout(r, 30)); // bouchon de travail
29  }
30}

Le décorateur ne change pas le corps de findTributes : la méthode se contente toujours de chercher, et la mesure se fait à côté.

Database Query Optimization

Le coupable le plus fréquent, c'est la base de données. Le problème classique du N+1 a l'air innocent :

1// query-optimizer.service.ts
2@Injectable()
3export class QueryOptimizerService {
4  constructor(
5    @InjectRepository(Tribute) private tributeRepo: Repository<Tribute>,
6  ) {}
7
8  // Au lieu de N+1 requêtes
9  async getBadTributesWithOwners(): Promise<any[]> {
10    const tributes = await this.tributeRepo.find();
11
12    // Cela génère N requêtes supplémentaires !
13    const tributesWithOwners = [];
14    for (const tribute of tributes) {
15      const owner = await this.getOwner(tribute.ownerId);
16      tributesWithOwners.push({ ...tribute, owner });
17    }
18
19    return tributesWithOwners;
20  }

Une requête pour la liste, puis une par propriétaire : dans un test sur PostgreSQL, 120 tributs ont coûté 121 requêtes.

La solution est un JOIN, que TypeORM construit à partir de l'option relations :

1  // Version optimale avec un JOIN
2  async getOptimizedTributesWithOwners(): Promise<any[]> {
3    // Une seule requête avec un JOIN
4    return await this.tributeRepo.find({
5      relations: { owner: true },
6      select: {
7        id: true,
8        name: true,
9        value: true,
10        owner: {
11          id: true,
12          name: true,
13          rank: true,
14        },
15      },
16    });
17  }

Le même résultat est arrivé en une seule requête, et select n'a récupéré que les colonnes utiles. TypeORM 1.x a supprimé la syntaxe en tableau relations: ['owner'] : la forme objet est désormais obligatoire.

On traite les gros volumes par lots au lieu de tout charger en mémoire :

1  // Traitement par lots pour les gros volumes
2  async processTributesInBatches(batchSize: number = 100): Promise<void> {
3    let offset = 0;
4    let batch;
5
6    do {
7      batch = await this.tributeRepo.find({
8        skip: offset,
9        take: batchSize,
10        order: { id: 'ASC' },
11      });
12
13      if (batch.length > 0) {
14        await this.processTributeBatch(batch);
15        offset += batchSize;
16
17        // Rendre un instant l'event loop aux autres requêtes
18        await this.delay(10);
19      }
20    } while (batch.length === batchSize);
21  }

La pause rend l'event loop aux autres requêtes ; la première version prétendait qu'elle « laisse du temps au garbage collector », mais le GC s'exécute quand il le décide lui-même. skip grandit à chaque lot, et la base parcourt quand même les lignes sautées, donc avec des millions d'enregistrements un curseur vaut mieux :

1  // Pagination par curseur pour les très gros volumes
2  async getTributesWithCursor(cursor?: number, limit: number = 50) {
3    const qb = this.tributeRepo.createQueryBuilder('tribute');
4
5    if (cursor) {
6      qb.where('tribute.id > :cursor', { cursor });
7    }
8
9    const tributes = await qb
10      .orderBy('tribute.id', 'ASC')
11      .limit(limit + 1) // +1 pour savoir s'il y en a d'autres
12      .getMany();
13
14    const hasNext = tributes.length > limit;
15    const items = hasNext ? tributes.slice(0, -1) : tributes;
16    const nextCursor = hasNext ? tributes[tributes.length - 2].id : null;
17
18    return {
19      items,
20      nextCursor,
21      hasNext,
22    };
23  }

Le curseur est l'id de la dernière ligne renvoyée, et WHERE id > :cursor utilise l'index de la clé primaire, donc la centième page est aussi rapide que la première. Une ligne supplémentaire indique s'il existe une page suivante, sans COUNT séparé.

Les filtres décident eux aussi de la vitesse :

1  // Optimisation avec des index
2  async searchTributesOptimized(filters: any) {
3    const qb = this.tributeRepo.createQueryBuilder('tribute');
4
5    // Utiliser des colonnes indexées dans WHERE
6    if (filters.type) {
7      qb.andWhere('tribute.type = :type', { type: filters.type });
8    }
9
10    if (filters.minValue) {
11      qb.andWhere('tribute.value >= :minValue', { minValue: filters.minValue });
12    }
13
14    // Utiliser l'index sur created_at
15    if (filters.dateRange) {
16      qb.andWhere('tribute.createdAt BETWEEN :start AND :end', {
17        start: filters.dateRange.start,
18        end: filters.dateRange.end,
19      });
20    }
21
22    return await qb
23      .orderBy('tribute.value', 'DESC') // Index sur value
24      .limit(filters.limit || 100)
25      .getMany();
26  }
27
28  private async delay(ms: number): Promise<void> {
29    return new Promise(resolve => setTimeout(resolve, ms));
30  }
31
32  private async processTributeBatch(tributes: Tribute[]): Promise<void> {
33    // Traitement d'un lot
34    console.log(`Traitement de ${tributes.length} tributs...`);
35  }
36}

Des conditions sur des colonnes indexées permettent à la base de sauter directement aux bonnes lignes. limit() est sûr ici car la requête n'a pas de JOIN ; avec des JOIN, utilise take().

Les couches de vitesse

La requête la plus rapide est celle qui n'atteint jamais le fort. De la couche la plus proche de l'utilisateur :

  • le cache du navigateur - piloté par les en-têtes Cache-Control,
  • le CDN, c'est-à-dire l'edge cache - des copies du contenu sur des serveurs proches des utilisateurs, ce qui raccourcit le trajet et la latence,
  • le cache applicatif - Redis des leçons précédentes,
  • le cache des requêtes côté base de données.

Je te recommande un ordre de travail : mesure, corrige le plus gros problème, mesure à nouveau - sans la deuxième mesure, tu ne sais pas si tu as aidé. Dans les prochaines leçons, nous nous occuperons des index, de la répartition du trafic et de la mémoire.

Retiens : une cohorte rapide ne court pas plus vite, elle ne porte simplement aucun poids inutile - pèse-le d'abord, puis débarrasse-t'en.

Code de cette leçon : src/performance-optimization.ts
1// Performance Optimization - Augmenter la vitesse du navire
2import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
3import { Observable } from 'rxjs';
4import { tap } from 'rxjs/operators';
5
6// 1. Interceptor mesurant le temps de réponse
7@Injectable()
8export class PerformanceInterceptor implements NestInterceptor {
9  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
10    const start = Date.now();
11    const req = context.switchToHttp().getRequest();
12
13    return next.handle().pipe(
14      tap(() => {
15        const duration = Date.now() - start;
16        const method = req.method;
17        const url = req.url;
18        console.log(`[${method}] ${url} - ${duration}ms`);
19
20        if (duration > 1000) {
21          console.warn(`SLOW REQUEST: ${url} took ${duration}ms`);
22        }
23      }),
24    );
25  }
26}
27
28// 2. Lazy loading des modules
29// @Module({
30//   imports: [
31//     LazyModuleLoader, // NestJS lazy loading
32//   ],
33// })
34// class AppModule {}
35//
36// // Dans le service :
37// const { ProvinceModule } = await import('./province.module');
38// const moduleRef = await this.lazyModuleLoader.load(
39//   () => ProvinceModule
40// );
41// const service = moduleRef.get(ProvinceService);
42
43// 3. Pagination - ne pas tout charger en une seule fois
44@Injectable()
45class PaginationService {
46  async findPaginated<T>(
47    query: any,
48    page: number = 1,
49    limit: number = 20
50  ): Promise<{
51    data: T[];
52    total: number;
53    page: number;
54    lastPage: number;
55  }> {
56    const skip = (page - 1) * limit;
57    // const [data, total] = await repo.findAndCount({
58    //   skip, take: limit,
59    //   order: { createdAt: 'DESC' },
60    // });
61
62    const total = 100; // simulation
63    const data = [] as T[];
64
65    return {
66      data,
67      total,
68      page,
69      lastPage: Math.ceil(total / limit),
70    };
71  }
72}
73
74// 4. Connection pooling
75const databaseConfig = {
76  type: 'postgres',
77  host: 'localhost',
78  port: 5432,
79  // Pool configuration
80  extra: {
81    max: 20,        // Max de connexions dans le pool
82    min: 5,         // Min de connexions
83    idle: 10000,    // Timeout d'inactivité (ms)
84    acquire: 30000, // Timeout d'acquisition de connexion
85  },
86};
87
88// 5. Compression des réponses
89// import * as compression from 'compression';
90// app.use(compression());
91// Réduit la taille des réponses de 60 à 80 % !
92

Tu 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. 1. Le stress testing dans le contexte des performances signifie :

  2. 2. L'edge caching dans un CDN signifie :

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les étapes des tests de charge de la plus légère :

  • Clique dans l'ordre

    Ordonne les métriques de performance clés dans l'ordre des quatre signaux d'or du SRE de Google :