Cache dla API o dużym ruchu wprowadza się dopiero po pomiarze: najpierw warstwa cache-aside w Redis przed najwolniejszymi i najczęściej odczytywanymi endpointami, z jawnie ustawionymi TTL, unieważnianiem przy zapisie i ochroną przed cache stampede. Elasticsearch służy do wyszukiwania i filtrowanych list, z którymi główna baza danych radzi sobie słabo. Danych zależnych od użytkownika lub wymagających ścisłej spójności nigdy nie należy cache'ować bez przemyślanego projektu.
Najpierw pomiar opóźnienia P95, dopiero potem cache
Średnie ukrywają te żądania, na które skarżą się użytkownicy. Warto śledzić opóźnienie P95 i P99 dla każdego endpointu, razem z liczbą żądań, aby widzieć, które trasy są jednocześnie wolne i mocno obciążone.
Następnie trzeba ustalić, gdzie ucieka czas. Ślad wykonania (trace) albo prosty rozkład czasu dla każdego żądania zwykle pokazuje, czy kosztem jest wolne zapytanie, powtarzane zapytania, wywołanie zewnętrznego API czy serializacja. Cache'owanie odpowiedzi, której prawdziwym problemem jest brakujący indeks, tylko ukrywa ten problem do następnego chybienia (cache miss).
- Rejestrowanie P50, P95 i P99 dla każdego endpointu, a nie jednej globalnej liczby
- Ranking endpointów według liczby żądań pomnożonej przez opóźnienie, aby zobaczyć, gdzie cache opłaca się najbardziej
- Sprawdzenie proporcji odczytów do zapisów, bo najlepszym kandydatem są dane odczytywane znacznie częściej, niż się zmieniają
- Naprawa brakujących indeksów i zapytań N+1, zamiast przykrywać je cache
Cache-aside jako domyślny wzorzec
We wzorcu cache-aside aplikacja najpierw sprawdza Redis. Przy trafieniu zwraca wartość z cache. Przy chybieniu odczytuje dane z bazy, zapisuje wynik w Redis z TTL i go zwraca.
Ten wzorzec pozostawia bazę danych źródłem prawdy i bezpiecznie obsługuje awarie. Gdy Redis działa wolno lub jest niedostępny, aplikacja wraca do bazy danych, z krótkim limitem czasu i mechanizmem circuit breaker, aby przeciążony cache nie spowalniał każdego żądania.
Klucze trzeba projektować świadomie. Powinny zawierać typ zasobu, identyfikator, wersję API i każdy parametr, który zmienia odpowiedź, na przykład język czy numer strony. Przewidywalny schemat kluczy to warunek, by później można było unieważniać je selektywnie.
TTL według dopuszczalnej nieaktualności, a przy zmianie unieważnienie
TTL to decyzja biznesowa zapisana liczbą. Trzeba zapytać, jak stare mogą być dane, zanim zaszkodzi to użytkownikowi lub systemowi dalej w łańcuchu, i na tej podstawie ustawić TTL. Wynik meczu na żywo i zarchiwizowany artykuł znoszą zupełnie inny stopień nieaktualności.
Poleganie wyłącznie na wygasaniu oznacza serwowanie nieaktualnych danych aż do upływu TTL. W przypadku danych zmienianych przez własną aplikację klucz należy usunąć lub nadpisać po zatwierdzeniu zapisu, najlepiej na podstawie zdarzenia emitowanego po transakcji, aby cache nigdy nie przechowywał danych, które baza wycofała.
Warto dodać do TTL niewielki losowy rozrzut (jitter), aby klucze zapisane w tym samym czasie nie wygasały wszystkie w tej samej chwili.
- Krótkie TTL, rzędu kilku sekund, dla szybko zmieniających się danych, przy których niewielka nieaktualność jest akceptowalna
- Dłuższe TTL z unieważnianiem przy zapisie dla rzadko zmieniających się danych, nad którymi ma się kontrolę
- Klucze z wersją, gdzie podniesienie numeru wersji unieważnia od razu całą grupę kluczy
Ochrona bazy danych przed cache stampede
Cache stampede występuje, gdy popularny klucz wygasa i wiele równoczesnych żądań naraz trafia w pusty cache, a wszystkie wysyłają do bazy to samo kosztowne zapytanie. W API o dużym ruchu może to przeciążyć bazę dokładnie w chwili szczytu.
Na najgorętszych kluczach warto łączyć co najmniej dwa z poniższych zabezpieczeń.
- Łączenie żądań (request coalescing): jedno żądanie odbudowuje klucz pod krótką blokadą w Redis ustawioną z NX i czasem wygaśnięcia, a pozostałe chwilę czekają lub serwują poprzednią wartość
- Stale-while-revalidate: w wartości zapisuje się miękki termin ważności, po jego upływie nadal serwuje się nieaktualną kopię, a odświeżenie odbywa się w tle
- Wczesne probabilistyczne odświeżanie: gorący klucz jest czasem odbudowywany przed wygaśnięciem, z prawdopodobieństwem rosnącym w miarę zbliżania się terminu
- Wstępne rozgrzewanie (pre-warming): wypełnienie znanych gorących kluczy przed zaplanowanym skokiem ruchu, na przykład przed dużym wydarzeniem na żywo
Elasticsearch do wyszukiwania i list, nie jako ogólny cache
Redis najlepiej sprawdza się przy odczytach klucz-wartość: pojedynczy obiekt, obliczony fragment, licznik limitu żądań. Elasticsearch pasuje do innego zadania: wyszukiwania pełnotekstowego, filtrów fasetowych i sortowanych list, których obliczenie w relacyjnej bazie danych jest kosztowne.
Indeks Elasticsearch należy traktować jako model odczytu zasilany z głównej bazy danych, przez zdarzenia o zmianach lub zaplanowaną synchronizację, i zaakceptować, że jest spójny ostatecznie (eventually consistent). Baza danych pozostaje nadrzędna dla zapisów i dla wszystkiego, co musi być dokładne.
Na platformie mediów sportowych NorthStar Network, obsługującej 50M+ użytkowników miesięcznie, nasi inżynierowie przebudowali architekturę kluczowych API i przeprojektowali cache w Redis i Elasticsearch, co skróciło czasy odpowiedzi P95.
Czego nie cache'ować
Najkosztowniejszy błąd cache to pokazanie danych jednego użytkownika innemu. Każdy cache'owany endpoint trzeba sprawdzić pod kątem tożsamości w kluczu i przed wdrożeniem przetestować na dwóch różnych kontach.
- Odpowiedzi zależne od tożsamości lub uprawnień wywołującego, chyba że klucz zawiera użytkownika lub rolę
- Dane, które muszą być ściśle spójne, takie jak salda, stany magazynowe przy finalizacji zamówienia czy cokolwiek, co służy do decyzji o autoryzacji
- Endpointy zapisu, tokeny jednorazowe i wszystko, co ma efekty uboczne
- Endpointy o małym ruchu, gdzie cache dodaje złożoność i nowy rodzaj awarii przy niewielkim zysku
- Bardzo duże odpowiedzi, które wypierają z pamięci wiele mniejszych, częściej używanych kluczy
Cache bez monitorowania to cache, któremu nie można ufać
Warto śledzić współczynnik trafień dla każdego prefiksu kluczy, zużycie pamięci Redis i usuwanie kluczy (evictions), opóźnienie poleceń Redis oraz obciążenie bazy danych, obok P95 dla API na tym samym dashboardzie. Gdy P95 się zmienia, w ciągu kilku minut powinno być jasne, czy przyczyną jest cache, baza danych czy usługa, od której API zależy.
Alerty warto ustawić na nagły spadek współczynnika trafień, który często oznacza, że wdrożenie zmieniło format klucza, oraz na rosnącą liczbę usuwanych kluczy, która oznacza, że cache jest za mały dla swojego zbioru roboczego.
Prace nad wydajnością API realizujemy jako wyodrębniony etap: pomiar, przeprojektowanie warstwy cache oraz przekazanie dashboardów i instrukcji operacyjnych (runbooków) zespołowi, który ją utrzymuje.
Najważniejsze wnioski
- P95 trzeba mierzyć dla każdego endpointu, a problemy z zapytaniami naprawić przed dodaniem cache.
- Cache-aside w Redis to najbezpieczniejszy domyślny wybór, bo baza danych pozostaje źródłem prawdy.
- TTL wynika z tego, jak nieaktualne mogą być dane; dane pod własną kontrolą unieważnia się przy zapisie.
- Gorące klucze chroni się przed cache stampede blokadą, stale-while-revalidate lub wstępnym rozgrzewaniem.
- Elasticsearch to ostatecznie spójny model odczytu dla wyszukiwania i list, a nie ogólny cache.
FAQ
Redis czy Elasticsearch do cache'owania odpowiedzi API?
Redis do cache'owania obiektów, obliczonych fragmentów i liczników w modelu klucz-wartość, bo odczyty po kluczu są szybkie, a unieważnianie proste. Elasticsearch, gdy kosztowną częścią jest wyszukiwanie, filtrowanie lub sortowanie wielu rekordów; jego indeks należy wtedy traktować jako model odczytu, a nie cache.
Jakie TTL ustawić dla odpowiedzi API?
Nie ma uniwersalnej wartości. Każde TTL ustala się według tego, jak nieaktualne mogą być dane bez szkody dla użytkownika: kilka sekund dla szybko zmieniających się danych, a dłuższe TTL w połączeniu z unieważnianiem przy zapisie. Losowy rozrzut (jitter) sprawia, że powiązane klucze nie wygasają jednocześnie.
Jak zapobiec cache stampede w Redis?
Wygasły klucz powinno odbudowywać tylko jedno żądanie, które zakłada krótką blokadę poleceniem SET z opcją NX, a pozostałe żądania chwilę czekają lub serwują poprzednią wartość. Stale-while-revalidate i wczesne odświeżanie gorących kluczy od początku zmniejszają liczbę twardych wygaśnięć.