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

PROJEKT - system optymalizacji wydajności

5 min czytania
W tej lekcji6

Ten moduł pokazał kilka narzędzi: cache w pamięci procesu i w Redisie, indeksy w bazie, kompresję odpowiedzi, ograniczanie liczby żądań. Projekt jest miejscem, w którym trzeba zdecydować, którego użyć - a to zupełnie inna umiejętność niż znajomość składni.

Zbudujesz API zarządzania legionem, które radzi sobie z ruchem większym, niż wytrzymałaby wersja naiwna, i potrafi pokazać, dlaczego sobie radzi.

Jedna zasada przed wszystkimi innymi

Optymalizacja bez pomiaru to zgadywanie. Brzmi banalnie, a jest najczęściej łamaną regułą w tej dziedzinie: dodaje się cache tam, gdzie akurat przyszło do głowy, i odnotowuje poprawę, której nikt nie zmierzył.

Porządek pracy jest zawsze ten sam:

  1. Zmierz - ile trwa żądanie, ile zajmuje pamięć, ile zapytań idzie do bazy.
  2. Znajdź wąskie gardło - jedno, to najkosztowniejsze.
  3. Zastosuj najtańszą poprawkę, która je usuwa.
  4. Zmierz ponownie - i sprawdź, czy poprawka zadziałała, a nie tylko przesunęła problem.

Dlatego projekt zaczyna się od pomiaru, nie od cache'a.

Krok 1 - endpoint z metrykami

Pierwszą rzeczą, jaką wystawisz, jest podgląd stanu procesu:

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() zwraca kilka liczb i warto wiedzieć, czym się różnią. heapUsed to pamięć faktycznie zajęta przez obiekty twojej aplikacji - ta rośnie, gdy trzymasz w cache'u za dużo. heapTotal to pamięć, którą silnik V8 zarezerwował na stertę; jest zawsze większa i zmienia się skokowo. rss (Resident Set Size) to całość pamięci procesu widziana przez system operacyjny - sterta, stos, kod i bufory razem.

Najważniejsza jest różnica między heapUsed a rss. Gdy rośnie sam heapUsed, gromadzisz obiekty - zwykle w jakimś cache'u bez limitu. Gdy rośnie rss, a heapUsed stoi, wyciek jest poza stertą: bufory, otwarte połączenia, biblioteki natywne.

process.uptime() podaje, ile sekund działa proces. Sam w sobie mało mówi, ale w zestawieniu z resztą owszem: zużycie pamięci rosnące liniowo z czasem działania to wyciek, a nie obciążenie.

Krok 2 - cache w Redisie

Cache w pamięci procesu, który poznałeś wcześniej, ma jedną wadę: każda instancja aplikacji ma własny. Przy dwóch instancjach ten sam odczyt trafia do bazy dwa razy, a unieważnienie w jednej nie dociera do drugiej. Redis to naprawia:

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

Kolejność zapisu jest stała: CacheModule.register({ otwiera konfigurację, store: redisStore, podmienia magazyn z pamięci procesu na Redisa, host: 'localhost', i port: 6379, wskazują, gdzie ten Redis stoi, a ttl: 300 }) ustala domyślny czas życia wpisu i domyka.

Kluczowy jest store. Bez niego CacheModule działa dalej, tylko w pamięci procesu - i to jest podstępne, bo lokalnie wszystko wygląda dobrze, a problem pojawia się dopiero po uruchomieniu drugiej instancji na produkcji.

Zwróć uwagę, że host i port stoją tu bezpośrednio w obiekcie konfiguracji. W nowszych wersjach cache-manager zagnieżdża się je w osobnym polu połączenia - sprawdź wersję w swoim package.json, zanim skopiujesz cudzy przykład.

Krok 3 - baza danych

Cache przyspiesza powtarzalne odczyty. Zapytania, których nie da się zbuforować, trzeba naprawić u źródła:

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// w repozytorium
12findActiveByCohort(cohortId: number) {
13  return this.repo.find({
14    where: { cohortId, isActive: true },
15    select: ['id', 'name', 'rank'],
16    take: 50,
17  });
18}

Trzy poprawki naraz, każda o innym działaniu. Indeks złożony na kolumnach, po których naprawdę filtrujesz, zamienia przegląd całej tabeli w skok do konkretnych wierszy. select pobiera tylko potrzebne kolumny - bez niego ściągasz z bazy pola, których nikt nie użyje, wraz z całą ich objętością. take ogranicza liczbę wierszy; endpoint bez limitu działa świetnie do dnia, w którym ktoś ma ich sto tysięcy.

Zanim dodasz indeks, sprawdź plan zapytania przez EXPLAIN. Indeks na kolumnie, po której nikt nie filtruje, nie przyspiesza odczytu, a spowalnia każdy zapis.

Krok 4 - warstwa wyjściowa

Na koniec dwie rzeczy działające na brzegu aplikacji:

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

compression() pakuje odpowiedzi gzipem - przy listach w formacie JSON to zwykle kilkukrotne zmniejszenie transferu, kosztem odrobiny procesora. Rate limiting z ThrottlerModule działa z drugiej strony: nie przyspiesza obsługi, tylko ogranicza liczbę żądań, które w ogóle przyjmujesz.

Te dwa mechanizmy warto rozumieć jako parę przeciwieństw. Kompresja i cache sprawiają, że jedno żądanie kosztuje mniej. Ograniczanie liczby żądań sprawia, że żądań jest mniej. Gdy system się dławi, zawsze masz do wyboru te dwie drogi - i zwykle potrzebujesz obu.

Co oddajesz

Projekt jest gotowy, gdy zawiera:

  1. Endpoint /metrics z heapUsed, heapTotal, rss i uptime.
  2. Cache w Redisie przez CacheModule.register ze store: redisStore, z unieważnianiem przy zapisie.
  3. Indeksy na kolumnach filtrowanych oraz select i take w zapytaniach listujących.
  4. Kompresję odpowiedzi i ograniczanie liczby żądań na endpointach zapisu.
  5. Pomiar przed i po - liczby dla co najmniej jednego endpointu, z opisem, co się zmieniło.

Piąty punkt jest najważniejszy i najczęściej pomijany. Bez niego oddajesz zbiór technik, a nie optymalizację - bo optymalizacja to różnica między dwoma pomiarami a nie lista zastosowanych narzędzi.

Prześlij link do repozytorium, gdy skończysz.

Kod do tej lekcji: src/performance-project.ts
1// PROJEKT: System Optymalizacji Wydajnosci Legionu
2import { Injectable, Inject, Module, Logger } from '@nestjs/common';
3import { CACHE_MANAGER } from '@nestjs/cache-manager';
4import { Cache } from 'cache-manager';
5
6// === KOMPLETNY SYSTEM WYDAJNOSCI ===
7
8// 1. Cache Service z metrykami
9@Injectable()
10export class LegionCacheService {
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 agregujacy wszystkie metryki
92@Injectable()
93export class LegionHealthService {
94  constructor(
95    private cacheService: LegionCacheService,
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

Widzisz błąd w tej lekcji?

Zadania praktyczne w grze

  • Edytor kodu

    Stwórz endpoint /metrics zwracający heapUsed, heapTotal, rss i uptime

  • Układanie w pionie

    Ułóż poprawną konfigurację CacheModule z Redis store:

Przydatne artykuły