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

Memory Management - optymalizacja magazynów kastrum

10 min czytania
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:

  1. async/await zamiast operacji synchronicznych, np. fs.promises zamiast readFileSync,
  2. przeniesienie ciężkich obliczeń do kolejki Bull (dziś BullMQ) obsługiwanej przez osobny proces,
  3. worker threads z node:worker_threads dla zadań obciążających CPU,
  4. 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:

  1. monitoruj rosnące zużycie pamięci (process.memoryUsage),
  2. wykonaj heap dump i przeanalizuj retencję obiektów,
  3. zidentyfikuj niezwolnione referencje i listenery,
  4. 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!
95

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. Event loop blocking w Node.js powoduje:

  2. 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:

Przydatne artykuły