Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Caching - skrytki z zapasami w aplikacji

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, @name. 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ł.

Przejdź do CodeWorlds