Strona główna / Artykuły

Cache Stampedes w Node.js: Dlaczego cache Redis może przeładować bazę danych

Dowiedz się, dlaczego prosta konfiguracja Redis z mechanizmem cache-aside synchronizuje obciążenie bazy danych po wygaśnięciu klucza często używanego, oraz jak blokady i tło odświeżania zapobiegają temu.

Wydawca: REACTAPP.TOP7 min czytania1648 słów

Źródła: “I Thought Redis Would Make My API Faster.” It Didn’t.

Dodanie Redis przed wolnym zapytaniem to klasyczne rozwiązanie problemu bazy danych pod dużym obciążeniem, a w spokojny dzień wydaje się oczywistym sukcesem. Jednak przy maksymalnym ruchu prosta konfiguracja typu cache-aside może znacznie pogorszyć sytuację: w momencie wygaśnięcia popularnego klucza wszystkie jednoczesne żądania zawiodą i nagle obciążą bazę danych. Ten artykuł wyjaśnia ten problem, znany jako „cache stampede”, pokazuje, jak rozprzestrzenia się on w API Node.js, oraz przedstawia dwa sposoby na jego uniknięcie: rozproszony zamek regeneracji oraz przedsobowe obliczanie w tle.

Rozwiązanie, które działa do momentu maksymalnego ruchu

Zwykłą konfiguracją jest model cache-aside. Przy każdym żądaniu API sprawdza Redis; jeśli klucz nie zostanie znaleziony, wysyła zapytanie do bazy danych i zapisuje wynik z powrotem do Redis na później. Gdy jest wdrożone w dzień o niskim ruchu, natychmiast zmniejsza opóźnienia.

Następnie pojawia się szczyt obciążenia w poniedziałek rano i API przestaje odpowiadać. Proces Node.js wykazuje niskie zużycie CPU, więc pętla zdarzeń nie jest przyczyną problemu. Główna baza danych natomiast ma zużycie CPU na poziomie 100%.

Redis nie jest wąskim gardłem

Sam Redis rzadko stanowi problem – potrafi obsłużyć setki tysięcy operacji na sekundę przy opóźnieniach poniżej milisekundy. Problemem jest natomiast relacja między aplikacją a pamięcią cache, a konkretnie to, jak zachowuje się aplikacja, gdy dane są brakujące.

Zwykły model mentalny przypomina cichą bibliotekę: zadajesz pytanie przy ladzie (Redis), a jeśli książka tam nie ma, bibliotekarz (API) pobiera ją z półek (PostgreSQL) i zostawia kopię przy ladzie. W skali większej sceneria przypomina salę handlową. Gdy tysiące klientów prosi o ten sam popularny produkt w momencie wygaśnięcia jego wpisu w pamięci podręcznej, wszyscy jednocześnie stwierdzają, że lada jest pusta, i udają się do półek.

Anatomia tłumu

Weźmy punkt końcowy taki jak GET /products/trending. Przygotowanie jego odpowiedzi wymaga kilku połączeń, zapytań podrzędnych oraz pewnej agregacji w Node.js i trwa około 1,2 sekundy. Wynik jest przechowywany w pamięci podręcznej przez dziesięć minut.

W stanie równowagi jest to bardzo efektywne: z dziesięciu tysięcy żądań tylko jedno trafia do bazy danych, a pozostałe są serwowane z Redis w ciągu około 5 ms.

Załóżmy teraz, że dziesięciominutowy TTL wygaśnie o godzinie 09:00:00. O 09:00:01 przychodzi jednocześnie pięćdziesiąt żądań. Każde z nich zawodzi i każde z nich rozpoczyna tę samą, 1,2-sekundową zapytanie. Baza danych nagle przetwarza pięćdziesiąt intensywnych zapytań równolegle. Zbiór połączeń jest wyczerpany niemal natychmiast, a każde inne żądanie API, które wymaga dostępu do bazy danych, nawet w celu uzyskania niepowiązanych danych, musi czekać w kolejce za tymi pięćdziesięcioma.

Warto tu być precyzyjnym: oczekiwanie na połączenie z zbiornika nie blokuje pętli zdarzeń Node.js, ponieważ te czekania są asynchroniczne. Zamiast tego żądania gromadzą się jako niespełnione obietnice i otwarte sokety, dlatego proces wydaje się nieaktywny, podczas gdy użytkownicy doświadczają przeciążeń czasowych.

To jest tzw. ucieczka z pamięci cache, znana również jako efekt dogpile. Pamięć cache nie zmniejszyła obciążenia; zsynchronizowała jedynie nagły napływ obciążeń w jeden konkretny moment.

Dlaczego dochodzi do eskalacji zamiast odzyskania równowagi

Tłok nie jest po prostu okresem spowolnienia – on sam się wzmacnia.

  • Czas wykonywania zapytań gwałtownie rośnie. Zapytanie trwające 1,2 sekundy może się wydłużyć do 25 sekund, gdy pięćdziesiąt instancji konkuruje o zasoby CPU, blokady oraz operacje wejścia/wyjścia.
  • Instancje API utrzymują tysiące otwartych połączeń HTTP, czekając na realizację zapytań, co powoduje zużycie pamięci i deskryptorów plików.
  • Zasoby CPU na poziomie API pozostają niskie, więc automatyczny skalator oparty na CPU może nigdy nie zwiększyć mocy obliczeniowej, a dodawanie nowych instancji API i tak tylko przesyłałoby więcej zapytań do bazy danych.
  • Nowe żądania wciąż przychodzą, ale nie są obsługiwane, ponieważ powolne zapytania jeszcze nie zapisали odpowiednich wartości. W końcu baza danych przestaje funkcjonować z powodu wyczerpania pamięci lub czasów oczekiwania na połączenie.

Zapobieganie tłokom

Dodanie szybszego magazynu nie może naprawić problemu, który w rzeczywistości jest kwestią koordynacji. Rozwiązaniem jest zmiana osoby uprawnionej do regeneracji drogich danych oraz określenie terminu jej wykonywania.

Wzorzec 1: rozproszona blokada regeneracji

Gdy API działa w postaci kilku instancji, regeneracja musi być koordynowana pomiędzy nimi wszystkimi, co oznacza konieczność przechowywania blokady w Redis. Pierwszy żądanie, które nie znajduje danych w pamięci podręcznej, próbuje uzyskać blokadę przed zapytaniem do bazy danych. Każde inne żądanie, które nie znajduje potrzebnych danych podczas trwania blokady, unika bazy danych i podejmuje jedną z dwóch działań:

  • Czeka i próbuje ponownie: krótko czeka, na przykład 50 ms, i ponownie sprawdza Redis, aż pojawi się nowa wartość.
  • Przekazuje przestarzałe dane: zwraca poprzednią wartość, jeśli produkt może zaakceptować nieco starsze wyniki.

Przykład poniżej wykorzystuje ioredis. Najpierw odczytuje pamięć cache. W przypadku braku danych próbuje wykonać operację SET z parametrami NX i PX 5000, co tworzy zamek blokujący tylko wtedy, gdy nikt inny go nie posiada, i sprawia, że wygasa on po pięciu sekundach. Proces, który uzyska dostęp do zamykania, wykonuje zapytanie, przechowuje wynik na 600 sekund i uwalnia zamek w bloku finally; wszystkie pozostałe procesy sprawdzają stan aż do uzupełnienia wartości.

// Example: Using Redis for a locking/syncing pattern (Conceptual)
import { Redis } from 'ioredis';
const redis = new Redis();


async function getCachedTrendingProducts() {
  const cacheKey = 'products:trending';
  const lockKey = 'lock:products:trending';
  // Attempt 1: Get data
  let data = await redis.get(cacheKey);
  if (data) return JSON.parse(data);
  // Attempt 2: Cache miss. Try to acquire the regeneration lock.
  const acquiredLock = await redis.set(lockKey, 'locked', 'NX', 'PX', 5000); // 5s lock
  if (acquiredLock) {
    // We hold the lock. We are the ONLY worker hitting the DB.
    try {
      // 1.2s Database Operation
      const dbData = await fetchTrendingProductsFromDB();
      // Store in Redis (10 min TTL)
      await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
      return dbData;
    } finally {
      // Release lock
      await redis.del(lockKey);
    }
  } else {
    // We did NOT get the lock. Someone else is already regenerating.
    // Wait and Retry (e.g., exponential backoff or simple polling)
    return pollRedisUntilPopulated(cacheKey);
  }
}

Skutkiem jest to, że jednocześnie do bazy danych trafia co najwyżej jedno kosztowne zapytanie na klucz, niezależnie od liczby jednoczesnych żądań.

W środowisku produkcyjnym warto rozważyć kilka ulepszeń:

  • Czas wygaśnięcia zamykania musi przewyższać najgorszy możliwy czas wykonywania zapytania. Jeśli proces regeneracji trwa dłużej niż pięć sekund, zamek wygasa i inny proces rozpoczyna duplikatowe zapytanie.
  • Bezsensowne usuwanie zamykania może doprowadzić do usunięcia zamykania, które inny proces uzyskał po wygaśnięciu tego twojego. Przechowywanie unikalnego tokena jako wartości zamykania i usuwanie go tylko wtedy, gdy nadal się zgadza – zwykle za pomocą małego skryptu Lua – pozwala uniknąć tego problemu.
  • pollRedisUntilPopulated wymaga czasu oczekiwania i rozwiązania awaryjnego, w przeciwnym razie żądania oczekujące mogą utknąć, jeśli proces trzymający zamykanie ulegnie awarii.
  • W ramach jednego procesu Node.js można również połączyć równoczesne niepowodzenia, dzieląc się jedną obietnicą w trakcie przetwarzania na każdy klucz, co zmniejsza liczbę prób uzyskania zamykania, zanim w ogóle zostanie zaangażowany Redis.
  • Wzorzec 2: obliczenia w tle

    Najbardziej niezawodne podejście polega na oddzieleniu dostarczania danych od ich generowania. Obsługi żądań, działające w trybie synchronicznym, czytają dane wyłącznie z pamięci cache i nigdy jej nie odbudowują. Jeśli dana wartość jest brakująca, natychmiast zwracają stan pusty lub „loading”, co jest znacznie lepsze niż utrzymywanie połączenia przez kilka sekund.

    Odbudowa danych odbywa się w tle, za pomocą procesu takiego jak zadań typu cron, pracownika BullMQ lub odbiorcy do przechwytywania zmian danych, który odświeża klucz przed jego wygaśnięciem. W poniższym przykładzie pracownik zaplanowany co dziewięć minut odbudowuje listę najpopularniejszych elementów i zapisuje ją ponownie z takim samym czasem trwania 10 minut, dzięki czemu klucz nie staje się nieużywany podczas normalnej pracy:

    // Separate background worker file
    // For example, running every 9 minutes via Cron or BullMQ
    async function regenerateTrendingProductsCache() {
      const cacheKey = 'products:trending';
      try {
        // Proactive DB fetch (1.2s)
        const dbData = await fetchTrendingProductsFromDB();
        // Refresh the 10-minute cache
        await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
        console.log('Cache proactively refreshed.');
      } catch (err) {
        console.error('Proactive refresh failed:', err);
      }
    }
    

    Ponieważ przychodzące żądania zawsze znajdują już gotowy klucz, nie ma momentu, w którym mogłyby one jednocześnie skierować się do bazy danych. Dwa elementy zapewniają bezpieczeństwo: pierwszy to wykonywanie aktualizacji za pomocą pojedynczego planera lub pod zabezpieczeniem blokady, aby kilka instancji nie aktualizowało ich jednocześnie, a drugi to powiadamianie o niepowodzeniu aktualizacji, ponieważ sam blok catch jedynie rejestruje błąd, a klucz wygaśnie po minucie. Jeśli chodzi o planowanie, przewodnik dotyczący niezawodnych zadań w tle przy użyciu BullMQ i Redis dostarcza bardziej szczegółowych informacji.

    Wybór między nimi

    Zamknięcie zapewnia znajomy schemat pracy z pamięcią podręczną na boku i nadaje się do danych, których żądania są nieprzewidywalne. Wstępne obliczanie w tle jest odpowiednie dla małej liczby znanych, kosztownych kluczy, które są często odczytywane, takich jak lista najpopularniejszych elementów lub podsumowanie na panelu sterowania, i zapewnia najbardziej przewidywalną latencję. Dodanie niewielkiej ilości losowego odchylenia do czasu ważności TTL również pomaga, gdy wiele kluczy jest zapisywanych jednocześnie i w przeciwnym razie wygasłoby one wszystkie naraz.

    Główne wnioski

    • Redis jest szybki; problemy wynikają z sposobu, w jaki aplikacja reaguje na brak danych, a nie z samej pamięci podręcznej.
    • Naiwny schemat pracy z pamięcią podręczną na boku przekształca pojedynczy termin wygaśnięcia w synchronizowany napływ zapytań do naj wolniejszej zależności, a awaria może być ukryta za niskim obciążeniem CPU API.
    • Rozproszony zamek gwarantuje, że tylko jeden użytkownik regeneruje klucz; należy starannie zarządzać terminem wygaśnięcia zamka, prawa własności oraz czasami oczekiwania.
  • Odświeżanie tła całkowicie eliminuje konieczność regeneracji z ścieżki żądania i jest najlepszą opcją dla kluczy intensywnie używanych i kosztownych w obsłudze.
  • Rozpatruj cache jako decyzję architektoniczną dotyczącą ochrony bazy danych pod obciążeniem, a nie jako szybkie rozwiązanie zwiększające wydajność.
  • Literatura pokrewna