Poprzedni wpis (Koniec darmowych obiadków) | Następny wpis (Vendor lock-in (na melodię "do no evil"))

Żarte aplikacje na AppEngine

Aplikacja, która jest moją piaskownicą na Google AppEngine z upływem czasu rozrosła się trochę (procesory kontekstu, middleware, takie tam...) i okazało się, że każdy request w przeglądarce logów świeci na żółto, to znaczy że według Google jego obsługa zjadła nadmierną ilość zasobów i powinien zostać w jakiś sposób zoptymalizowany. No i faktycznie, powinien — obsługa każdego z żądań do aplikacji zjadała ~1200 ms CPU (łącznie kod + storage). Coś było ewidentnie nie halo, więc musiałem podjąć pewne kroki zaradcze:

  1. użycie googlowego cache gdzie się tylko da (porzucając tradycyjne pojęcie o tym, gdzie ma to sens);
  2. optymalizacja sposobu dostępu do danych w datastore (wybieranie encji używając klucza/kluczy, a nie budując Query);
  3. jak dla mnie najważniejsze: optymalizacja importów.

Zanim przejdę do omówienia poszczególnych optymalizacji... Warto było nad tym popracować, bo średni czas obsługi żądania spadł do ~120 ms CPU (z grubsza: 10x wzrost wydajności). Pomimo tego, co ja uznałem za najważniejsze, to nie optymalizacja importów dała największy zysk, a zmniejszenie ilości odczytów z datastore (dzięki użyciu memcache i wybierania danych przy użyciu kluczy). Co uważam za dobre w tym wszystkim to to, że AppEngine wymusza przemyślane zaplanowanie aplikacji i optymalizację każdego aspektu działania kodu (i to od samego początku). To nie jest zabawka dla niecierpliwych chłopców-pehapowców. ;)

Memcache gdzie się da

Memcache API było jedną z pierwszych rzeczy, jakie Google dodało do AppEngine po jego uruchomieniu w kwietniu 2008 roku. Jego użycie w aplikacji jest nie tyle optymalizacją, co po prostu koniecznością (szczególnie w świetle zapowiedzianego na koniec maja 2009 obniżenia limitów). O ile w zwykłych aplikacjach w cache umieszcza się rzeczy, które są albo kosztowne do wyliczenia, albo niemal statyczne, o tyle na AppEngine buforować trzeba niemal wszystko, bo pomimo twierdzeń googlarzy, że odczyty z datastore są tanie, to ta taniość jest względna (chyba względem kosztu zapisów) — a koszt obsługi żądania jest liczony jako suma kosztu wykonania kodu jako całości, wraz z kosztem pobrania danych (w limitach te wartości są liczone oddzielnie).

Na co zwrócić uwagę na początku? Na drobne rzeczy: profil użytkownika, listy ostatnio dodanych/popularnych obiektów, to, co pojawia się w kontekście w wyniku działania procesorów lub jest dodawane do obiektu request przez middleware. Po tych rzeczach można zająć się resztą, czyli każdą instancją modelu (i każdą wyliczoną wartością), która pojawia się w aplikacji. Czasem trzeba będzie podjąć decyzję, czy warto aktualizować pokazywane dane w czasie rzeczywistym, czy może da się przełknąć mały poślizg rzędu 15 minut... Bo obiekty wyjęte z cache nie zawsze zachowują się tak, jak byśmy tego oczekiwali (przynajmniej na razie).

Dostęp do danych

Tym, co bywa najtrudniejsze do przełknięcia przy robieniu aplikacji na AppEngine jest zupełnie inny model storage — nierelacyjny, bez złączeń i nastawiony na zupełnie inne użycie, niż bazy danych ogólnego stosowania, jak MySQL czy PostgreSQL (nie ujmując nic relacyjnym bazom danych). Oczywiście, można udawać, że się tego nie zauważa i próbować symulować relacyjność, ale efektem tego będzie obniżona wydajność. Z tego co zauważyłem w różnych artykułach i podpowiedziach tu i ówdzie, najważniejsze podpowiedzi można streścić w kilku punktach:

  • storage świetnie sprawdza się jako wielka tablica asocjacyjna, dostęp do danych przy użyciu kluczy jest najbardziej wydajny;
  • klucze są jedynymi unikalnymi atrybutami obiektów, można to wykorzystać do kilku celów;
  • przechowywanie listy kluczy (np. w atrybucie typu db.ListProperty) jest równie wygodne jak złączenie wiele-do-wiele, a w większości wypadków wygodniejsze;
  • atrybuty indeksowane (db.StringProperty) są bardziej kosztowne w aktualizacji niż nieindeksowane (db.TextProperty), warto wziąć to pod uwagę przy projektowaniu modelu.

Uwaga na importy

Czym się różni kod uruchamiany na AppEngine od kodu uruchamianego w zwykłym środowisku Pythona? Niczym, oprócz tego, że proces, który obsługuje żądanie żyje dokładnie tyle, ile trwa obsługa żądania. A to oznacza, że wszystkie moduły konieczne do wykonania kodu przy każdym żądaniu muszą zostać zaimportowane, co jest sporym obciążeniem. Aby trochę poprawić sytuację, AppEngine buforuje zaimportowane moduły przez jakiś czas (liczony raczej w sekundach niż w godzinach). Nie są buforowane moduły, które zostały zaimportowane przy użyciu funkcji __import__(), co oznacza tylko jedno: wszystkie wynalazki typu klass = import_string('mypackage.mymodule.MyClass') trzeba odłożyć na półkę. Nie zawsze da się tego całkiem uniknąć, ale w takich przypadkach trzeba przygotować swój kod na to, że może otrzymać obiekt wykonywalny lub ciąg znaków i zminimalizować ilość miejsc, gdzie wykonywany jest niebuforowany import.

Dużo tego, ale nikt nie mówił, że będzie lekko. :)

Skomentujesz?

* 


* 


* oznacza pole wymagane