pl  |  en

Cache’owanie w Railsach

Przychodzi czasem taki moment w życiu aplikacji, że trzeba pomyśleć o cache’owaniu. To zazwyczaj miły moment, bo często wynika ze sporego wzrostu liczby użytkowników, ale to nie zmienia faktu, że cache prosty nie jest. Tzn. niby jest prosty, ale jak dochodzi do szczegółów to się sprawy komplikują — jak zwykle. Można do tego problemu podejść na 1000 różnych sposobów, każdy ma jakieś wady i zalety, ja przedstawiam rozwiązanie, którego używamy i które polecam.

Railsy dostarczają kilka różnych mechanizmów cache’owania treści, wszystkie są ładnie opisane w Rails Guide’ach. Z grubsza metody są trzy:

1. Page caching — zapisuje gotowy plik HTML z całością odpowiedzi serwera w taki sposób, że może być potem serwowany z pominięciem aplikacji (np. przez nginksa).

2. Action caching — również zapisuje pełen HTML, ale nie pomija aplikacji: wykonywane są wszystkie before_ i after_filtery, co pozwala np. na kontrolę dostępu do podstron.

3. Fragment caching — zapisuje tylko fragmenty wygenerowanego HTML—a.

Pierwsze dwie metody tak naprawdę mogą być zastosowane tylko w bardzo specyficznych sytuacjach, zresztą coraz rzadziej się przydają, bo mało która aplikacja Railsowa jest na tyle statyczna, żeby je wykorzystać — wystarczy przecież, żeby gdzieś w górnej części strony wyświetlany był login zalogowanego użytkownika, żeby i page i action caching przestały mieć jakiekolwiek zastosowanie.

Za to trzecia metoda — fragment caching — jest bardzo przydatna i warto się jej przyjrzeć.

Fragment caching

Tę część posta można śmiało ominąć, jeśli ktoś zna fragment caching, to nie znajdzie tu nic odkrywczego.

Fragment caching z grubsza polega na tym, że w widokach zamykamy fragmenty, które chcemy cache’ować w bloku metody `#cache` i podajemy klucz, pod którym ten cache ma być zapamiętany. Przykład będzie czytelniejszy.

Załóżmy, że w przykładowej aplikacji mamy menu. Menu zapisane w bazie danych, obsługiwane w Railsach przez model MenuItem.

        # app/controllers/application_controller.rb
        before_filter :load_menu
        # ...
        private

        def load_menu
                @menu_items = MenuItem.scoped
        end

        # app/views/layouts/application.html.erb
        <%# ... %>
        <%= render "menu" %>
        <%# ... %>

        # app/views/application/_menu.html.erb
        <ul>
                <% @menu_items.each do |menu_item| %>
                        <li><%= link_to menu_item.name, menu_item %></li>
                <% end %>
        </ul>

Menu jest ładowane w kółko — na każdej podstronie, za każdą odsłoną, ciągle od nowa. A zmienia się rzadko, a nawet bardzo rzadko. Idealne miejsce do scache’owania. Więc co robimy?

        # app/views/application/_menu.html.erb
        <% cache "menu" do %>
                <ul>
                        <% @menu_items.each do |menu_item| %>
                                <li><%= link_to menu_item.name, menu_item %></li>
                        <% end %>
                </ul>
        <% end %>

I sprawa rozwiązana — teraz cały gotowy kod HTML menu jest zapisany w otchłaniach mechanizmu cache’ującego naszej aplikacji. I za każdą kolejną odsłoną aplikacja już nie będzie sięgać do bazy (to dzięki lazy loadingowi, zwróćcie uwagę na metodę `#scoped` zamiast `#all` w kontrolerze), nie będzie tworzyć tablicy obiektów, nie będzie po niej iterować, nie będzie generować linków, tylko wstawi załadowany z pliku fragment HTMLa. A to działa szybciutko.

Jeszcze jest tylko jeden problem. Czasem trzeba to menu zmienić. W obecnym układzie żadna zmiana menu nie będzie widoczna — my możemy sobie pododawać nowe menu_itemy, pozmieniać stare, parę z nich usunąć, a użytkownicy ciągle będą widzieć to samo, bo aplikacja będzie serwować scache’owany kod HTML tego fragmentu zamiast od nowa zrenderować widok. Żeby zmiany stały się widoczne dla użytkowników trzeba unieważnić cache tego fragmentu:

        # app/models/menu_item.rb
        after_save :expire_cache
        # ...
        private

        def expire_cache
                expire_fragment("menu")
        end

Teraz po każdym zaktualizowaniu którejkolwiek z pozycji, menu cache tego fragmentu HTML—a zostanie skasowany i będzie musiał być zrenderowany od nowa. Żeby było ładniej, to unieważnianie warto przenieść do sweepera.

Unieważnianie po kluczu

O ile fragment caching jest fajny i często przydatny, to rozwiązania podobne do powyższego przykładu mają jedną główną wadę: trzeba zapanować nad unieważnianiem cache’u. Na początku wygląda to niewinnie, ale z czasem zaczyna być coraz gorzej: przybywa logiki decydującej kiedy co unieważnić, przybywa różnych fragmentów dotyczących wyświetlania tych samych modeli w różnych miejscach serwisu (np. to menu z przykładu może być też wyświetlane inaczej w stopce), trzeba obsłużyć wszystkie aktualizacje obiektów łącznie z aktualizacjami asocjacji i powiązanych obiektów itd. No i jeszcze trzeba pamiętać o tym, żeby ręcznie (albo skryptem) unieważnić cache po zmienieniu kodu widoków. Prosta droga do dziwnych i trudnych do zdebugowania błędów.

Stałe połączenie klucza z zawartością

Jest na to rozwiązanie, wprawdzie nie zawsze daje się je zastosować, ale jest genialnie proste i wszystko porządkuje. Zobaczmy na trochę innym przykładzie, dość standardowym: mamy projekty (model `Project`), a każdy projekt ma todosy (model `Todo`). I tak:

        # app/views/projects/show.html.erb
        <h1><%= @project.name %></h1>
        <%= render @project.todos %>

        # app/views/todos/_todo.html.erb
        <p><%= todo.name %></p>

I jak to teraz sprawnie pocache’ować i nie wpaść znów w zamęt ręcznego unieważniania cache’u? Podstawowe założenie, które teraz przyjmiemy jest takie: _klucz powinien być na stałe powiązany z zawartością_. Oznacza to, że jeśli tylko w jakikolwiek sposób zmienimy zawartość danego cache’owanego fragmentu, to powinien on już być cache’owany pod nowym kluczem. Jak to najsprawniej zrobić? Zróbmy więc tak:

        # app/views/projects/show.html.erb
        <% cache "projects/#{@project.id}—#{@project.updated_at.to_i}" do %>
                <h1><%= @project.name %></h1>
                <%= render @project.todos %>
        <% end %>

        # app/views/todos/_todo.html.erb
        <% cache "todos/#{todo.id}—#{todo.updated_at.to_i}" do %>
                <p><%= todo.name %></p>
        <% end %>

Jak widać do klucza włączyliśmy zmienne elementy — identyfikator obiektu i timestamp jego ostatniej aktualizacji. Teraz każda zmiana spowoduje użycie nowego klucza i wszystko będzie aktualne. Ale dobrze się Wam wydaje — za dużo powtarzania podobnego kodu jak na Railsy. Więc inaczej:

        # app/views/projects/show.html.erb
        <% cache @project do %>
                <h1><%= @project.name %></h1>
                <%= render @project.todos %>
        <% end %>

        # app/views/todos/_todo.html.erb
        <% cache todo do %>
                <p><%= todo.name %></p>
        <% end %>

Lepiej, prawda? Niby magia, ale sprawa jest tak naprawdę prosta — metoda `#cache` na obiekcie, który się jej przekazało wykonuje metodę `#cache_key`, a modele ActiveRecordu w tej metodzie zwracają dokładnie to, co potrzebujemy: „nazwa_modelu_w_liczbie_mnogiej/id—timestamp”.

Obsługa powiązanych rekordów

Jest jeszcze tylko jedna sprawa. Co się stanie, kiedy zaktualizujemy todosa? Zmieni się klucz cache’u dla tego todosa, ale przecież nie zmieni się klucz projektu i cały czas będziemy serwować użytkownikom tę samą, starą, listę todosów. Trzeba więc dodać jeszcze tylko jedną rzecz do modelu `Todo`:

        # app/models/todo.rb
        class Todo < ActiveRecord::Base
                belongs_to :project, touch: true
        end

Teraz każde zapisanie todosa spowoduje wykonanie metody `#touch` na projekcie, do którego ten todos należy. A ta metoda aktualizuje atrybut `updated_at` i tym samym zmienia klucz cache’u projektu.

Tu warto zwrócić na jeszcze jedną fajną właściwość tego rozwiązania: po zaktualizowaniu jednego todosa, aplikacja zrenderuje tylko widok projektu i widok tego jednego todosa, a pozostałe todosy zostaną załadowane z cache’u. Więc nawet pierwsze wyświetlenie tego widoku po zmianie czegoś powinno być bardzo szybkie.

Obsługa zmian w widokach

No i jeszcze tylko jeden problem do rozwiązania: zmiany kodu widoków. Bez sensu by było, gdyby trzeba było pamiętać o tym, żeby po każdym zaktualizowaniu widoku czyścić cache na serwerze. Do tego trzeba by albo w jakiś sposób czyścić tych cache dotyczący zmienionych widoków (to byłoby trudne i podatne na przeoczenia), albo czyścić cały (a to straszne marnotrawstwo). Zamiast tego lepiej również wersje kodu widoku powiązać z kluczem cache’u. Więc dodajmy przed dotychczasowy klucz numer wersji, np. tak:

        # app/views/projects/show.html.erb
        <% cache [ "v2", @project ] do %>
                <h1><%= @project.name %></h1>
                <%= render @project.todos %>
        <% end %>

        # app/views/todos/_todo.html.erb
        <% cache [ "v1", todo ] do %>
                <p><%= todo.name %></p>
        <% end %>

Od teraz, jeśli tylko coś zmienimy w którymś widoku, to zmieniamy numer koło „v” na wyższy. I tyle. Reszta zadziała tak samo, jak przy zaktualizowaniu któregoś z rekordów.

Jest też taki nowy gem, który zastępuje podejście z numerami wersji automatycznie generowanym hashem widoku i wszystkich widoków, których ten widok używa: cache_digest. Zaletę ma bardzo dużą — po zaktualizowaniu partiala nie trzeba szukać widoków, które go używają i tam też podbijać numerku wersji.

Czyszczenie śmieci

Wszystko działa, łatwo nad tym zapanować, nie trzeba żmudnie śledzić wszystkich miejsc, w których rekordy są aktualizowane. Jest fajnie.

Warto tylko pamiętać, że powstaje strasznie dużo śmieci. Jeśli używamy cache’a zapisującego dane na dysku, to cache zapisany pod wszystkimi tymi kluczami, które przestały być aktualne i już nigdy nie będą użyte gdzieś tam zostaje i tkwi. I zbiera się. W takiej sytuacji trzeba go regularnie czyścić usuwając najdawniej odczytywane pliki. Można np. napisać prosty skrypt i dodać go do crona.

Mniej kłopotu jest, kiedy cache’ujemy w pamięci. Wbudowany w railsy mechanizm cache’ujący w pamięci sam usuwa najdawniej użyte klucze. Ten mechanizm ma jednak parę wad (chociażby to, że działa w procesie aplikacji, więc cache nie jest współdzielony między instancjami aplikacji) i zamiast niego lepiej użyć:

Memcached

Memcached jest mały, szybki i prosty w obsłudze. No i na serwerach MegiTeamu stawia się go łatwo 🙂 Krótka instrukcja:

Po pierwsze uruchamiany proces memcached na serwerze. Wchodzimy do ustawień konta w panelu MegiTeam, klikamy w „Serwery memcached” (w sekcji „inne”). Dodajemy nowy serwer ustawiając mu ilość RAMu (ile to trzeba sobie oszacować). Na liście serwerów memcached będzie podany adres serwera (adres IP i port), ten adres będzie potrzebny w konfiguracji aplikacji.

Po drugie konfigurujemy aplikację. Ale to proste bo obsługa memcached jest w Railsy wbudowana. W pliku production.rb (oczywiście do testów trzeba też w development.rb to zrobić) ustawiamy cache_store:

       config.cache_store = :mem_cache_store, "ip:port"

Aplikację trzeba zrestartować i już sprawa rozwiązana — cache będzie składowany w pamięci operacyjnej. Stare wpisy (najdawniej użyte) będą usuwane, kiedy memcachedowi zacznie brakować pamięci. No i będzie szybko 🙂

Na koniec

Cache jest fajny. Zwłaszcza cache fragmentów z unieważnianiem po kluczu, który wyżej mniej więcej opisałem. Ale to oczywiście nie jedyne cache’owanie jakie może być potrzebne. Warto też np. rozważyć cache’owanie rekordów — je również można zapisywać w memcached. A może Wy macie jakieś inne sprawdzone pomysły na cache w Railsach?

My zaczerpnęliśmy ten pomysł z bloga 37signals, więcej można przeczytać tutaj  i tutaj.

Piotr Bator

Piotrek jest współwłaścicielem https://nibynic.com. Projektuje i tworzy aplikacje oparte o HTML5, JavaScript i Ruby on Rails.