Skip to content
← Wszystkie wpisy
5 min czytania Dawid Skłodowski

Strategie cache'owania stron katalogu o dużym ruchu

Strona katalogu szybka dla dziesięciu użytkowników może paść przy dziesięciu tysiącach. Deep-dive o warstwach cache'owania, które trzymają ją szybką — cache fragmentów, cache HTTP/CDN, cache niskopoziomowy — i o trudnej części: unieważnianiu i stampede.

Listing produktów renderujący się w 80ms na twoim laptopie z garstką produktów może zawalić się pod realnym ruchem, bo ta sama kosztowna praca — odpytywanie, liczenie cen, renderowanie setek kart produktów — uruchamia się na każdym pojedynczym żądaniu, pomnożona przez każdego odwiedzającego. Strony katalogu są czytane ogromnie częściej, niż katalog się zmienia, co jest podręcznikowym warunkiem cache’owania. Sztuka tkwi w wyborze właściwej warstwy do cache’owania i przeżyciu dwóch rzeczy, które czynią cache’owanie trudnym: unieważniania i stampede. Oto jak układają się warstwy.

Myśl warstwami, od najtańszej do najszerszej

Nie ma jednego „cache’a” — jest ich stos, a im dalej od twojej aplikacji żądanie może być obsłużone, tym taniej. Od najgłębszej do najbardziej zewnętrznej:

  1. Cache fragmentów — cache’uj wyrenderowane kawałki strony w Rails.
  2. Cache niskopoziomowy — cache’uj wynik kosztownych obliczeń lub zapytań.
  3. Cache HTTP — pozwól przeglądarce i pośrednikom uniknąć żądania całkowicie.
  4. Cache CDN / na krawędzi — serwuj całe odpowiedzi z krawędzi, nigdy nie dotykając aplikacji.

Solidny katalog używa kilku z nich razem. Celem jest wypchnięcie jak najwięcej pracy na zewnątrz — najlepsze żądanie to takie, którego twoja aplikacja nigdy nie widzi.

Cache fragmentów i klucze matrioszki

Koniem roboczym Rails jest cache fragmentów, a dla katalogu wzorzec matrioszki pasuje idealnie: cache’uj listing, cache’uj każdą kartę produktu w środku i pozwól kluczom się zagnieżdżać, by zmiana jednego produktu wygasiła tylko jego kartę i opakowanie strony — nie każde rodzeństwo:

<% cache ["v2", @products] do %>
  <% @products.each do |product| %>
    <% cache product do %>
      <%= render product %>
    <% end %>
  <% end %>
<% end %>

Klucz to cała gra. Rails buduje klucz cache’a z rekordu i jego updated_at plus digest szablonu, więc cache unieważnia się sam: dotknij produktu (albo touch: true w górę jego asocjacji), a jego klucz się zmienia, stary fragment jest porzucony, a nowy renderuje się raz. To najczystsza forma unieważniania, jaka istnieje — niczego nie wygaszasz, po prostu kluczujesz na czymś, co zmienia się, gdy zmienia się treść. Dodaj ręczny prefiks wersji ("v2"), byś mógł zbić wszystko naraz po przeróbce markupu.

Cache niskopoziomowy dla kosztownych obliczeń

Niektóre koszty to nie renderowanie widoku, lecz obliczenia — licznik filtra fasetowego, ranking „bestsellerów”, agregat wymagający ciężkiego zapytania. Rails.cache.fetch cache’uje wynik, kluczowany jak chcesz, z wygaśnięciem:

def bestsellers
  Rails.cache.fetch("bestsellers/#{category.id}", expires_in: 1.hour) do
    Order.top_products_for(category)   # kosztowne zapytanie uruchamia się najwyżej godzinowo
  end
end

Wzorzec — fetch z blokiem, który liczy przy chybieniu — to najużyteczniejszy idiom cache’owania w Rails. Używaj go do wszystkiego kosztownego, co toleruje bycie nieco przestarzałym. Skład powinien być Memcached albo Redis (współdzielony między wszystkimi serwerami), nigdy skład w pamięci procesu na produkcji, inaczej każdy serwer cache’uje osobno, a twój współczynnik trafień się zapada.

Cache HTTP i CDN: serwuj bez dotykania aplikacji

Największe wygrane biorą się z nieuruchamiania aplikacji w ogóle. Dla stron identycznych dla wielu użytkowników — publicznej strony kategorii, strony szczegółów produktu dla anonimowych odwiedzających — cache HTTP pozwala odpowiedzi być reużytą przez przeglądarki, proxy i CDN:

def show
  @product = Product.find(params[:id])
  fresh_when(@product)   # ustawia ETag + Last-Modified z rekordu
end

fresh_when ustawia nagłówki walidacji; jeśli przeglądarka ponownie żąda i nic się nie zmieniło, Rails zwraca maleńkie 304 Not Modified zamiast renderować od nowa — zaoszczędzona praca. Jeszcze lepiej, ustaw Cache-Control, by CDN cache’ował całą stronę na krawędzi i serwował ją tysiącom użytkowników bez ani jednego żądania docierającego do twojego origin. Dla anonimowego ruchu katalogu to transformujące: twoje serwery obsługują chybienia cache’a i aktualizacje; CDN obsługuje zalew. Haczykiem jest personalizacja — strona pokazująca „Cześć, Dave” albo licznik koszyka nie może być współdzielona między użytkownikami, więc izoluj kawałki per użytkownik (ładuj je osobnym żądaniem albo edge-side include) i trzymaj cache’owalną skorupę publiczną.

Trudne części: unieważnianie i stampede

Powiedzenie Phila Karltona — „są tylko dwie trudne rzeczy w informatyce: unieważnianie cache’a i nazywanie rzeczy” — to żart, który staje się bardzo niezabawny na produkcji. Dwa problemy, które naprawdę gryzą:

Unieważnianie. Przestarzałe dane serwowane z pewnością są gorsze niż wolna strona. Dyscypliną jest preferowanie wygaśnięcia opartego na kluczu (podejście matrioszki, gdzie klucz zmienia się z treścią) nad ręcznym wygaśnięciem (pamiętaniem o usunięciu właściwych kluczy, gdy coś się zmienia), bo ręczne wygaśnięcie to miejsce, gdzie żyją błędy — zapomnisz jednej ścieżki, a cena będzie błędna na cache’owanej stronie. Gdy musisz unieważnić powiązane cache (stronę kategorii, gdy produkt w niej się zmienia), zrób to świadomie, idealnie przez touch:, by framework śledził zależność za ciebie.

Stampede cache’a (thundering herd). Gdy popularny cache’owany element wygasa, każde równoległe żądanie chybia naraz i wszystkie uruchamiają kosztowne obliczenie jednocześnie — często przytłaczając bazę w dokładnie tym momencie, w którym cache miał ją chronić. Obrony: serwuj nieco przestarzałą treść, podczas gdy jeden proces regeneruje w tle (race_condition_ttl w Rails pomaga), dodaj mały losowy jitter do czasów wygaśnięcia, by popularne klucze nie wygasały wszystkie w tej samej sekundzie, i podgrzewaj krytyczne cache zawczasu, zamiast czekać, aż pierwszy pechowy użytkownik zapłaci koszt.

Werdykt

Cache’owanie katalogu to nie jedna technika, lecz warstwowa strategia: cache’uj fragmenty wyrenderowanych kawałków samounieważniającymi się kluczami matrioszki, cache’uj niskopoziomowo kosztowne obliczenia przez fetch i — największa dźwignia — używaj cache’a HTTP i CDN, by serwować anonimowy ruch bez uruchamiania aplikacji w ogóle, izolując spersonalizowane kawałki. Wesprzyj to współdzielonym składem (Redis/Memcached), preferuj unieważnianie oparte na kluczu nad ręcznym wygaśnięciem i broń się przed stampede przez stale-while-revalidate, jitter i podgrzewanie. Ułóż te warstwy poprawnie, a strona katalogu, która ugięła się przy dziesięciu tysiącach odwiedzających, serwuje milion komfortowo — większość z nich nigdy nie dotykając twojej aplikacji, co jest dokładnie sednem.