Kurs NestJS · Moduł 8: Cache i wydajność

Performance Optimization - zwiększanie prędkości kohorty

8 min czytania
W tej lekcji6

Wieczorem, gdy kohorty zdają raporty, fort zwalnia: odpowiedź, która rano trwa 80 ms, po zmroku ciągnie się sekundami. Konsul Caesar.js chce przyspieszenia, ale nikt nie wie, co spowalnia - baza, procesor czy pamięć. Pierwsza zasada inżyniera legionowego brzmi więc: najpierw zmierz, potem optymalizuj.

Czym jest optymalizacja wydajności?

Wyobraź sobie aplikację jako obóz legionu:

  • siła legionistów (CPU) - musi wystarczyć na wszystkie zadania,
  • magazyny (pamięć) - muszą być dobrze zarządzane,
  • tabularium (baza danych) - musi być uporządkowane,
  • kohorty (procesy) - muszą sprawnie współpracować,
  • drogi i posłańcy (operacje I/O) - muszą być przejezdne.

Optymalizacja to sztuka harmonijnego dostrojenia wszystkich tych elementów.

Cztery sygnały

Zanim cokolwiek zmienisz, ustal, co mierzysz. Książka Google o SRE wymienia cztery złote sygnały, w tej kolejności:

  • latencja - czas odpowiedzi, podawany jako percentyle p95 i p99,
  • ruch - przepustowość, na przykład żądania na sekundę,
  • błędy - odsetek nieudanych żądań,
  • nasycenie - zużycie zasobów, czyli CPU i pamięci.

p95 równe 200 ms znaczy, że 95% żądań kończy się szybciej niż w 200 ms. Średnia ukrywa pechowców z ogona: użytkownik czekający 10 sekund ginie w niej wśród tysiąca szybkich odpowiedzi.

Cztery próby obciążenia

Sygnały odczytujesz podczas testów obciążeniowych, od najlżejszego do najcięższego:

  • smoke test - minimalne obciążenie, sprawdza, czy system w ogóle działa,
  • load test - oczekiwany, typowy ruch,
  • stress test - obciążenie powyżej oczekiwanego, szukające punktu krytycznego,
  • spike test - nagły, krótki i ogromny skok ruchu.

Tak dzieli je dokumentacja narzędzia k6; samo strzelanie żądaniami poznasz w lekcji o profilowaniu.

Profiling - diagnoza problemów

Wewnątrz aplikacji czas mierzy moduł perf_hooks z Node.js: performance.mark() stawia znacznik na osi czasu, performance.measure() liczy odstęp między dwoma znacznikami, a PerformanceObserver dostaje każdy nowy pomiar. Na tym zbudujemy serwis profilujący:

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    // Zachowaj tylko ostatnie 100 pomiarów
35    if (measurements.length > 100) {
36      measurements.shift();
37    }
38
39    // Loguj wolne operacje
40    if (duration > 1000) {
41      console.warn(`Wolna operacja: ${name} - ${duration.toFixed(2)}ms`);
42    }
43  }

Obserwator przekazuje pomiary do recordMeasurement(), który trzyma ostatnie 100 wyników każdej operacji i ostrzega o dłuższych niż sekunda.

Najwygodniej mierzy się dekoratorem - statyczną metodą, która owija oryginalną metodę pomiarem:

1  // Dekorator do mierzenia czasu wykonania
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; // osobne znaczniki dla równoległych wywołań
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          // Znaczniki zostają w globalnej osi czasu, dopóki ich nie usuniemy
26          performance.clearMarks(startMark);
27          performance.clearMarks(endMark);
28          performance.clearMeasures(measureName);
29          performance.clearMeasures(`${measureName}-error`);
30        }
31      };
32    };
33  }

Pierwsza wersja budowała nazwy znaczników z Date.now(), więc dwa wywołania w tej samej milisekundzie nadpisywały sobie znaczniki; licznik callId to naprawia. Blok finally sprząta, bo znaczniki bez clearMarks() rosłyby w serwerze działającym tygodniami jak wyciek pamięci. Obserwator dostał już pomiar, więc nic nie tracimy.

Pomiar ręczny przydaje się, gdy mierzysz tylko fragment metody:

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() zwraca świeży pomiar - nie szukamy go w historii
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  }

Wcześniej serwis szukał pomiaru przez getEntriesByName(name)[0], a to zwraca najstarszy pomiar o tej nazwie - od drugiego wywołania wynik był w kółko ten sam.

Z zebranych liczb robimy statystyki:

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}

p95 liczymy metodą najbliższej rangi: przy 100 pomiarach to 95. wynik po posortowaniu.

W serwisie dekorator stoi nad metodą, a pomiar ręczny otacza wybrany fragment:

1// Użycie w serwisie
2@Injectable()
3export class TributeService {
4  constructor(private profiler: PerformanceProfilerService) {}
5
6  @PerformanceProfilerService.Measure('tribute-search')
7  async findTributes(criteria: any) {
8    // Złożone wyszukiwanie tributów
9    await this.complexDatabaseQuery(criteria);
10    return [];
11  }
12
13  async manualMeasurement() {
14    this.profiler.startMeasurement('manual-operation');
15
16    // Jakaś operacja
17    await this.doSomething();
18
19    const duration = this.profiler.endMeasurement('manual-operation');
20    console.log(`Operacja zajęła: ${duration}ms`);
21  }
22
23  private async complexDatabaseQuery(criteria: any) {
24    await new Promise((r) => setTimeout(r, 20)); // zaślepka zapytania
25  }
26
27  private async doSomething() {
28    await new Promise((r) => setTimeout(r, 30)); // zaślepka pracy
29  }
30}

Dekorator nie zmienia treści findTributes - metoda dalej tylko wyszukuje, a pomiar dzieje się obok.

Database Query Optimization

Najczęstszym winowajcą jest baza. Klasyczny problem N+1 wygląda niewinnie:

1// query-optimizer.service.ts
2@Injectable()
3export class QueryOptimizerService {
4  constructor(
5    @InjectRepository(Tribute) private tributeRepo: Repository<Tribute>,
6  ) {}
7
8  // Zamiast N+1 queries
9  async getBadTributesWithOwners(): Promise<any[]> {
10    const tributes = await this.tributeRepo.find();
11
12    // To generuje N zapytań dodatkowych!
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  }

Jedno zapytanie po listę i po jednym na każdego właściciela: w teście na PostgreSQL 120 tributów kosztowało 121 zapytań.

Rozwiązaniem jest JOIN, który TypeORM zbuduje z opcji relations:

1  // Optymalna wersja z JOIN
2  async getOptimizedTributesWithOwners(): Promise<any[]> {
3    // Jedno zapytanie z 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  }

Ten sam wynik przyszedł jednym zapytaniem, a select pobrał tylko potrzebne kolumny. TypeORM 1.x usunął tablicową składnię relations: ['owner'] - obowiązuje obiekt.

Duże zbiory przetwarzamy partiami, zamiast ładować wszystko do pamięci:

1  // Batch processing dla dużych zbiorów
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        // Oddaj na chwilę event loop innym żądaniom
18        await this.delay(10);
19      }
20    } while (batch.length === batchSize);
21  }

Pauza oddaje event loop innym żądaniom; pierwsza wersja twierdziła, że „daje czas garbage collectorowi”, ale GC działa, kiedy sam uzna za stosowne. Z każdą partią rośnie skip, a baza i tak przechodzi pominięte wiersze, więc przy milionach rekordów lepszy jest kursor:

1  // Paginacja z cursor dla bardzo dużych zbiorów
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 żeby sprawdzić czy są kolejne
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  }

Kursor to id ostatniego zwróconego wiersza, a WHERE id > :cursor korzysta z indeksu klucza głównego, więc setna strona jest tak szybka jak pierwsza. Jeden dodatkowy wiersz mówi, czy istnieje następna strona, bez osobnego COUNT.

Filtry też decydują o szybkości:

1  // Optymalizacja z indeksami
2  async searchTributesOptimized(filters: any) {
3    const qb = this.tributeRepo.createQueryBuilder('tribute');
4
5    // Używaj indeksowanych kolumn w 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    // Użyj indeksu na 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') // Indeks na 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    // Przetwarzanie batch'a
34    console.log(`Przetwarzam ${tributes.length} tributów...`);
35  }
36}

Warunki na indeksowanych kolumnach pozwalają bazie od razu przeskoczyć do właściwych wierszy. limit() jest tu bezpieczny, bo zapytanie nie ma JOIN-ów; przy nich używaj take().

Warstwy szybkości

Najszybsze żądanie to takie, które w ogóle nie dotrze do fortu. Od warstwy najbliższej użytkownikowi:

  • cache przeglądarki - sterowany nagłówkami Cache-Control,
  • CDN, czyli edge cache - kopie treści na serwerach blisko użytkowników, co skraca drogę i latencję,
  • cache aplikacji - Redis z poprzednich lekcji,
  • cache zapytań po stronie bazy danych.

Polecam Ci jedną kolejność pracy: zmierz, popraw największy problem, zmierz ponownie - bez drugiego pomiaru nie wiesz, czy pomogłeś. W kolejnych lekcjach zajmiemy się indeksami, rozkładaniem ruchu i pamięcią.

Pamiętaj: szybka kohorta nie biega szybciej, tylko nie dźwiga zbędnego ciężaru - najpierw go zważ, potem zrzuć.

Kod do tej lekcji: src/performance-optimization.ts
1// Performance Optimization - Zwiekszanie Predkosci Kohorty
2import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
3import { Observable } from 'rxjs';
4import { tap } from 'rxjs/operators';
5
6// 1. Interceptor mierzacy czas odpowiedzi
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 modulow
29// @Module({
30//   imports: [
31//     LazyModuleLoader, // NestJS lazy loading
32//   ],
33// })
34// class AppModule {}
35//
36// // W serwisie:
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 - nie laduj wszystkiego naraz
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; // symulacja
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 polaczen w puli
82    min: 5,         // Min polaczen
83    idle: 10000,    // Timeout bezczynnosci (ms)
84    acquire: 30000, // Timeout uzyskania polaczenia
85  },
86};
87
88// 5. Kompresja odpowiedzi
89// import * as compression from 'compression';
90// app.use(compression());
91// Zmniejsza rozmiar odpowiedzi o 60-80%!
92

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Stress testing w kontekście wydajności oznacza:

  2. 2. Edge caching w CDN oznacza:

Zadania praktyczne w grze

  • Układanie w pionie

    Uporządkuj etapy testowania obciążeniowego od najlżejszego:

  • Klikanie w kolejności

    Ułóż kluczowe metryki wydajności w kolejności czterech złotych sygnałów Google SRE:

Przydatne artykuły