Kurs NestJS · Moduł 8: Cache i wydajność
PROJEKT - system optymalizacji wydajności
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:
- Zmierz - ile trwa żądanie, ile zajmuje pamięć, ile zapytań idzie do bazy.
- Znajdź wąskie gardło - jedno, to najkosztowniejsze.
- Zastosuj najtańszą poprawkę, która je usuwa.
- 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:
- Endpoint
/metricszheapUsed,heapTotal,rssiuptime. - Cache w Redisie przez
CacheModule.registerzestore: redisStore, z unieważnianiem przy zapisie. - Indeksy na kolumnach filtrowanych oraz
selectitakew zapytaniach listujących. - Kompresję odpowiedzi i ograniczanie liczby żądań na endpointach zapisu.
- 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}
113Widzisz 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: