RSS

Memoize - cichy przyjaciel programisty 16

Posted by Marek Majkowski Wed, 05 Mar 2008 12:00:00 GMT

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.

Architektura serwisu

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ć.