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.
Ź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.
pollRedisUntilPopulated wymaga czasu oczekiwania i rozwiązania awaryjnego, w przeciwnym razie żądania oczekujące mogą utknąć, jeśli proces trzymający zamykanie ulegnie awarii.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.
Literatura pokrewna
- Od Modulo do Hash Ring: Skalowanie floty cache Node.js bez kryzysów — Dowiedz się, dlaczego metoda shardingu typu hash-mod-N powoduje problemy w bazach danych przy zmianie węzłów, jak porównują się metody rendezvous, jump i ring hashing oraz jak stworzyć zrównoważony pierścień wagowy w Node.js.
- Utrzymywanie czyszczonej pamięci cache Redis w Node.js: unieważnianie danych bez nieaktualizowanych odczytów — Porównaj mechanizmy TTL, usuwanie danych przy zapisie, pub/sub oraz unieważnianie kluczy w wersjach dla Redis w Node.js, zobacz problemy wynikające z ich stosowania oraz stwórz wrapper do pamięci cache typu fail-open.