Kurs NestJS · Moduł 8: Cache i wydajność
Performance Optimization - zwiększanie prędkości kohorty
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%!
92Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Stress testing w kontekście wydajności oznacza:
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: