Kurs NestJS · Moduł 8: Cache i wydajność
Memory Management - optymalizacja magazynów kastrum
W tej lekcji5
Zarządco magazynów! Konsul Caesar.js zauważył, że nasz fort jest przeciążony: po tygodniu pracy proces zajmuje dwa razy więcej pamięci niż po starcie, a legion ma coraz mniej miejsca na manewry. W końcu kontener dostaje limit pamięci i ginie w środku dnia. Czas nauczyć się zarządzania pamięcią w Node.js.
Czym jest zarządzanie pamięcią w świecie legionariuszy?
Wyobraź sobie aplikację jako system magazynów:
- heap - główny magazyn, gdzie przechowywane są tributy (obiekty),
- stack - formacja, w której tymczasowo stoją wywołania funkcji i ich zmienne lokalne,
- garbage collector - oddział sprzątający przedmioty, do których nikt już nie sięga,
- memory leaks - tributy, o których zapomniano, ale wciąż ktoś trzyma do nich klucz.
Dobre zarządzanie pamięcią to sprawny, szybki fort.
Monitoring pamięci
process.memoryUsage() zwraca pięć liczb: rss to cała pamięć procesu, heapTotal - zarezerwowana sterta, heapUsed - jej zajęta część, external - pamięć obiektów C++ powiązanych z JavaScriptem, a arrayBuffers - bufory. Serwis monitorujący próbkuje je co 10 sekund:
1// memory-monitor.service.ts
2import { Injectable } from '@nestjs/common';
3import { Cron } from '@nestjs/schedule';
4import { getHeapStatistics } from 'node:v8';
5
6@Injectable()
7export class MemoryMonitorService {
8 private memoryHistory: Array<{
9 timestamp: Date;
10 usage: NodeJS.MemoryUsage;
11 }> = [];
12
13 private readonly MEMORY_THRESHOLD_MB = 512; // Alert przy 512MB
14 private readonly HISTORY_SIZE = 100;
15 // Prawdziwy limit sterty: domyślny albo z flagi --max-old-space-size
16 private readonly HEAP_LIMIT = getHeapStatistics().heap_size_limit;
17
18 @Cron('*/10 * * * * *') // Co 10 sekund
19 checkMemoryUsage(): void {
20 const usage = process.memoryUsage();
21
22 this.memoryHistory.push({ timestamp: new Date(), usage });
23
24 // Zachowaj tylko ostatnie 100 pomiarów
25 if (this.memoryHistory.length > this.HISTORY_SIZE) {
26 this.memoryHistory.shift();
27 }
28
29 // Sprawdź czy pamięć nie przekracza progu
30 const heapUsedMB = usage.heapUsed / 1024 / 1024;
31 if (heapUsedMB > this.MEMORY_THRESHOLD_MB) {
32 console.warn(`Wysokie użycie pamięci: ${heapUsedMB.toFixed(2)}MB`);
33 this.suggestOptimizations(usage);
34 }
35
36 // Loguj co minutę
37 if (new Date().getSeconds() === 0) {
38 this.logMemoryStatus(usage);
39 }
40 }@Cron wymaga ScheduleModule.forRoot(). Prawdziwy limit sterty podaje getHeapStatistics() z node:v8: heapTotal to tylko bieżąca rezerwacja, którą V8 sam powiększa, więc pierwsza wersja, porównując z nią, alarmowała przy zupełnie zdrowym procesie.
Co minutę serwis wypisuje stan i liczy trend:
1 private logMemoryStatus(usage: NodeJS.MemoryUsage): void {
2 const formatMB = (bytes: number) => (bytes / 1024 / 1024).toFixed(2);
3
4 console.log('Status magazynów kastrum:');
5 console.log(`Heap używany: ${formatMB(usage.heapUsed)}MB`);
6 console.log(`Heap całkowity: ${formatMB(usage.heapTotal)}MB`);
7 console.log(`RSS (pamięć fizyczna): ${formatMB(usage.rss)}MB`);
8 console.log(`Zewnętrzna: ${formatMB(usage.external)}MB`);
9
10 // Oblicz wzrost pamięci
11 const growthInfo = this.calculateMemoryGrowth();
12 if (growthInfo.isGrowing) {
13 console.log(`Trend: +${growthInfo.growthRate.toFixed(2)}MB/min`);
14 }
15 }
16
17 private calculateMemoryGrowth(): { isGrowing: boolean; growthRate: number } {
18 if (this.memoryHistory.length < 10) return { isGrowing: false, growthRate: 0 };
19
20 const recent = this.memoryHistory.slice(-10);
21 const oldest = recent[0];
22 const newest = recent[recent.length - 1];
23
24 const timeDiffMin = (newest.timestamp.getTime() - oldest.timestamp.getTime()) / 60000;
25 const memoryDiffMB = (newest.usage.heapUsed - oldest.usage.heapUsed) / 1024 / 1024;
26
27 const growthRate = memoryDiffMB / timeDiffMin;
28
29 return {
30 isGrowing: growthRate > 1, // Więcej niż 1MB/min
31 growthRate,
32 };
33 }Trend porównuje najstarszy i najnowszy z dziesięciu ostatnich pomiarów. Wzrost o ponad 1 MB na minutę, który nie spada, to pierwszy objaw wycieku.
Sugestie i alerty sprawdzają te same progi:
1 private suggestOptimizations(usage: NodeJS.MemoryUsage): void {
2 console.log('Sugestie optymalizacji:');
3
4 if (usage.heapUsed / this.HEAP_LIMIT > 0.9) {
5 console.log(' - Sterta blisko limitu: zrób heap snapshot i szukaj wycieku');
6 }
7
8 if (usage.external > 50 * 1024 * 1024) {
9 console.log(' - Sprawdź bufory i strumienie - może za dużo danych w pamięci');
10 }
11
12 console.log(" - Sprawdź potencjalne memory leaks w cache'ach");
13 console.log(' - Rozważ zmniejszenie TTL dla danych tymczasowych');
14 }
15
16 getMemoryReport() {
17 const current = process.memoryUsage();
18 const formatMB = (bytes: number) => (bytes / 1024 / 1024).toFixed(2);
19
20 return {
21 current: {
22 heapUsed: formatMB(current.heapUsed) + 'MB',
23 heapTotal: formatMB(current.heapTotal) + 'MB',
24 heapLimit: formatMB(this.HEAP_LIMIT) + 'MB',
25 rss: formatMB(current.rss) + 'MB',
26 external: formatMB(current.external) + 'MB',
27 },
28 trend: this.calculateMemoryGrowth(),
29 history: this.memoryHistory.slice(-20), // Ostatnie 20 pomiarów
30 alerts: this.generateAlerts(current),
31 };
32 }
33
34 private generateAlerts(usage: NodeJS.MemoryUsage): string[] {
35 const alerts = [];
36 const heapUsedMB = usage.heapUsed / 1024 / 1024;
37
38 if (heapUsedMB > this.MEMORY_THRESHOLD_MB) {
39 alerts.push(`Wysokie użycie heap: ${heapUsedMB.toFixed(2)}MB`);
40 }
41
42 if (usage.heapUsed / this.HEAP_LIMIT > 0.85) {
43 alerts.push('Sterta zajmuje ponad 85% limitu - grozi błąd out of memory');
44 }
45
46 const growthInfo = this.calculateMemoryGrowth();
47 if (growthInfo.isGrowing && growthInfo.growthRate > 5) {
48 alerts.push(`Szybki wzrost pamięci: ${growthInfo.growthRate.toFixed(2)}MB/min`);
49 }
50
51 return alerts;
52 }Stara wersja radziła tu „uruchom global.gc()”. To zła rada: V8 sprząta sam, a ręczne GC tylko zatrzymuje aplikację. Pełne sprzątanie ma jedno uczciwe zastosowanie - diagnostykę:
1 // Diagnostyka: czy pamięć wraca po pełnym GC? (wymaga flagi --expose-gc)
2 forceGarbageCollection(): { before: number; after: number; freed: number } {
3 const beforeMB = process.memoryUsage().heapUsed / 1024 / 1024;
4
5 if (global.gc) {
6 global.gc();
7 const afterMB = process.memoryUsage().heapUsed / 1024 / 1024;
8 const freedMB = beforeMB - afterMB;
9
10 console.log(`Garbage collection: zwolniono ${freedMB.toFixed(2)}MB`);
11
12 return {
13 before: beforeMB,
14 after: afterMB,
15 freed: freedMB,
16 };
17 } else {
18 console.warn('Garbage collection niedostępny - uruchom z flagą --expose-gc');
19 return { before: beforeMB, after: beforeMB, freed: 0 };
20 }
21 }
22}Jeśli po wymuszonym GC pamięć nie spada, obiekty wciąż ktoś trzyma, czyli masz wyciek. Flagę --expose-gc włączaj tylko przy takim śledztwie.
Event loop - jedna droga przez obóz
JavaScript w Node.js działa w jednym wątku, który obsługuje zdarzenia w pętli. Dokumentacja Node.js wymienia jej fazy w tej kolejności: timers (setTimeout, setInterval), pending callbacks (odroczone wywołania I/O, dawniej nazywane I/O callbacks), idle i prepare, poll (nowe zdarzenia I/O), check (setImmediate) i close callbacks. Gdy jedna funkcja liczy synchronicznie przez sekundę, event loop jest zablokowany i zatrzymuje obsługę wszystkich innych żądań.
Opóźnienie pętli mierzy monitorEventLoopDelay() z perf_hooks, który zbiera pomiary w histogram:
1// event-loop.monitor.ts
2import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
3import { Interval } from '@nestjs/schedule';
4import { monitorEventLoopDelay } from 'node:perf_hooks';
5
6@Injectable()
7export class EventLoopMonitor implements OnModuleDestroy {
8 private readonly logger = new Logger(EventLoopMonitor.name);
9 private readonly histogram = monitorEventLoopDelay({ resolution: 20 });
10
11 constructor() {
12 this.histogram.enable();
13 }
14
15 @Interval(10000) // Co 10 sekund
16 check() {
17 const p99 = this.histogram.percentile(99) / 1e6; // nanosekundy na milisekundy
18 if (p99 > 100) {
19 this.logger.warn(`Event loop opóźniony: p99 = ${p99.toFixed(1)} ms`);
20 }
21 this.histogram.reset();
22 }
23
24 onModuleDestroy() {
25 this.histogram.disable();
26 }
27}Histogram podaje wartości w nanosekundach, stąd dzielenie przez 1e6. W teście 200 ms blokady dało p99 równe 208 ms.
Techniki unikania blokowania, od najprostszej:
- async/await zamiast operacji synchronicznych, np.
fs.promiseszamiastreadFileSync, - przeniesienie ciężkich obliczeń do kolejki Bull (dziś BullMQ) obsługiwanej przez osobny proces,
- worker threads z
node:worker_threadsdla zadań obciążających CPU, - clustering z PM2, czyli kilka procesów na kilku rdzeniach.
Wycieki - tributy, o których zapomniano
Najczęstszą przyczyną wycieku są niezwolnione referencje: listener dodany przy każdym żądaniu i nigdy nieusunięty, domknięcie trzymające duży obiekt, rosnąca bez końca mapa albo zapomniany timer. Diagnoza idzie krokami:
- monitoruj rosnące zużycie pamięci (
process.memoryUsage), - wykonaj heap dump i przeanalizuj retencję obiektów,
- zidentyfikuj niezwolnione referencje i listenery,
- napraw wyciek i zweryfikuj stabilność pamięci.
Analiza heap dumpa pokazuje, które obiekty zajmują pamięć i kto je trzyma; narzędzia do zrzutów poznasz w lekcji o profilowaniu.
Optymalizacja cache'a i kolekcji
Klasyczny wyciek to cache bez limitu. Nasz ma limit liczby elementów i pamięci:
1// memory-optimized-cache.service.ts
2import { Injectable } from '@nestjs/common';
3import { Cron } from '@nestjs/schedule';
4
5@Injectable()
6export class MemoryOptimizedCacheService {
7 private cache = new Map<string, {
8 data: any;
9 timestamp: number;
10 accessCount: number;
11 lastAccess: number;
12 size: number;
13 timer: NodeJS.Timeout;
14 }>();
15
16 private maxSize = 1000; // Maksymalna liczba elementów
17 private maxMemoryMB = 100; // Maksymalna pamięć cache'a
18 private currentMemoryBytes = 0;
19
20 set(key: string, value: any, ttl: number = 300000): void {
21 const size = this.estimateSize(value);
22
23 // Usuń stary element jeśli istnieje - razem z jego timerem
24 this.delete(key);
25
26 // Zwalniaj miejsce, dopóki nowy element się nie zmieści
27 while (this.cache.size > 0 && (this.cache.size >= this.maxSize ||
28 (this.currentMemoryBytes + size) > this.maxMemoryMB * 1024 * 1024)) {
29 this.evictOldest();
30 }
31
32 // Ustaw TTL; unref() nie trzyma procesu przy życiu
33 const timer = setTimeout(() => this.delete(key), ttl);
34 timer.unref();
35
36 this.cache.set(key, {
37 data: value,
38 timestamp: Date.now(),
39 accessCount: 0,
40 lastAccess: Date.now(),
41 size,
42 timer,
43 });
44
45 this.currentMemoryBytes += size;
46 }
47
48 get(key: string): any {
49 const item = this.cache.get(key);
50 if (!item) return null;
51
52 // Aktualizuj statystyki dostępu
53 item.accessCount++;
54 item.lastAccess = Date.now();
55
56 return item.data;
57 }
58
59 delete(key: string): boolean {
60 const item = this.cache.get(key);
61 if (item) {
62 clearTimeout(item.timer);
63 this.currentMemoryBytes -= item.size;
64 return this.cache.delete(key);
65 }
66 return false;
67 }To wersja poprawiona. Pierwsza tworzyła nowy timer przy każdym set(), nie kasując starego - stary odpalał później i usuwał świeżą wartość. delete() czyści teraz timer, a unref() sprawia, że czekający timer nie blokuje zamknięcia procesu. Pętla while zwalnia tyle miejsca, ile trzeba, a nie jeden element.
Strategię eksmisji dobiera stan pamięci:
1 // Eksmisja według różnych strategii
2 private evictOldest(): void {
3 if (this.cache.size === 0) return;
4
5 const strategy = this.getEvictionStrategy();
6
7 switch (strategy) {
8 case 'LRU': // Least Recently Used
9 this.evictLRU();
10 break;
11 case 'LFU': // Least Frequently Used
12 this.evictLFU();
13 break;
14 case 'SIZE': // Największe elementy pierwsze
15 this.evictLargest();
16 break;
17 default:
18 this.evictOldestByTime();
19 }
20 }
21
22 private getEvictionStrategy(): string {
23 // Inteligentny wybór strategii na podstawie stanu pamięci
24 const memoryPressure = this.currentMemoryBytes / (this.maxMemoryMB * 1024 * 1024);
25
26 if (memoryPressure > 0.9) {
27 return 'SIZE'; // Pod wysokim ciśnieniem pamięci - usuń duże elementy
28 } else if (this.cache.size > this.maxSize * 0.8) {
29 return 'LRU'; // Dużo elementów - usuń najdawniej używane
30 } else {
31 return 'LFU'; // Normalnie - usuń najmniej popularne
32 }
33 }Pod presją pamięci usuwamy największe elementy, przy dużej liczbie - najdawniej używane (LRU), a normalnie - najrzadziej używane (LFU).
Cztery strategie różnią się tylko kryterium:
1 private evictLRU(): void {
2 let oldestKey = '';
3 let oldestTime = Infinity;
4
5 this.cache.forEach((item, key) => {
6 if (item.lastAccess < oldestTime) {
7 oldestTime = item.lastAccess;
8 oldestKey = key;
9 }
10 });
11
12 if (oldestKey) {
13 console.log(`LRU eviction: ${oldestKey}`);
14 this.delete(oldestKey);
15 }
16 }
17
18 private evictLFU(): void {
19 let leastUsedKey = '';
20 let leastUsedCount = Infinity;
21
22 this.cache.forEach((item, key) => {
23 if (item.accessCount < leastUsedCount) {
24 leastUsedCount = item.accessCount;
25 leastUsedKey = key;
26 }
27 });
28
29 if (leastUsedKey) {
30 console.log(`LFU eviction: ${leastUsedKey}`);
31 this.delete(leastUsedKey);
32 }
33 }
34
35 private evictLargest(): void {
36 let largestKey = '';
37 let largestSize = 0;
38
39 this.cache.forEach((item, key) => {
40 if (item.size > largestSize) {
41 largestSize = item.size;
42 largestKey = key;
43 }
44 });
45
46 if (largestKey) {
47 console.log(`SIZE eviction: ${largestKey} (${largestSize} bytes)`);
48 this.delete(largestKey);
49 }
50 }
51
52 private evictOldestByTime(): void {
53 let oldestKey = '';
54 let oldestTime = Infinity;
55
56 this.cache.forEach((item, key) => {
57 if (item.timestamp < oldestTime) {
58 oldestTime = item.timestamp;
59 oldestKey = key;
60 }
61 });
62
63 if (oldestKey) {
64 console.log(`TIME eviction: ${oldestKey}`);
65 this.delete(oldestKey);
66 }
67 }Poszukiwania startują od Infinity. Pierwsza wersja zaczynała od Date.now(), więc elementy dodane w tej samej milisekundzie nigdy nie były starsze i nic nie znikało: w teście cache z limitem 3 urósł do 10 elementów.
Na koniec szacowanie rozmiaru, statystyki i sprzątanie:
1 private estimateSize(obj: any): number {
2 // Przybliżone obliczenie rozmiaru obiektu w pamięci
3 const jsonString = JSON.stringify(obj);
4 return jsonString.length * 2; // UTF-16 = 2 bytes per character
5 }
6
7 getStats() {
8 const memoryUsageMB = this.currentMemoryBytes / 1024 / 1024;
9 const avgItemSize = this.cache.size > 0 ? this.currentMemoryBytes / this.cache.size : 0;
10
11 let totalAccesses = 0;
12 this.cache.forEach(item => {
13 totalAccesses += item.accessCount;
14 });
15
16 return {
17 size: this.cache.size,
18 maxSize: this.maxSize,
19 memoryUsageMB: memoryUsageMB.toFixed(2),
20 maxMemoryMB: this.maxMemoryMB,
21 avgItemSize: Math.round(avgItemSize),
22 totalAccesses,
23 fillRatio: (this.cache.size / this.maxSize * 100).toFixed(1) + '%',
24 memoryRatio: (memoryUsageMB / this.maxMemoryMB * 100).toFixed(1) + '%',
25 };
26 }
27
28 // Czyszczenie przeterminowanych elementów
29 @Cron('0 */5 * * * *') // Co 5 minut (pierwsze pole to sekundy)
30 cleanupExpired(): void {
31 const now = Date.now();
32 const toDelete = [];
33
34 this.cache.forEach((item, key) => {
35 // Usuń elementy starsze niż godzina bez dostępu
36 if (now - item.lastAccess > 3600000) {
37 toDelete.push(key);
38 }
39 });
40
41 toDelete.forEach(key => {
42 this.delete(key);
43 });
44
45 if (toDelete.length > 0) {
46 console.log(`Wyczyszczono ${toDelete.length} przestarzałych elementów cache'a`);
47 }
48 }
49}JSON.stringify daje tylko zgrubny rozmiar, bo obiekt w pamięci zajmuje inaczej niż jego tekst. Wzorzec 0 */5 * * * * znaczy co 5 minut; stary */5 * * * * * odpalał się co 5 sekund, bo pierwsze pole to sekundy.
Polecam Ci gotową bibliotekę lru-cache albo prosty trik z edytora obok: Map pamięta kolejność wstawiania, więc najstarszy wpis usuniesz w stałym czasie. Własny cache pisz tylko po to, żeby zrozumieć mechanizm. W następnej lekcji ograniczymy ruch, zanim zaleje magazyny.
Pamiętaj: w dobrze prowadzonym magazynie każdy tribut ma datę przydatności i miejsce na półce, a nie leży zapomniany w kącie.
Kod do tej lekcji: src/memory-management.ts
1// Memory Management - Optymalizacja Magazynow Kohorty
2import { Injectable, Logger } from '@nestjs/common';
3
4// 1. Monitoring pamieci
5@Injectable()
6export class MemoryMonitor {
7 private readonly logger = new Logger('MemoryMonitor');
8
9 getMemoryUsage() {
10 const usage = process.memoryUsage();
11 return {
12 rss: Math.round(usage.rss / 1024 / 1024), // MB
13 heapTotal: Math.round(usage.heapTotal / 1024 / 1024),
14 heapUsed: Math.round(usage.heapUsed / 1024 / 1024),
15 external: Math.round(usage.external / 1024 / 1024),
16 heapPercent: Math.round(
17 (usage.heapUsed / usage.heapTotal) * 100
18 ),
19 };
20 }
21
22 // Sprawdz czy pamiec jest w normie
23 checkHealth(thresholdMB: number = 512): {
24 healthy: boolean;
25 message: string;
26 } {
27 const mem = this.getMemoryUsage();
28 const healthy = mem.heapUsed < thresholdMB;
29
30 return {
31 healthy,
32 message: healthy
33 ? `Memory OK: ${mem.heapUsed}MB / ${thresholdMB}MB`
34 : `WARNING: ${mem.heapUsed}MB exceeds ${thresholdMB}MB!`,
35 };
36 }
37}
38
39// 2. Unikanie wyciekow pamieci
40
41// ZLE - wyciek pamieci (tablica rosnie bez konca):
42// class BadService {
43// private cache: any[] = [];
44// addToCache(data: any) {
45// this.cache.push(data); // NIGDY nie czyszczone!
46// }
47// }
48
49// DOBRZE - cache z limitem:
50class BoundedCache<T> {
51 private items: Map<string, { data: T; addedAt: number }> = new Map();
52 private maxSize: number;
53 private ttlMs: number;
54
55 constructor(maxSize = 1000, ttlMs = 300000) {
56 this.maxSize = maxSize;
57 this.ttlMs = ttlMs;
58 }
59
60 set(key: string, data: T) {
61 // Usun najstarsze jesli za duzo
62 if (this.items.size >= this.maxSize) {
63 const oldest = this.items.keys().next().value;
64 this.items.delete(oldest);
65 }
66 this.items.set(key, { data, addedAt: Date.now() });
67 }
68
69 get(key: string): T | null {
70 const item = this.items.get(key);
71 if (!item) return null;
72
73 // Sprawdz TTL
74 if (Date.now() - item.addedAt > this.ttlMs) {
75 this.items.delete(key);
76 return null;
77 }
78 return item.data;
79 }
80
81 get size() { return this.items.size; }
82}
83
84// 3. Garbage Collector
85// Node.js automatycznie zarzadza pamiecia (GC)
86// Ale mozesz wymuszic: global.gc() (z flaga --expose-gc)
87
88// 4. Streams zamiast ladowania calych plikow do pamieci
89// ZLE: const data = fs.readFileSync('huge-file.csv');
90// DOBRZE: const stream = fs.createReadStream('huge-file.csv');
91
92// 5. WeakMap dla tymczasowych danych
93// const cache = new WeakMap();
94// Obiekty w WeakMap moga byc garbage collected!
95Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Event loop blocking w Node.js powoduje:
2. Worker threads w Node.js najlepiej używać do:
To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Napisz serwis monitorujący opóźnienia event loop i logujący ostrzeżenia
- Układanie w pionie
Uporządkuj fazy event loop Node.js:
- Klikanie w kolejności
Ułóż techniki unikania blokowania event loop od najprostszej:
- Edytor kodu
Napisz serwis zbierający metryki pamięci (heapUsed, rss, external)
- Układanie w pionie
Uporządkuj kroki diagnostyki memory leak:
- Układanie w pionie
Ułóż warstwy cache'owania od najbliższej użytkownikowi: