Przewodnik po testowaniu wydajności i optymalizacji za pomocą Pythona i Django
Opublikowany: 2022-03-11Donald Knuth powiedział, że „przedwczesna optymalizacja jest źródłem wszelkiego zła”. Ale przychodzi czas, zwykle w dojrzałych projektach o dużym obciążeniu, kiedy pojawia się nieunikniona potrzeba optymalizacji. W tym artykule chciałbym omówić pięć typowych metod optymalizacji kodu projektu internetowego. Będę używał Django, ale zasady powinny być podobne dla innych frameworków i języków. W tym artykule użyję tych metod, aby skrócić czas odpowiedzi na zapytanie z 77 do 3,7 sekundy.
Przykładowy kod jest zaadaptowany z rzeczywistego projektu, nad którym pracowałem i demonstruje techniki optymalizacji wydajności. Jeśli chcesz śledzić i zobaczyć wyniki samodzielnie, możesz pobrać kod w jego stanie początkowym na GitHub i wprowadzić odpowiednie zmiany, śledząc artykuł. Będę używał Pythona 2, ponieważ niektóre pakiety innych firm nie są jeszcze dostępne dla Pythona 3.
Przedstawiamy naszą aplikację
Nasz projekt internetowy po prostu śledzi oferty nieruchomości w poszczególnych krajach. Dlatego istnieją tylko dwa modele:
# houses/models.py from utils.hash import Hasher class HashableModel(models.Model): """Provide a hash property for models.""" class Meta: abstract = True @property def hash(self): return Hasher.from_model(self) class Country(HashableModel): """Represent a country in which the house is positioned.""" name = models.CharField(max_length=30) def __unicode__(self): return self.name class House(HashableModel): """Represent a house with its characteristics.""" # Relations country = models.ForeignKey(Country, related_name='houses') # Attributes address = models.CharField(max_length=255) sq_meters = models.PositiveIntegerField() kitchen_sq_meters = models.PositiveSmallIntegerField() nr_bedrooms = models.PositiveSmallIntegerField() nr_bathrooms = models.PositiveSmallIntegerField() nr_floors = models.PositiveSmallIntegerField(default=1) year_built = models.PositiveIntegerField(null=True, blank=True) house_color_outside = models.CharField(max_length=20) distance_to_nearest_kindergarten = models.PositiveIntegerField(null=True, blank=True) distance_to_nearest_school = models.PositiveIntegerField(null=True, blank=True) distance_to_nearest_hospital = models.PositiveIntegerField(null=True, blank=True) has_cellar = models.BooleanField(default=False) has_pool = models.BooleanField(default=False) has_garage = models.BooleanField(default=False) price = models.PositiveIntegerField() def __unicode__(self): return '{} {}'.format(self.country, self.address) Abstrakcyjny HashableModel udostępnia dowolny model, który dziedziczy z niego właściwość hash , która zawiera klucz podstawowy wystąpienia i typ zawartości modelu. To ukrywa poufne dane, takie jak identyfikatory instancji, zastępując je hashem. Może to być również przydatne w przypadkach, gdy Twój projekt ma wiele modeli i potrzebujesz scentralizowanego miejsca, które rozszyfrowuje i decyduje, co zrobić z różnymi instancjami modeli różnych klas. Zwróć uwagę, że w naszym małym projekcie haszowanie nie jest tak naprawdę potrzebne, ponieważ możemy sobie bez niego poradzić, ale pomoże zademonstrować pewne techniki optymalizacji, więc będę go tam trzymać.
Oto klasa Hasher :
# utils/hash.py import basehash class Hasher(object): @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): base36 = basehash.base36() content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): base36 = basehash.base36() unhashed = '%09d' % base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1]Ponieważ chcielibyśmy udostępniać te dane za pośrednictwem punktu końcowego API, instalujemy Django REST Framework i definiujemy następujące serializatory i widok:
# houses/serializers.py class HouseSerializer(serializers.ModelSerializer): """Serialize a `houses.House` instance.""" id = serializers.ReadOnlyField(source="hash") country = serializers.ReadOnlyField(source="country.hash") class Meta: model = House fields = ( 'id', 'address', 'country', 'sq_meters', 'price' ) # houses/views.py class HouseListAPIView(ListAPIView): model = House serializer_class = HouseSerializer country = None def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country) return queryset def list(self, request, *args, **kwargs): # Skipping validation code for brevity country = self.request.GET.get("country") self.country = Hasher.to_object_pk(country) queryset = self.get_queryset() serializer = self.serializer_class(queryset, many=True) return Response(serializer.data) Teraz wypełniamy naszą bazę danych pewnymi danymi (łącznie 100 000 instancji domów wygenerowanych przy użyciu factory-boy : 50 000 dla jednego kraju, 40 000 dla innego i 10 000 dla trzeciego kraju) i jesteśmy gotowi do testowania wydajności naszej aplikacji.
Optymalizacja wydajności polega na pomiarach
W projekcie możemy zmierzyć kilka rzeczy:
- Czas egzekucji
- Liczba linii kodu
- Liczba wywołań funkcji
- Przydzielona pamięć
- Itp.
Ale nie wszystkie z nich są istotne w mierzeniu, jak dobrze działa nasz projekt. Ogólnie rzecz biorąc, najważniejsze są dwa główne wskaźniki: jak długo coś jest wykonywane i ile pamięci potrzebuje.
W projekcie internetowym czas odpowiedzi (czas potrzebny na otrzymanie przez serwer żądania wygenerowanego przez akcję użytkownika, przetworzenie go i odesłanie wyniku) jest zwykle najważniejszą metryką, ponieważ nie pozwala się nudzić podczas oczekiwania w celu uzyskania odpowiedzi i przełącz się na inną kartę w swojej przeglądarce.
W programowaniu analiza wydajności projektu nazywana jest profilowaniem. Aby sprofilować wydajność naszego punktu końcowego API, użyjemy pakietu Silk. Po zainstalowaniu i wykonaniu naszego wywołania /api/v1/houses/?country=5T22RI (hasz, który odpowiada krajowi z 50 000 wpisami domów), otrzymujemy to:
200 DOSTAŃ
/api/v1/domy/
77292 ms ogółem
15854ms na zapytania
50004 zapytań
Całkowity czas odpowiedzi wynosi 77 sekund, z czego 16 sekund przeznacza się na zapytania w bazie, w których wykonano łącznie 50 000 zapytań. Przy tak ogromnych liczbach jest wiele do zrobienia, więc zacznijmy.
1. Optymalizacja zapytań do bazy danych
Jedną z najczęstszych wskazówek dotyczących optymalizacji wydajności jest upewnienie się, że zapytania do bazy danych są zoptymalizowane. Ten przypadek nie jest wyjątkiem. Co więcej, możemy zrobić kilka rzeczy z naszymi zapytaniami, aby zoptymalizować czas odpowiedzi.
1.1 Podaj wszystkie dane naraz
Przyglądając się bliżej, czym są te 50 000 zapytań, można zauważyć, że są to wszystkie zbędne zapytania do tabeli houses_country :
200 DOSTAŃ
/api/v1/domy/
77292 ms ogółem
15854ms na zapytania
50004 zapytań
| Na | Stoły | Łączy | Czas wykonania (ms) |
|---|---|---|---|
| +0:01:15,874374 | "domy_kraj" | 0 | 0,176 |
| +0:01:15.873304 | "domy_kraj" | 0 | 0,218 |
| +0:01:15.872225 | "domy_kraj" | 0 | 0,218 |
| +0:01:15.871155 | "domy_kraj" | 0 | 0,198 |
| +0:01:15.870099 | "domy_kraj" | 0 | 0,173 |
| +0:01:15,869050 | "domy_kraj" | 0 | 0,197 |
| +0:01:15,867877 | "domy_kraj" | 0 | 0,221 |
| +0:01:15.866807 | "domy_kraj" | 0 | 0,203 |
| +0:01:15,865646 | "domy_kraj" | 0 | 0,211 |
| +0:01:15,864562 | "domy_kraj" | 0 | 0,209 |
| +0:01:15,863511 | "domy_kraj" | 0 | 0,181 |
| +0:01:15,862435 | "domy_kraj" | 0 | 0,228 |
| +0:01:15,861413 | "domy_kraj" | 0 | 0,174 |
Źródłem tego problemu jest fakt, że w Django zbiory zapytań są leniwe . Oznacza to, że zestaw zapytań nie jest oceniany i nie trafia do bazy danych, dopóki nie zajdzie potrzeba pobrania danych. Jednocześnie pobiera tylko te dane, które mu poleciłeś, wysyłając kolejne żądania, jeśli potrzebne są dodatkowe dane.
Tak właśnie stało się w naszym przypadku. Pobierając zapytanie ustawione przez House.objects.filter(country=country) , Django otrzymuje listę wszystkich domów w danym kraju. Jednak podczas serializacji wystąpienia house HouseSerializer wymaga wystąpienia country domu do obliczenia pola country serializatora. Ponieważ dane kraju nie są obecne w zestawie zapytań, django wysyła dodatkowe żądanie, aby uzyskać te dane. I dzieje się tak dla każdego domu w zestawie zapytań — łącznie 50 000 razy.
Rozwiązanie jest jednak bardzo proste. Aby wyodrębnić wszystkie wymagane dane do serializacji, możesz użyć metody select_related() w zestawie zapytań. Zatem nasz get_queryset będzie wyglądał tak:
def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country).select_related('country') return querysetZobaczmy, jak wpłynęło to na wydajność:
200 DOSTAŃ
/api/v1/domy/
35979 ms ogółem
102ms na zapytania
4 zapytania
Całkowity czas odpowiedzi spadł do 36 sekund, a czas spędzony w bazie danych to ~100ms przy zaledwie 4 zapytaniach! To świetna wiadomość, ale możemy zrobić więcej.
1.2 Podaj tylko odpowiednie dane
Domyślnie Django wyodrębnia wszystkie pola z bazy danych. Jednakże, gdy masz ogromne tabele z wieloma kolumnami i wierszami, sensowne jest poinformowanie Django, jakie konkretnie pola ma wyodrębnić, aby nie tracił czasu na zdobywanie informacji, które w ogóle nie będą używane. W naszym przypadku potrzebujemy tylko pięciu pól do serializacji, ale mamy 17 pól. Sensowne jest określenie dokładnie, jakie pola wyodrębnić z bazy danych, aby jeszcze bardziej skrócić czas odpowiedzi.
Django posiada defer() i only() zestaw metod zapytań, które pozwalają dokładnie to zrobić. Pierwsza określa jakie pola nie ładować , a druga określa jakie pola tylko ładować .
def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country)\ .select_related('country')\ .only('id', 'address', 'country', 'sq_meters', 'price') return querysetTo skróciło czas spędzany na zapytaniach o połowę, co jest dobre, ale 50 ms to niewiele. Całkowity czas również nieco się obniżył, ale jest więcej miejsca na jego skrócenie.

200 DOSTAŃ
/api/v1/domy/
33111 ms ogółem
52ms na zapytania
4 zapytania
2. Optymalizacja kodu
Nie można bez końca optymalizować zapytań do bazy danych, a nasz ostatni wynik właśnie to pokazał. Nawet gdybyśmy hipotetycznie skrócili czas spędzany na zapytaniach do 0, musielibyśmy czekać pół minuty na odpowiedź. Czas przejść na inny poziom optymalizacji: logikę biznesową .
2.1 Uprość swój kod
Czasami pakiety innych firm wiążą się z dużym obciążeniem dla prostych zadań. Jednym z takich przykładów jest nasze zadanie zwrócenia serializowanych instancji domów.
Django REST Framework jest świetny, z wieloma przydatnymi funkcjami po wyjęciu z pudełka. Jednak naszym głównym celem w tej chwili jest skrócenie czasu odpowiedzi, więc jest to świetny kandydat do optymalizacji, zwłaszcza że zserializowane obiekty są dość proste.
Napiszmy w tym celu niestandardowy serializator. Aby to uprościć, będziemy mieli pojedynczą metodę statyczną, która wykona zadanie. W rzeczywistości możesz chcieć mieć te same sygnatury klas i metod, aby móc używać serializatorów zamiennie:
# houses/serializers.py class HousePlainSerializer(object): """ Serializes a House queryset consisting of dicts with the following keys: 'id', 'address', 'country', 'sq_meters', 'price'. """ @staticmethod def serialize_data(queryset): """ Return a list of hashed objects from the given queryset. """ return [ { 'id': Hasher.from_pk_and_class(entry['id'], House), 'address': entry['address'], 'country': Hasher.from_pk_and_class(entry['country'], Country), 'sq_meters': entry['sq_meters'], 'price': entry['price'] } for entry in queryset ] # houses/views.py class HouseListAPIView(ListAPIView): model = House serializer_class = HouseSerializer plain_serializer_class = HousePlainSerializer # <-- added custom serializer country = None def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country) return queryset def list(self, request, *args, **kwargs): # Skipping validation code for brevity country = self.request.GET.get("country") self.country = Hasher.to_object_pk(country) queryset = self.get_queryset() data = self.plain_serializer_class.serialize_data(queryset) # <-- serialize return Response(data) 200 DOSTAŃ
/api/v1/domy/
17312 ms ogólnie
38ms na zapytania
4 zapytania
Teraz wygląda to lepiej. Czas odpowiedzi został prawie o połowę skrócony ze względu na to, że nie zastosowaliśmy kodu serializatorów DRF.
Kolejny mierzalny wynik — całkowita liczba wywołań funkcji wykonanych podczas cyklu żądanie/odpowiedź — spadła z 15 859 427 wywołań (z żądania wykonanego w sekcji 1.2 powyżej) do 9 257 469 wywołań. Oznacza to, że około 1/3 wszystkich wywołań funkcji zostało wykonanych przez Django REST Framework.
2.2 Zaktualizuj/zastąp pakiety stron trzecich
Opisane powyżej techniki optymalizacji są najczęstsze, te, które można wykonać bez dokładnej analizy i przemyślenia. Jednak 17 sekund nadal wydaje się dość długie; aby zmniejszyć tę liczbę, będziemy musieli głębiej zagłębić się w nasz kod i przeanalizować, co dzieje się pod maską. Innymi słowy, będziemy musieli sprofilować nasz kod.
Możesz zrobić profilowanie samodzielnie, używając wbudowanego profilera Pythona, lub możesz użyć do tego pakietów innych firm (które korzystają z wbudowanego profilera Pythona). Ponieważ już używamy silk , może on sprofilować kod i wygenerować binarny plik profilu, który możemy dalej wizualizować. Istnieje kilka pakietów wizualizacyjnych, które przekształcają profil binarny w kilka wnikliwych wizualizacji. Będę korzystał z pakietu snakeviz .
Oto wizualizacja profilu binarnego ostatniego żądania z góry, podpiętego do metody wysyłki widoku:
Od góry do dołu znajduje się stos wywołań, wyświetlający nazwę pliku, nazwę metody/funkcji wraz z numerem wiersza i odpowiadającym mu łącznym czasem spędzonym w tej metodzie. Teraz łatwiej jest zauważyć, że lwia część czasu poświęcona jest na obliczanie wartości skrótu (prostokąty __init__.py i primes.py w kolorze fioletowym).
Obecnie jest to główne wąskie gardło wydajności w naszym kodzie, ale jednocześnie tak naprawdę nie jest to nasz kod — jest to pakiet innej firmy.
W takich sytuacjach istnieje ograniczona liczba rzeczy, które możemy zrobić:
- Sprawdź, czy jest nowa wersja pakietu (która, miejmy nadzieję, ma lepszą wydajność).
- Znajdź inny pakiet, który lepiej radzi sobie z zadaniami, których potrzebujemy.
- Napisz własną implementację, która przebije wydajność pakietu, z którego obecnie korzystamy.
Na szczęście dla mnie istnieje nowsza wersja pakietu basehash odpowiedzialnego za haszowanie. Kod używa wersji 2.1.0, ale istnieje wersja 3.0.4. Takie sytuacje, gdy masz możliwość aktualizacji do nowszej wersji pakietu, są bardziej prawdopodobne, gdy pracujesz nad istniejącym projektem.
Podczas sprawdzania informacji o wydaniu v.3 jest to konkretne zdanie, które brzmi bardzo obiecująco:
Dokonano gruntownego przeglądu algorytmów pierwszości. W tym (sic!) wsparcie dla gmpy2, jeśli jest dostępne (sic!) w systemie, aby uzyskać znacznie większy wzrost.
Dowiedzmy się tego!
pip install -U basehash gmpy2 200 DOSTAŃ
/api/v1/domy/
7738 ms ogólnie
59ms na zapytania
4 zapytania
Skróciliśmy czas odpowiedzi z 17 do poniżej 8 sekund. Świetny wynik, ale jest jeszcze jedna rzecz, której powinniśmy się przyjrzeć.
2.3 Refaktoryzacja własnego kodu
Do tej pory ulepszyliśmy nasze zapytania, zastąpiliśmy złożony i ogólny kod firm trzecich naszymi własnymi bardzo specyficznymi funkcjami i zaktualizowaliśmy pakiety firm trzecich, ale pozostawiliśmy nasz istniejący kod nietknięty. Czasami jednak niewielka refaktoryzacja istniejącego kodu może przynieść imponujące rezultaty. Ale w tym celu musimy ponownie przeanalizować wyniki profilowania.
Przyglądając się bliżej, widać, że haszowanie nadal stanowi problem (nic dziwnego, że to jedyna rzecz, którą robimy z naszymi danymi), chociaż poprawiliśmy się w tym kierunku. Jednak zielonkawy prostokąt, który mówi, że __init__.py zajmuje 2,14 sekundy, niepokoi mnie, podobnie jak szarawy __init__.py:54(hash) , który pojawia się zaraz po nim. Oznacza to, że inicjalizacja zajmuje dużo czasu.
Rzućmy okiem na kod źródłowy pakietu basehash .
# basehash/__init__.py # Initialization of `base36` class initializes the parent, `base` class. class base36(base): def __init__(self, length=HASH_LENGTH, generator=GENERATOR): super(base36, self).__init__(BASE36, length, generator) class base(object): def __init__(self, alphabet, length=HASH_LENGTH, generator=GENERATOR): if len(set(alphabet)) != len(alphabet): raise ValueError('Supplied alphabet cannot contain duplicates.') self.alphabet = tuple(alphabet) self.base = len(alphabet) self.length = length self.generator = generator self.maximum = self.base ** self.length - 1 self.prime = next_prime(int((self.maximum + 1) * self.generator)) # `next_prime` call on each initialized instance Jak widać, inicjalizacja instancji base wymaga wywołania funkcji next_prime ; jest to dość ciężkie, jak widać w prostokątach w lewym dolnym rogu powyższej wizualizacji.
Przyjrzyjmy się jeszcze raz mojej klasie Hash :
class Hasher(object): @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): base36 = basehash.base36() # <-- initializing on each method call content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): base36 = basehash.base36() # <-- initializing on each method call unhashed = '%09d' % base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1] Jak widać, oznaczyłem dwie metody, które inicjują instancję base36 przy każdym wywołaniu metody, co nie jest tak naprawdę wymagane.
Ponieważ haszowanie jest procedurą deterministyczną, co oznacza, że dla danej wartości wejściowej musi zawsze generować tę samą wartość haszowania, możemy uczynić z niej atrybut klasy bez obawy, że coś zepsuje. Sprawdźmy, jak będzie działać:
class Hasher(object): base36 = basehash.base36() # <-- initialize hasher only once @classmethod def from_model(cls, obj, klass=None): if obj.pk is None: return None return cls.make_hash(obj.pk, klass if klass is not None else obj) @classmethod def make_hash(cls, object_pk, klass): content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False) return cls.base36.hash('%(contenttype_pk)03d%(object_pk)06d' % { 'contenttype_pk': content_type.pk, 'object_pk': object_pk }) @classmethod def parse_hash(cls, obj_hash): unhashed = '%09d' % cls.base36.unhash(obj_hash) contenttype_pk = int(unhashed[:-6]) object_pk = int(unhashed[-6:]) return contenttype_pk, object_pk @classmethod def to_object_pk(cls, obj_hash): return cls.parse_hash(obj_hash)[1] 200 DOSTAŃ
/api/v1/domy/
3766 ms ogółem
38ms na zapytania
4 zapytania
Ostateczny wynik to mniej niż cztery sekundy, czyli znacznie mniej niż to, od czego zaczęliśmy. Dalszą optymalizację czasu odpowiedzi można osiągnąć za pomocą buforowania, ale nie będę się tym zajmował w tym artykule.
Wniosek
Optymalizacja wydajności to proces analizy i odkrywania. Nie ma sztywnych reguł, które mają zastosowanie do wszystkich przypadków, ponieważ każdy projekt ma swój własny przepływ i wąskie gardła. Jednak pierwszą rzeczą, którą powinieneś zrobić, to sprofilować swój kod. A jeśli w tak krótkim przykładzie mógłbym skrócić czas odpowiedzi z 77 sekund do 3,7 sekundy, to duże projekty mają jeszcze większy potencjał optymalizacyjny.
Jeśli chcesz przeczytać więcej artykułów związanych z Django, zapoznaj się z 10 najczęstszymi błędami popełnianymi przez programistów Django przez innego programistę Toptal Django, Alexandra Shurigina.
