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

Caching - skrytki z zapasami w aplikacji

6 min czytania
W tej lekcji7

Lista prowincji zmienia się raz na kwartał. Zapytanie o nią przychodzi tysiąc razy na minutę - i za każdym razem baza wykonuje tę samą pracę, żeby zwrócić ten sam wynik. Serwer jest obciążony nie dlatego, że robi dużo, tylko dlatego, że robi wciąż to samo.

Legion nie posyłał gońca do Rzymu po każdy bochenek chleba. Trzymał skrytkę z zapasami przy obozie: to, co potrzebne często, leżało pod ręką, a po resztę schodziło się do składu. Cache to taka skrytka - tymczasowe przechowywanie danych dla szybszego dostępu.

Zwróć uwagę na słowo „tymczasowe". Cache nie jest trwałym magazynem - od tego jest baza. Nie jest też narzędziem do kompresji ani do logowania. Dane w nim mają zniknąć, i to jest cecha, nie usterka.

Konfiguracja

CacheModule.register() konfiguruje cache wraz z jego parametrami - nie routing, nie autoryzację, nie logowanie:

1@Module({
2  imports: [
3    CacheModule.register({
4      isGlobal: true,
5      ttl: 300,
6      max: 1000,
7    }),
8  ],
9})
10export class AppModule {}

ttl (Time To Live) oznacza maksymalny czas życia wpisu, po którym jest on automatycznie usuwany - tutaj 300 sekund. Nie jest to liczba dozwolonych odczytów ani czas odpowiedzi; to termin ważności.

max ogranicza liczbę wpisów. Gdy zostanie przekroczona, cache usuwa najdawniej używane - bo pamięć jest skończona, a cache bez limitu prędzej czy później ją wyczerpie.

Ręczny odczyt i zapis

Cache wstrzykujemy po tokenie. Kolejność zapisu: @Inject(CACHE_MANAGER), private cache:, Cache:

1@Injectable()
2export class ProvinceService {
3  constructor(
4    @Inject(CACHE_MANAGER) private cache: Cache,
5    private repo: ProvinceRepository,
6  ) {}
7
8  async findAll() {
9    const cached = await this.cache.get<Province[]>('provinces:all');
10    if (cached) {
11      return cached;
12    }
13
14    const provinces = await this.repo.findAll();
15    await this.cache.set('provinces:all', provinces, 600);
16
17    return provinces;
18  }
19}

Ten schemat nazywa się cache-aside (albo lazy loading) i oznacza, że kod ręcznie pobiera dane z cache i ręcznie je tam zapisuje. Cztery kroki, zawsze w tej kolejności:

  1. Sprawdź cache (get).
  2. Jeśli jest wpis - zwróć go klientowi.
  3. Jeśli brak (miss) - pobierz z bazy.
  4. Zapisz wynik do cache (set) i zwróć.

Nazwa „lazy" bierze się z kroku trzeciego: cache zapełnia się leniwie, dopiero gdy ktoś o dane poprosi. Pierwsze żądanie zawsze idzie do bazy.

Automatycznie, przez dekoratory

Dla zwykłych odczytów nie musisz pisać tych czterech kroków:

1@Controller('provinces')
2export class ProvinceController {
3  @Get()
4  @UseInterceptors(CacheInterceptor)
5  @CacheKey('provinces:all')
6  @CacheTTL(600)
7  findAll() {
8    return this.provinceService.findAll();
9  }
10}

@UseInterceptors(CacheInterceptor) automatycznie cache'uje odpowiedzi endpointu - nie kompresuje ich, nie loguje i nie waliduje.

@CacheTTL(600) to dekorator odpowiadający za czas życia danych z tej metody - sześćset sekund, czyli dziesięć minut, niezależnie od wartości domyślnej. Nie mylić z @CacheKey, który nadaje nazwę wpisu, ani z @UseInterceptors, który dopiero włącza cache'owanie.

Domyślnie klucz powstaje z adresu URL. @CacheKey przydaje się, gdy chcesz go ustalić sam - na przykład po to, żeby móc ten sam wpis później usunąć z serwisu.

Zapis - dwie strategie

Powyższe podejścia zapełniają cache przy odczycie. Można też przy zapisie, i tu istnieją dwa warianty różniące się jednym słowem:

  • Write-through zapisuje do cache i do bazy synchronicznie - aplikacja czeka na oba. Cache nigdy nie jest nieaktualny, ale zapis trwa dłużej.
  • Write-behind zapisuje do cache od razu, a do bazy asynchronicznie, później. Zapis jest błyskawiczny, ale przy awarii między jednym a drugim dane mogą przepaść.

Różnica sprowadza się więc do tego, czy aplikacja czeka na bazę. Write-through wybieraj tam, gdzie utrata zapisu jest niedopuszczalna; write-behind tam, gdzie liczy się przepustowość, a pojedynczy zgubiony wpis nie jest katastrofą - jak przy licznikach odsłon.

Unieważnianie - trzy strategie

Dane w cache starzeją się. Sposoby radzenia sobie z tym układają się od najprostszego do najbardziej złożonego:

  1. Expiration (TTL) - wpis znika sam po czasie. Nic nie piszesz, ale przez chwilę pokazujesz dane nieaktualne.
  2. Manual - usuwasz konkretny klucz, gdy dane się zmienią. Dokładne, ale musisz pamiętać o każdym miejscu zapisu.
  3. Pattern-based - usuwasz grupy kluczy pasujących do wzorca, na przykład wszystko zaczynające się od provinces:. Najpotężniejsze i najłatwiej nim usunąć za dużo.

Sam proces unieważnienia po aktualizacji ma trzy kroki: zaktualizuj dane w bazie, zidentyfikuj i usuń odpowiednie klucze, a następne żądanie pobierze świeże dane i zapisze je na nowo.

Rozgrzewanie cache

Przy cache-aside pierwszy użytkownik po każdym restarcie płaci pełną cenę odczytu z bazy. Można temu zapobiec, zapełniając skrytkę zanim ktokolwiek poprosi:

1@Injectable()
2export class CacheWarmupService implements OnModuleInit {
3  constructor(
4    @Inject(CACHE_MANAGER) private cache: Cache,
5    private repo: ProvinceRepository,
6  ) {}
7
8  async onModuleInit() {
9    const provinces = await this.repo.findAll();
10    await this.cache.set('provinces:all', provinces, 3600);
11  }
12}

onModuleInit to metoda wywoływana przez NestJS przy starcie aplikacji, po zbudowaniu modułu. Proces jest trzyetapowy: zidentyfikuj dane krytyczne, pobierz je i zapisz przy starcie, dostosuj listę na podstawie tego, co naprawdę jest odpytywane najczęściej.

Rozgrzewaj tylko to, co jest jednocześnie często potrzebne i rzadko zmieniane. Rozgrzewanie wszystkiego wydłuża start aplikacji i zapełnia pamięć danymi, po które nikt nie sięgnie.

Podsumowanie

Skrytka przy obozie działa, po resztę schodzi się do składu:

  • cache to tymczasowe przechowywanie danych dla szybszego dostępu - nie trwały magazyn, nie kompresja, nie logowanie,
  • CacheModule.register() konfiguruje cache z parametrami; max ogranicza liczbę wpisów,
  • TTL to maksymalny czas życia wpisu, po którym jest on automatycznie usuwany,
  • wstrzyknięcie w kolejności: @Inject(CACHE_MANAGER), private cache:, Cache,
  • cache-aside oznacza ręczne pobieranie i ręczny zapis; cztery kroki: sprawdź cache → zwróć przy trafieniu → pobierz z bazy przy pudle → zapisz i zwróć,
  • @UseInterceptors(CacheInterceptor) automatycznie cache'uje odpowiedzi endpointu,
  • @CacheTTL(600) ustawia czas życia; @CacheKey nadaje nazwę wpisu, @UseInterceptors włącza mechanizm,
  • write-through zapisuje do bazy synchronicznie, write-behind - asynchronicznie, ryzykując utratę przy awarii,
  • strategie unieważniania od najprostszej: expiration (TTL) → manual (jeden klucz) → pattern-based (grupy kluczy),
  • proces po aktualizacji: zaktualizuj bazę → zidentyfikuj i usuń klucze → następne żądanie pobierze świeże dane,
  • cache warming przez onModuleInit: zidentyfikuj dane krytyczne → pobierz i zapisz przy starcie → dostosuj listę.

W następnej lekcji przeniesiemy skrytkę poza proces - do Redisa, dzięki czemu zobaczą ją wszystkie instancje aplikacji. A na razie zapamiętaj: cache nie przyspiesza pracy, tylko pozwala jej nie wykonywać - a cała trudność polega na tym, by wiedzieć, kiedy zapas się zestarzał.

Kod do tej lekcji: src/caching-intro.ts
1// Caching - Skrytki z Zapasami w Imperium Rzymskim
2import { Injectable, Inject, Module } from '@nestjs/common';
3import { CACHE_MANAGER } from '@nestjs/cache-manager';
4import { CacheModule } from '@nestjs/cache-manager';
5import { Cache } from 'cache-manager';
6
7// 1. Konfiguracja CacheModule w NestJS
8@Module({
9  imports: [
10    CacheModule.register({
11      ttl: 300,      // Time To Live: 300 sekund (5 minut)
12      max: 100,      // Maksymalnie 100 elementow w cache
13      isGlobal: true // Dostepny globalnie w calej aplikacji
14    }),
15  ],
16})
17export class AppModule {}
18
19// 2. Serwis z cache'owaniem
20@Injectable()
21export class TributeCacheService {
22  constructor(
23    @Inject(CACHE_MANAGER) private cacheManager: Cache
24  ) {}
25
26  // GET - Pobierz z cache lub z bazy danych
27  async getTribute(id: number) {
28    const cacheKey = `tribute:${id}`;
29
30    // Sprawdz cache
31    let tribute = await this.cacheManager.get(cacheKey);
32    if (tribute) {
33      console.log('CACHE HIT - dane ze skrytki!');
34      return tribute;
35    }
36
37    // CACHE MISS - pobierz z bazy
38    console.log('CACHE MISS - ide do archiwum...');
39    tribute = await this.fetchFromDatabase(id);
40
41    // Zapisz do cache na 5 minut
42    if (tribute) {
43      await this.cacheManager.set(cacheKey, tribute, 300000);
44    }
45    return tribute;
46  }
47
48  // SET - Zapisz do cache
49  async cacheTribute(id: number, data: any, ttl = 300000) {
50    const key = `tribute:${id}`;
51    await this.cacheManager.set(key, data, ttl);
52    console.log(`Tribut ${id} zapisany w skrytce na ${ttl/1000}s`);
53  }
54
55  // DELETE - Usun z cache (invalidacja)
56  async invalidate(id: number) {
57    await this.cacheManager.del(`tribute:${id}`);
58    console.log(`Tribut ${id} usuniety ze skrytki`);
59  }
60
61  // CLEAR - Wyczysc caly cache
62  async clearAll() {
63    await this.cacheManager.reset();
64    console.log('Wszystkie skrytki wyczyszczone!');
65  }
66
67  private async fetchFromDatabase(id: number) {
68    // Symulacja zapytania do bazy
69    const tributes: Record<number, any> = {
70      1: { id: 1, province: 'Gallia', amount: 1000, type: 'gold' },
71      2: { id: 2, province: 'Aegyptus', amount: 2000, type: 'silver' },
72    };
73    return tributes[id] || null;
74  }
75}
76
77// Wzorce cache'owania:
78// - Cache-Aside (Lazy): Sprawdz cache -> jesli brak -> pobierz -> zapisz
79// - Write-Through: Zapisz do DB i cache jednoczesnie
80// - Write-Behind: Zapisz do cache, pozniej synchronizuj z DB
81// - Cache Warming: Wypelnij cache z gory popularnymi danymi
82

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. Cache w aplikacji NestJS to:

  2. 2. CacheModule.register() w NestJS konfiguruje:

To 2 z 8 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Zbuduj serwis cache z metodami get i set dla tributów

  • Klikanie w kolejności

    Ułóż wstrzyknięcie CACHE_MANAGER do serwisu:

  • Układanie w pionie

    Uporządkuj kroki wzorca Cache-Aside (Lazy Loading):

  • Edytor kodu

    Zastosuj CacheInterceptor i CacheTTL na endpoincie GET /provinces

  • Klikanie w kolejności

    Ułóż proces inwalidacji cache po aktualizacji danych:

  • Edytor kodu

    Napisz metodę update która czyści odpowiedni klucz cache po zmianie danych

  • Układanie w pionie

    Uporządkuj strategie invalidation od najprostszej do najzłożonej:

  • Klikanie w kolejności

    Ułóż proces cache warming (pre-loading cache przy starcie):

Przydatne artykuły