Strona główna / Artykuły

Podstawy cacheowania w Redisie: wzorce, pułapki i koncepcje z rozmów rekrutacyjnych

Cacheowanie w Redis w Node.js: mechanizm cache-aside, TTL, ochrona przed zalewem żądań oraz zasady usuwania danych z pamięci cache, a także pytania z rozmów rekrutacyjnych dotyczące projektowania takich systemów.

Wydawca: REACTAPP.TOP11 min czytania2516 słów

Źródła: Redis Isn’t Just a Cache: The Concepts Every Backend Developer Should Know

Dlaczego potrzebujemy pamięci cache, skoro już mamy bazę danych?

Załóżmy endpoint w twojej aplikacji, który wygląda tak:

GET /products/123

Klient wysyła żądanie do tej ścieżki, a twój serwer zwraca się do MongoDB:

API → MongoDB → Product

Baza danych zwraca rekord produktu i wszystko działa bez problemów.

A teraz wyobraź sobie, że **10 000 osób** prosi o ten sam produkt mniej więcej w tym samym czasie.

Wtedy musiałbyś powtarzać dokładnie ten sam proces tysiące razy:

Request
   ↓
Database
   ↓
Same Product

Twoja baza danych ciągle wykonywa identyczne zadania na te same pytania.

Czy nie byłoby lepiej, gdyby odpowiedź mogła zostać gdzieś zapamiętana?

To właśnie służy **pamięć cache**.

Czym jest pamięć cache?

Cache to szybsza warstwa przechowywania, która przechowuje często żądane dane przez krótki czas, dzięki czemu aplikacja nie musi za każdym razem korzystać z wolniejszego głównego magazynu danych.

Zamiast standardowego procesu:

Request → Database → Response

wprowadzamy skrót:

Request
   ↓
Cache
   ↓
Hit? ── Yes → Response
   │
   No
   ↓
Database
   ↓
Cache
   ↓
Response

Celem nie jest całkowite pozbycie się bazy danych.

Jest nim zmniejszenie ilości operacji w bazie danych, które nie muszą być wykonywane dwukrotnie.

Potwierdzenie dostępu do cache’u vs Brak dostępu do cache’u

Te dwa pojęcia pojawiają się ciągle, więc warto znać je na pamięć.

Potwierdzenie dostępu do cache’u

Dzieje się tak, gdy dane, których szukasz, są już przechowywane w cache’u.

Request
  ↓
Redis
  ↓
Found ✅
  ↓
Response

Brak dostępu do cache’u

Dzieje się tak, gdy cache nie zawiera żadnych danych dla ciebie.

Request
  ↓
Redis
  ↓
Not Found ❌
  ↓
Database
  ↓
Store in Redis
  ↓
Response

Przydatną miarą jest tu wskaźnik potwierdzeń dostępu do cache’u:

Cache Hit Ratio =
Cache Hits / Total Cache Requests

Wyższy wskaźnik zazwyczaj wskazuje na to, że większa ilość żądań jest obsługiwana bezpośrednio z pamięci cache, a nie z podstawowej bazy danych.

Mimo to większa liczba nie oznacza automatycznie sukcesu. Jeśli pamięć cache jest pełna przestarzałych lub mało ważnych wpisów, wysoki wskaźnik trafień nie czyni jej przydatną.

A więc jak w tym przypadku odgrywa rolę Redis?

Redis to magazyn danych w pamięci operacyjnej często używany do cacheowania, chociaż jego możliwości wykraczają daleko poza ten jeden przypadek zastosowania.

Oferuje szereg struktur danych, w tym:

  • Strony tekstowe
  • Hashe
  • Listy
  • Zbiory
  • Zbiory uporządkowane
  • Strumienie
  • JSON
  • Ponadto inne specjalistyczne typy

Dla prostego systemu cache można to przedstawić w następujący sposób:

Key                 Value
────────────────────────────
product:123         Product data
user:42             User data

Ponieważ Redis przechowuje dane często używane w pamięci, odczyty z pamięci cache mogą być niezwykle szybkie.

Redis w aplikacji Node.js

Aby użyć klienta node-redis, należy go zainstalować za pomocą:

npm install redis

Ustawienie podstawowego połączenia wymaga zaledwie kilku linijek kodu:

import { createClient } from "redis";
const redis = createClient();
await redis.connect();

Od tego momentu przechowywanie i pobieranie wartości jest proste:

await redis.set("user:42", "Hamza");
const user = await redis.get("user:42");

Oficjalna dokumentacja Redis wskazuje obecnie na node-redis jako zalecanego klienta dla projektów Node.js.

Jednak znajomość poleceń SET i GET wystarcza tylko do pewnego stopnia.

Prawdziwe pytanie, na które warto odpowiedzieć, brzmi:

Jak faktycznie zaprojektować aplikację wokół Redis?

Najważniejszy wzorzec: Cache-Aside

Jeśli istnieje jeden wzorzec, którego każdy programista zajmujący się backendem powinien najpierw opanować, to jest to Cache-Aside.

W tym wzorcu sama aplikacja jest odpowiedzialna za zarządzanie pamięcią cache.

Request
   ↓
Check Redis
   ↓
 ┌───────────────┐
 │ Cache Hit?    │
 └───────────────┘
    ↓ Yes    ↓ No
 Response   Database
              ↓
           Redis
              ↓
           Response

Uproszczona wersja w kodzie wygląda tak:

const cached = await redis.get(key);
if (cached) return JSON.parse(cached);const product = await Product.findById(id);await redis.set(key, JSON.stringify(product), {
    EX: 300
});

Opcja EX: 300 ustawia TTL wynoszący 300 sekund dla wpisu w pamięci cache.

Cache-Aside jest bezsprzecznie najczęściej używanym wzorcem cacheowania z Redis.

TTL: Pamięć cache nie powinna istnieć wiecznie

Załóżmy, że zapisaliśmy w pamięci cache coś takiego:

product:123

Co powinno się stać, jeśli cena produktu zostanie później zaktualizowana?

Bez terminu wygaśnięcia użytkownicy mogliby nieograniczenie widzieć stary cenę.

To właśnie jest powód istnienia TTL, czyli Time To Live.

Product stored
     ↓
TTL = 300 seconds
     ↓
Expires
     ↓
Next request → Database → Fresh cache

TTL określa maksymalny czas, przez jaki można serwować przestarzałe dane.

Mimo to unikaj wyboru zbyt długiego TTL.

Prawidłowa wartość zależy wyłącznie od stopnia przestarzałości, który twoja aplikacja może zaakceptować.

Nieważność pamięci cache: słynny trudny problem

Rozważmy następujący scenariusz:

Database:
price = ₹799
Redis:
price = ₹799

Następnie baza danych zostaje zaktualizowana:

Database:
price = ₹899

Ale Redis nadal przechowuje stare dane:

price = ₹799

W tym momencie twoja aplikacja serwuje niespójne dane w zależności od źródła odczytu.

Powszechnym rozwiązaniem jest czyszczenie pamięci cache za każdym razem, gdy zmieniają się dane bazowe:

await Product.findByIdAndUpdate(id, update);
await redis.del(`product:${id}`);

Po tym następna prośba przebiega tą ścieżką:

Redis → Miss
   ↓
Database → ₹899
   ↓
Redis → Store fresh value

Dla systemów opartych na podejściu cache-aside usunięcie dotkniętej pozycji w pamięci cache przy każdej zapisie jest zazwyczaj prostsze i bezpieczniejsze niż próba utrzymywania ciągłej synchronizacji pamięci cache z bazą danych.

Trzy problemy z pamięcią cache, które powinieneś znać

Są to doskonałe tematy do dyskusji podczas rozmów kwalifikacyjnych.

1. Cache Stampede

Wyobraź sobie klucz intensywnie używany, który kończy swój okres ważności:

product:123 → EXPIRED

Bezpośrednio po tym przychodzi jednocześnie 1000 żądań.

Każde z nich nie znajduje danych w pamięci cache.

Każde z nich trafia bezpośrednio do bazy danych.

1000 requests
      ↓
1000 DB queries 💥

Pamięć cache miała chronić bazę danych przed obciążeniem, ale sam wydarzenie końca ważności spowodował nagły wzrost obciążenia.

Typowe sposoby zapobiegania temu to:

  • Zamykanie dostępu lub realizacja żądań pojedynczo, tak aby tylko jedno żądanie uzupełniało pamięć cache, podczas gdy pozostałe czekają
  • Różne wartości TTL, aby klucze nie wygasły wszystkie jednocześnie
  • Probabilistyczne wczesne odświeżanie, które odnowia klucz nieco przed jego faktycznym wygaśnięciem
  • Zgromadzanie z góry danych często wymaganych
  • Własna dokumentacja Redisa w sposób wyraźny omawia strategie ochrony przed atakami typu stampede dotyczące kluczy często używanych.

    2. Penetracja pamięci cache

    Co się dzieje, gdy klienci ciągle proszą o dane, które po prostu nie istnieją?

    GET /users/999999
    

    Redis zwraca informację o braku danych.

    Baza danych zwraca „not found.”

    Kolejna identyczna prośba powtarza dokładnie ten sam cykl.

    Złośliwy użytkownik lub uszkodzony klient może to wykorzystać, by obciążać bazę danych bezsensownymi zapytaniami.

    Korzystną środkiem przeciwdziałania jest negatywne cacheowanie — przechowywanie informacji o braku danej rzeczy:

    user:999999 → NOT_FOUND
    TTL → short
    

    Dzięki temu powtarzające się zapytania o ten sam nieistniejący rekord mogą być odpowiadane bezpośrednio z Redis, zamiast za każdym razem korzystać z bazy danych.

    3. Lawina pamięci cache

    Załóżmy teraz, że duża liczba kluczy z pamięci cache wygasa mniej więcej w tym samym momencie.

    Key A → expired
    Key B → expired
    Key C → expired
    Key D → expired
           ↓
    Massive DB traffic
    

    Tę sytuację nazywa się lawiną pamięci cache.

    Typowym sposobem na zmniejszenie tego ryzyka jest unikanie przypisywania identycznych terminów wygaśnięcia dużym grupom kluczy — na przykład poprzez dodanie niewielkiej, losowej odchylenia do każdego TTL.

    Ewakuacja z pamięci cache: co się dzieje, gdy Redis ma niewystarczająco pamięci?

    Pamięć Redis nie jest nieskończona.

    Gdy zużycie pamięci osiągnie ustawiony limit, Redis zaczyna usuwać klucze zgodnie z zasadą wyrzucania, którą skonfigurowałeś. Redis dostarcza kilka takich trybów wyrzucania, umożliwiając usuwanie najrzadziej używanych wpisów w całym przestrzeni kluczy lub tylko tych, dla których zdefiniowano termin ważności, usuwanie najrzadziej używanych wpisów w tych dwóch obszarach, usuwanie na podstawie tego, które klucze są najbliżej terminu wygaśnięcia, lub całkowite odrzucanie nowych zapisów, gdy pamięć jest już pełna.

    LRU oznacza zasadniczo:

    Pozbyć się tego, czego od dawna nie używano.

    LFU oznacza zasadniczo:

    Pozbyć się tego, do czego rzadko uzyskuje się dostęp.

    To, który tryb będzie najbardziej odpowiedni, zależy od Twoich konkretnych wzorców dostępu.

    Redis to nie tylko cache

    To pytanie często pojawia się podczas rozmów kwalifikacyjnych.

    Ponieważ Redis oferuje kilka wbudowanych struktur danych, może służyć do celów znacznie wykraczających poza cacheowanie:

    Strings       → Caching
    Hashes        → Objects / sessions
    Lists         → Queues
    Sets          → Unique collections
    Sorted Sets   → Leaderboards
    Streams       → Event processing
    

    Zatem twierdzenie, że:

    "Redis to baza danych do cacheowania"

    opisuje tylko część prawdy.

    Bardziej dokładny opis brzmi:

    Redis to magazyn struktur danych w pamięci, zwykle używany do cacheowania, ale także zdolny do obsługi sesji, kolejek, tabel rankingowych, ograniczania szybkości dostępu, komunikacji typu pub/sub i wielu innych zadań.

    Oficjalna dokumentacja Redis dotycząca typów danych omawia te struktury wraz z innymi specjalistycznymi typami, które obsługuje.

    A co z trwałością danych w Redis?

    Ponieważ Redis znajduje się w pamięci, czy restart usuwa wszystko?

    Niekoniecznie.

    Redis oferuje kilka strategii trwałości danych:

    • RDB — okresowe zrzuty stanu danych w konkretnym momencie
    • AOF — dziennik rejestrujący każdą operację zapisu
    • RDB + AOF — połączenie obu metod
    • Brak trwałości danych w ogóle

    Jeśli Redis pełni jedynie rolę pamięci podręcznej, utrata jej zawartości po ponownym uruchomieniu często nie stanowi problemu, ponieważ aplikacja może ją łatwo uzupełnić z bazy danych głównej.

    Jednak jeśli Redis przechowuje dane, które same w sobie są istotne, ustawienia dotyczące trwałości stają się znacznie ważniejsze.

    Powszechna pułapka na rozmowie kwalifikacyjnej

    „Dlaczego po prostu nie przechowywać wszystkiego w Redis?”

    Ponieważ pamięć Redis jest zasobem ograniczonym i stosunkowo drogim w porównaniu z tradycyjnymi metodami przechowywania danych trwałego.

    To oznacza, że nie należy bezkrytycznie używać pamięci podręcznej.

    Dobre kandydaty do buforowania dzielą się następującymi cechami:

    • Są często żądane
    • Ich pobranie lub obliczenie jest kosztowne
    • Pewny stopień przestarzałości jest do przyjęcia
    • Warto ponieść koszt pamięci związany z przechowywaniem ich w pobliżu

    Traktuj Redis jako warstwę poprawiającą wydajność działającą obok twojego systemu, a nie jako pełną zamiennikę głównej bazy danych.

    Bufor lokalny vs Bufor rozproszony

    Załóżmy aplikację Node.js działającą na trzech oddzielnych serwerach:

    Server A → Cache A
    Server B → Cache B
    Server C → Cache C
    

    Każdy z tych serwerów utrzymuje własny, niezależny bufor.

    Użytkownik może trafić na Serwer A i uzyskać dostęp do danych z bufora, a później na Serwer B i nie znaleźć tych samych danych w buforze.

    Wprowadzenie wspólnego instancji Redis zmienia tę sytuację:

                 ┌→ Server A ─┐
    Client ──────┼→ Server B ─┼→ Redis
                 └→ Server C ─┘
    

    Teraz każda instancja aplikacji korzysta z tego samego, wspólnego bufora.

    To korzyść wynikająca z wspólnego stanu jest głównym powodem, dla którego Redis sprawdza się szczególnie dobrze, gdy aplikacja jest skalowana horyzontalnie.

    Zaawansowany obraz rozmowy kwalifikacyjnej

    Załóżmy, że rozmówca postawi takie zadanie:

    "Zaprojektuj strategię cacheowania dla API produktu o dużym ruchu.

    Słaba odpowiedź kończy się na:

    ">Użyję Redis.

    Lepsza odpowiedź przedstawia uzasadnienie krok po kroku:

    Request
                         ↓
                      Redis
                    ↙       ↘
                 Hit         Miss
                  ↓            ↓
               Response      Database
                                ↓
                             Redis
                                ↓
                             Response
    

    Następnie omawia każdą z tych decyzji projektowych:

    1. Wzorzec cacheowania

    Należy wybrać podejście typu cache-aside.

    2. TTL

    Ustal je w zależności od tego, jak dużą starość danych produktu można zaakceptować.

    3. Unieważnianie

    Należy usunąć odpowiadający klucz za każdym razem, gdy aktualizowane są dane podstawowe.

    4>Ochrona przed nadmiernym obciążeniem

    Zastosuj techniki blokowania, łączenia żądań lub wczesnego odświeżania.

    5. Wymuszanie wyrzucenia

    Wybierz odpowiednią politykę LRU lub LFU dla danego obciążenia.

    6. Projekt kluczy

    product:{id}
    user:{id}
    

    7. Monitorowanie

    Bądź uważny na:

    • Stosunek udanych prób
    • Stosunek nieudanych prób
    • Latencję
    • Użycie pamięci
    • Liczbę wyrzucanych elementów
    • Główne klucze
    • Błędy Redis

    Taki stopniowy sposób rozumowania daje znacznie bardziej przekonującą odpowiedź dotyczącą projektu systemu niż proste stwierdzenie, że "Redis jest szybki."

    Szybki przegląd rozmowy kwalifikacyjnej

    Dlaczego w ogóle warto używać cache’u?

    Zmniejsza on latencję i oszczędza główną bazę danych przed koniecznością wielokrotnego przetwarzania drogich operacji odczytu.

    Dlaczego akurat Redis?

    Zapewnia szybki dostęp z pamięci operacyjnej, obsługę wygaszania danych, uniwersalne struktury danych oraz funkcje stworzone specjalnie dla systemów rozproszonych.

    Co oznacza mechanizm cache-aside?

    Aplikacja najpierw sprawdza pamięć cache; jeśli dane tam nie są, zapytuje bazę danych, a następnie zapisuje uzyskany wynik z powrotem do cache.

    Co oznacza TTL?

    To okres czasu, przez który wpis w cache pozostaje aktywny, zanim automatycznie wygaśnie.

    Co oznacza unieważnianie danych w cache?

    Oznacza to usuwanie lub odświeżanie wpisów z cache w momencie zmiany danych źródłowych.

    Co to jest „cache stampede”?

    Sytuacja, w której duża liczba żądań jednocześnie nie znajduje danych w cache i wszystkie one kierują się do bazy danych jednocześnie.

    LRU versus LFU?

    LRU usuwa elementy, które były używane najrzadziej ostatnio. LFU usuwa elementy, które były używane najrzadziej ogółem.

    Czy każde zapytanie zasługuje na cacheowanie?

    Wcale nie. Decyzję należy podejmować na podstawie częstotliwości dostępu do danych, kosztu ich pobierania, dopuszczalnego stopnia przestarzałości oraz ilości pamięci, którą możesz zaoszczędzić.

    Model myślowy, który warto zachować

    Unikaj zapamiętywania poleceń Redis jako punktu wyjścia. Zamiast tego skup się na podstawowej architekturze:

                  ┌─────────────┐
    Request ─────→│    Redis    │
                  └──────┬──────┘
                         │
                  Cache Hit?
                   ↙         ↘
                 YES           NO
                  ↓             ↓
              Response       Database
                                ↓
                             Redis
                                ↓
                             Response
    

    Następnie rozważ cztery pytania:

    Co powinno znajdować się w cache’u?

    Jak długo powinno tam pozostać?

    Kiedy należy je unieważnić?

    Co się stanie, jeśli cache zawiedzie lub wygaśnie przy dużej obciążeniu?

    Rozważanie tych pytań odróżnia powierzchowną wiedzę o cacheowaniu od myślenia na poziomie produkcyjnym.

    Zakończenie

    Cacheowanie to nie tylko:

    "Umieść Redis przed MongoDB."

    Skuteczna strategia cacheowania polega w istocie na kompromisach.

    Wymienia się:

    pamięć na szybkość

    a czasami:

    świeżość na wydajność

    Redis dostarcza mechanizmy, ale to logika aplikacji decyduje o tym, w jaki sposób są one wykorzystywane.

    Jeśli przygotowujesz się do rozmów kwalifikacyjnych, nie poprzestawaj na stwierdzeniu:

    "Redis to baza danych w pamięci operacyjnej."

    Bądź gotowy omówić:

    metodę cache-aside, TTL, unieważnianie danych, wskaźnik trafień, usuwanie rekordów, zjawisko stampede, penetrację, lawinę danych, rozproszone cacheowanie oraz spójność.

    Gdy zrozumiesz, jak te koncepcje ze sobą współgrają, przekroczysz poziom zwykłej znajomości Redis.

    Teraz rozumiesz, jak systemy o dużym ruchu chronią swoje bazy danych, zachowując jednocześnie szybkość.

    Literatura pokrewna

  • Projektowanie backendów do rozmów w rzeczywistym czasie: pokoje, persistentność i skalowanie — Dowiedz się, jak zaprojektować backend do rozmów w czasie rzeczywistym przy użyciu Socket.IO, PostgreSQL i Redis, omawiając pokoje, kolejność przechowywania wiadomości, obecność użytkowników oraz skalowanie między serwerami.
  • Budowanie niezawodnych systemów zadań w tle za pomocą BullMQ i Redis — Dowiedz się, jak zaprojektować odporne łańcuchy przetwarzania zadań w tle w Node.js przy użyciu BullMQ i Redis, omawiając próby ponownego wykonania zadań, równoczesność, idempotencję oraz monitorowanie.