Memoize - cichy przyjaciel programisty 16
Grono generuje wszystkie strony html całkowicie dynamicznie. W przeciwieństwie do innych serwisów, nie mamy żadnego odwrotnego proxy (reverse proxy). Nie zapamiętujemy w cache wygenerowanych stron, nie zachodzi u nas taka potrzeba. To na czym się skupiamy, to cache'owanie1 surowych informacji pobieranych z bazy danych.
W naszej architekturze zapytanie klienta trafia poprzez Loadbalancer do serwera, który generuje stronę html. Te serwery nazywamy Backendami. Backendy pobierają niezbędne informacje z bazy danych i zwracają wynikową stronę stworzoną dla konkretnego użytkownika.
W takiej architekturze baza danych musi obsłużyć ogromne ilości zapytań. Aby ją odciążyć wyniki zapytań są zapisywane w cache. Jeśli w przyszłości zajdzie potrzeba wykonania takiego samego zapytania, to nie trzeba będzie ponownie pytać bazy. Wystarczy tylko pobrać wynik z cache. Ta technika jest szeroko znana pod nazwą Memoization.
Rozważmy najprostszy przykład:mem_dict = {} def add(a, b): key = "%r %r" % (a,b) if key in mem_dict: # mamy zapamiętane? return mem_dict[key] val = a + b # wylicz wartość mem_dict[key] = val # zapamiętaj return val
W słowniku mem_dict zapamiętywane są wyniki funkcji. Jeśli funkcja będzie wywołana drugi raz z tymi samymi parametrami, to nie będzie konieczne powtórne wykonywanie obliczeń. Wynik zostanie pobrany ze słownika.
W Gronie do przechowywania cache używamy wielu serwerów Memcached. Nasze serwery Memcached są traktowane przez programistę jako gigantyczny rozproszony słownik, w którym dane są trzymane w pamięci wielu maszyn.
Stosowanie Memcached to dość standardowa praktyka. Używa go wiele serwisów, na przykład Facebook.
Jednak standardowy serwer Memcached udostępnia jedynie podstawowe funkcje, których trudno jest używać w naszym środowisku. Dlatego stworzyliśmy programistyczny interfejs do cache'owania o nazwie Memoize, który znacząco upraszcza używanie cache (jego głównym autorem jest Marek Pułczyński).
Kolejnym przykładem będzie funkcja, która pobiera pewne informacje z bazy danych, na przykład dane użytkownika. Standardowo w Django wyglądałoby to mniej więcej tak:
from models import User def get_user(uid): user = User.objects.get(id=uid) return user
Spróbujmy użyć dekoratora Memoize, aby wynik tej funkcji był pobierany z cache:
from models import User from grono2.contrib.memoize import memoize def user_key(uid): return 'model.user.%i' % (uid) @memoize(user_key) def get_user(uid): user = User.objects.get(id=uid) return user
Funkcja user_key generuje klucz, pod którym zostanie umieszczony element w cache. Moża by przypuszczć, że klucz do obiektu można wygenerować automatycznie z parametrów funkcji. Jednak funkcja generująca klucz została wyodrębniona, gdyż może zaistnieć przypadek, że niektóre parametry cache’owanej funkcji nie są jej kluczami głównymi.
Na przykład, poniższy parametr print_debug nie wpływa w żaden sposób na stan cache'owanego obiektu, dlatego nie powinien być brany pod uwagę podczas generowania klucza:
@memoize(user_key2) def get_user_with_warning(uid, print_debug=True): user = User.objects.get(id=uid) if user.name == "Jozin" and print_debug: print "Jozin pobrany z bazy." return user
Jednak co się stanie, gdy zmienimy coś na obiekcie User? W cache będzie przecież stara wartość, więc kolejne wywołania funkcji będą zwracać niewłaściwe stare dane. Dlatego programista musi explicite usunąć (unieważnić, zinwalidować) starą wartość z cache.
Realizowane jest to następująco:from grono2.contrib.memoize import invalidate def change_user_name(uid, new_name): user = get_user(uid) user.name = new_name user.save() invalidate(user_key(uid)) # wyczyść stary cache
Zajmijmy się trudniejszym przykładem, gdzie dwa obiekty połączone są relacją. Przy zmianie jednego może zaistnieć potrzeba zinwalidowania drugiego. Nasza implementacja Memoize pozwala na to.
W tym przykładzie niech model Profile będzie powiązany relacją z modelem User:from models import User, Profile from grono2.contrib.memoize import memoize def user_key(uid): return 'model.user.%i' % (uid) @memoize(user_key) def get_user(uid): user = User.objects.get(id=uid) return user def profile_key(uid): return 'model.profile.%i' % (uid) @memoize(profile_key) def get_profile(uid): p = Profile.objects.get(id=uid) p.user = get_user(id=uid) return p
Jak widać zmiana w User musi spowodować unieważnienie cache obiektu Profile. Aby to osiągnąć definiujemy klucz wiążący, u nas nazywany kluczem wersji. Następnie, aby usunąć wszystkie powiązane rekordy wystarczy jedynie zinwalidować ten klucz.
def super_key(uid): return "super_key.%d" % uid @memoize(user_key, super_key) def get_user(uid): ... @memoize(profile_key, super_key) def get_profile(uid): ... def change_user_name(uid, new_name): user = get_user(uid) user.name = new_name user.save() invalidatev(super_key(uid)) # usuń powiązane rekordy z cache
Wygląda to prosto i intuicyjnie. Jednak główna trudność polega na tym, że działamy w środowisku rozproszonym. Jest możliwa sytuacja, gdy jeden Backend zmodyfikuje rekord i go usunie z cache, a inny Backend pobierze chwilę wcześniej starą wartość i doda ją do cache. Efekt tego będzie taki, że w cache będzie przechowywana niepoprawna wartość. Na szczęście, dzięki przemyślanej implementacji Memoize mamy gwarancję, że taka sytuacja nie wystąpi.
Opisana architektura bardzo dobrze sprawdza się w praktyce. Serwery Memcached bardzo łatwo się skalują, gdy brakuje nam pamięci na cache, to po prostu dostawiamy kolejną maszynę.
Ciekawskich może zainteresować to, że raz na jakiś czas musimy zrestartować serwery Memcached. Zanim cache ponownie zostanie wypełniony danymi mija co najmniej doba.
Z punktu widzenia programisty dekorator Memoize można bardzo prosto stosować w praktyce. Nie jest to żadna przełomowa technologia, lecz łatwość w nauce i zastosowaniu powoduje że nawet młodsi informatycy mogą pisać kod, który będzie działał dla dziesiątków tysięcy użytkowników.
Dzięki cache'owaniu prawie 99% wszystkich zapytań nie musi trafić do bazy. Jednak cały czas staramy się znaleźć miejsca, które nie są poprawnie cache’owane, aby móc jeszcze bardziej przyspieszyć serwis i odciążyć bazę danych.
1Przepraszam purystów językowych za polonizowanie wyrazu cache. Jak to często w informatyce bywa, nie istnieje jeszcze dobry odpowiednik tego wyrazu w języku polskim. Artykuł Wikipedii sugeruje odmianę z apostrofem i postaram się tego trzymać.
