Un ghid pentru testarea și optimizarea performanței cu Python și Django
Publicat: 2022-03-11Donald Knuth spunea că „optimizarea prematură este rădăcina tuturor relelor”. Dar vine un moment, de obicei în proiectele mature cu sarcini mari, când există o nevoie inevitabil de optimizare. În acest articol, aș dori să vorbesc despre cinci metode comune de optimizare a codului proiectului dvs. web. Voi folosi Django, dar principiile ar trebui să fie similare pentru alte cadre și limbaje. În acest articol, voi folosi aceste metode pentru a reduce timpul de răspuns al unei interogări de la 77 la 3,7 secunde.
Exemplul de cod este adaptat dintr-un proiect real cu care am lucrat și demonstrează tehnicile de optimizare a performanței. În cazul în care doriți să urmăriți și să vedeți singur rezultatele, puteți prelua codul în starea sa inițială pe GitHub și faceți modificările corespunzătoare în timp ce urmăriți articolul. Voi folosi Python 2, deoarece unele pachete terțe nu sunt încă disponibile pentru Python 3.
Vă prezentăm aplicația noastră
Proiectul nostru web urmărește pur și simplu ofertele imobiliare pe țară. Prin urmare, există doar două 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) HashableModel abstract oferă oricărui model care moștenește de la acesta o proprietate hash care conține cheia primară a instanței și tipul de conținut al modelului. Aceasta ascunde datele sensibile, cum ar fi ID-urile instanțelor, înlocuindu-le cu un hash. De asemenea, poate fi util în cazurile în care proiectul dvs. are mai multe modele și aveți nevoie de un loc centralizat care să dezactiveze și să decidă ce să facă cu diferite instanțe de model din diferite clase. Rețineți că pentru micul nostru proiect, hashingul nu este cu adevărat necesar, deoarece ne putem descurca fără el, dar va ajuta la demonstrarea unor tehnici de optimizare, așa că îl voi păstra acolo.
Iată clasa 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]Deoarece am dori să difuzăm aceste date printr-un punct final API, instalăm Django REST Framework și definim următoarele serializatoare și vizualizare:
# 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) Acum, populăm baza noastră de date cu câteva date (un total de 100.000 de instanțe de casă generate folosind factory-boy : 50.000 pentru o țară, 40.000 pentru alta și 10.000 pentru o țară terță) și suntem gata să testăm performanța aplicației noastre.
Optimizarea performanței este totul despre măsurare
Există mai multe lucruri pe care le putem măsura într-un proiect:
- Timpul de execuție
- Numărul de linii de cod
- Numărul de apeluri de funcții
- Memorie alocată
- etc.
Dar nu toate sunt relevante pentru a măsura cât de bine funcționează proiectul nostru. În general, există două valori principale care sunt cele mai importante: cât timp se execută ceva și de câtă memorie are nevoie.
Într-un proiect web, timpul de răspuns (timpul necesar serverului pentru a primi o solicitare generată de acțiunea unui utilizator, a o procesa și a trimite înapoi rezultatul) este de obicei cea mai importantă măsurătoare, deoarece nu lasă utilizatorii să se plictisească în timp ce așteaptă pentru un răspuns și treceți la o altă filă din browserul lor.
În programare, analiza performanței proiectului se numește profilare. Pentru a profila performanța punctului nostru final API, vom folosi pachetul Silk. După ce îl instalăm și efectuăm apelul nostru /api/v1/houses/?country=5T22RI (hash-ul care corespunde țării cu 50.000 de intrări de case), obținem asta:
200 GET
/api/v1/case/
77292 ms în total
15854 ms la interogări
50004 interogări
Timpul total de răspuns este de 77 de secunde, din care 16 secunde sunt petrecute pe interogări din baza de date, unde au fost efectuate un total de 50.000 de interogări. Cu numere atât de mari, există mult loc de îmbunătățire, așa că să începem.
1. Optimizarea interogărilor bazei de date
Unul dintre cele mai frecvente sfaturi privind optimizarea performanței este să vă asigurați că interogările bazei de date sunt optimizate. Acest caz nu face excepție. Mai mult, putem face mai multe lucruri cu privire la interogările noastre pentru a optimiza timpul de răspuns.
1.1 Furnizați toate datele simultan
Aruncând o privire mai atentă la care sunt acele 50.000 de interogări, puteți vedea că toate acestea sunt interogări redundante în tabelul houses_country :
200 GET
/api/v1/case/
77292 ms în total
15854 ms la interogări
50004 interogări
| La | Mese | Se alătură | Timp de execuție (ms) |
|---|---|---|---|
| +0:01:15.874374 | "case_tara" | 0 | 0,176 |
| +0:01:15.873304 | "case_tara" | 0 | 0,218 |
| +0:01:15.872225 | "case_tara" | 0 | 0,218 |
| +0:01:15.871155 | "case_tara" | 0 | 0,198 |
| +0:01:15.870099 | "case_tara" | 0 | 0,173 |
| +0:01:15.869050 | "case_tara" | 0 | 0,197 |
| +0:01:15.867877 | "case_tara" | 0 | 0,221 |
| +0:01:15.866807 | "case_tara" | 0 | 0,203 |
| +0:01:15.865646 | "case_tara" | 0 | 0,211 |
| +0:01:15.864562 | "case_tara" | 0 | 0,209 |
| +0:01:15.863511 | "case_tara" | 0 | 0,181 |
| +0:01:15.862435 | "case_tara" | 0 | 0,228 |
| +0:01:15.861413 | "case_tara" | 0 | 0,174 |
Sursa acestei probleme este faptul că, în Django, seturile de interogări sunt leneșe . Aceasta înseamnă că un set de interogări nu este evaluat și nu atinge baza de date până când nu trebuie să obțineți datele. În același timp, primește doar datele pe care i-ai spus, făcând solicitări ulterioare dacă sunt necesare date suplimentare.
Exact asta s-a întâmplat în cazul nostru. Când obțineți interogarea setată prin House.objects.filter(country=country) , Django primește o listă cu toate casele din țara dată. Cu toate acestea, atunci când serializează o instanță de house , HouseSerializer necesită instanța de country a casei pentru a calcula câmpul de country al serializatorului. Deoarece datele țării nu sunt prezente în setul de interogări, django face o solicitare suplimentară pentru a obține acele date. Și face acest lucru pentru fiecare casă din setul de interogări, adică de 50.000 de ori în total.
Soluția este însă foarte simplă. Pentru a extrage toate datele necesare pentru serializare, puteți utiliza metoda select_related() din setul de interogări. Astfel, get_queryset -ul nostru va arăta astfel:
def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country).select_related('country') return querysetSă vedem cum a afectat performanța:
200 GET
/api/v1/case/
35979 ms în total
102 ms la interogări
4 interogări
Timpul total de răspuns a scăzut la 36 de secunde, iar timpul petrecut în baza de date este de ~100 ms cu doar 4 interogări! Este o veste grozavă, dar putem face mai mult.
1.2 Furnizați numai datele relevante
În mod implicit, Django extrage toate câmpurile din baza de date. Cu toate acestea, atunci când aveți tabele uriașe cu multe coloane și rânduri, este logic să spuneți lui Django ce câmpuri specifice să extragă, astfel încât să nu petreacă timp pentru a obține informații care nu vor fi folosite deloc. În cazul nostru, avem nevoie de doar cinci câmpuri pentru serializare, dar avem 17 câmpuri. Este logic să specificăm exact ce câmpuri să extragem din baza de date, astfel încât să reducem și mai mult timpul de răspuns.
Django are metodele defer() și only() set de interogări pentru a face exact acest lucru. Primul specifică ce câmpuri nu trebuie încărcate, iar al doilea specifică numai ce câmpuri trebuie încărcate.
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 querysetAcest lucru a redus timpul petrecut cu interogări la jumătate, ceea ce este bine, dar 50 ms nu este atât de mult. Timpul total a scăzut, de asemenea, ușor, dar există mai mult spațiu pentru a-l tăia.

200 GET
/api/v1/case/
33111 ms în total
52 ms la interogări
4 interogări
2. Optimizarea codului dvs
Nu puteți optimiza la infinit interogările bazei de date, iar ultimul nostru rezultat tocmai a arătat asta. Chiar dacă reducem ipotetic timpul petrecut cu interogări la 0, ne-am confrunta totuși cu realitatea de a aștepta o jumătate de minut pentru a obține răspunsul. Este timpul să trecem la un alt nivel de optimizare: logica de afaceri .
2.1 Simplificați-vă codul
Uneori, pachetele de la terțe părți vin cu o mulțime de cheltuieli generale pentru sarcini simple. Un astfel de exemplu este sarcina noastră de a returna instanțe casa serializate.
Django REST Framework este grozav, cu o mulțime de funcții utile din cutie. Cu toate acestea, scopul nostru principal în acest moment este să reducem timpul de răspuns, deci este un candidat excelent pentru optimizare, mai ales că obiectele seriale sunt destul de simple.
Să scriem un serializator personalizat în acest scop. Pentru a rămâne simplu, vom avea o singură metodă statică care face treaba. În realitate, s-ar putea să doriți să aveți aceleași semnături de clasă și metodă pentru a putea folosi serializatoarele în mod interschimbabil:
# 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 GET
/api/v1/case/
17312 ms în total
38 ms la interogări
4 interogări
Asta arată mai bine acum. Timpul de răspuns a fost aproape înjumătățit din cauza faptului că nu am folosit cod de serializatoare DRF.
Un alt rezultat măsurabil – numărul total de apeluri de funcții efectuate în timpul ciclului de solicitare/răspuns – a scăzut de la 15.859.427 de apeluri (de la solicitarea făcută în secțiunea 1.2 de mai sus) la 9.257.469 de apeluri. Aceasta înseamnă că aproximativ 1/3 din toate apelurile de funcții au fost efectuate de Django REST Framework.
2.2 Actualizați/înlocuiți pachetele de la terți
Tehnicile de optimizare descrise mai sus sunt cele mai comune, cele pe care le poți face fără analiză și gândire amănunțită. Cu toate acestea, 17 secunde sunt încă destul de lungi; pentru a reduce acest număr, va trebui să ne adâncim în codul nostru și să analizăm ce se întâmplă sub capotă. Cu alte cuvinte, va trebui să ne profilăm codul.
Puteți face singur profilarea, utilizând profilerul Python încorporat sau puteți utiliza pachete terțe (care folosesc profilerul Python încorporat). Deoarece folosim deja silk , acesta poate profila codul și poate genera un fișier de profil binar, pe care îl putem vizualiza în continuare. Există mai multe pachete de vizualizare care transformă un profil binar în niște vizualizări perspicace. Voi folosi pachetul snakeviz .
Iată vizualizarea profilului binar al ultimei solicitări de sus, conectată la metoda de expediere a vizualizării:
De sus în jos este stiva de apeluri, afișând numele fișierului, numele metodei/funcției cu numărul său de linie și timpul cumulat corespunzător petrecut în acea metodă. Acum este mai ușor de observat că o parte a leului din timp este dedicată calculării hash-ului (dreptunghiurile __init__.py și primes.py de culoare violetă).
În prezent, acesta este principalul blocaj de performanță din codul nostru, dar, în același timp, nu este chiar codul nostru - este un pachet terță parte.
În astfel de situații, există un număr limitat de lucruri pe care le putem face:
- Verificați o nouă versiune a pachetului (care, sperăm, are o performanță mai bună).
- Găsiți un alt pachet care funcționează mai bine la sarcinile de care avem nevoie.
- Scrieți propria noastră implementare care va depăși performanța pachetului pe care îl folosim în prezent.
Din fericire pentru mine, există o versiune mai nouă a pachetului basehash care este responsabilă pentru hashing. Codul folosește v.2.1.0, dar există un v.3.0.4. Astfel de situații, când puteți actualiza la o versiune mai nouă a unui pachet, sunt mai probabile atunci când lucrați la un proiect existent.
Când verificați notele de lansare pentru v.3, există această propoziție specifică care sună foarte promițător:
O revizuire masivă a fost făcută cu algoritmii de primalitate. Inclusiv (sic) suport pentru gmpy2 dacă este disponibil (sic) pe sistem pentru o creștere mult mai mare.
Să aflăm asta!
pip install -U basehash gmpy2 200 GET
/api/v1/case/
7738 ms în total
59 ms la interogări
4 interogări
Am redus timpul de răspuns de la 17 la sub 8 secunde. Rezultat grozav, dar mai este un lucru la care ar trebui să ne uităm.
2.3 Refactorizați propriul cod
Până acum, ne-am îmbunătățit interogările, am înlocuit codul complex și generic terță parte cu propriile noastre funcții foarte specifice și am actualizat pachete terță parte, dar am lăsat codul existent neatins. Dar uneori o mică refactorizare a codului existent poate aduce rezultate impresionante. Dar pentru aceasta trebuie să analizăm din nou rezultatele profilării.
Aruncând o privire mai atentă, puteți vedea că hashingul este încă o problemă (nu este surprinzător, este singurul lucru pe care îl facem cu datele noastre), deși ne-am îmbunătățit în această direcție. Cu toate acestea, mă deranjează dreptunghiul verzui care spune că __init__.py consumă 2,14 secunde, alături de __init__.py:54(hash) cenușiu care merge imediat după el. Aceasta înseamnă că unele inițializare durează mult.
Să aruncăm o privire la codul sursă al pachetului 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 După cum puteți vedea, inițializarea unei instanțe de base necesită un apel al funcției next_prime ; care este destul de greu, așa cum putem vedea în dreptunghiurile din stânga jos al vizualizării de mai sus.
Să ne uităm din nou la clasa mea 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] După cum puteți vedea, am etichetat două metode care inițializează o instanță base36 la fiecare apel de metodă, ceea ce nu este cu adevărat necesar.
Deoarece hashingul este o procedură deterministă, ceea ce înseamnă că pentru o anumită valoare de intrare trebuie să genereze întotdeauna aceeași valoare hash, putem face din acesta un atribut de clasă fără a ne teme că va sparge ceva. Să vedem cum va funcționa:
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 GET
/api/v1/case/
3766 ms în total
38 ms la interogări
4 interogări
Rezultatul final este sub patru secunde, ceea ce este mult mai mic decât ceea ce am început. O optimizare suplimentară a timpului de răspuns poate fi realizată utilizând memorarea în cache, dar nu o voi aborda în acest articol.
Concluzie
Optimizarea performanței este un proces de analiză și descoperire. Nu există reguli stricte care să se aplice în toate cazurile, deoarece fiecare proiect are propriul său flux și blocaje. Cu toate acestea, primul lucru pe care ar trebui să-l faceți este să vă profilați codul. Și dacă într-un exemplu atât de scurt aș putea reduce timpul de răspuns de la 77 de secunde la 3,7 secunde, proiectele uriașe au și mai mult potențial de optimizare.
Dacă sunteți interesat să citiți mai multe articole legate de Django, consultați Top 10 greșeli pe care dezvoltatorii Django le fac de colegul dezvoltator Toptal Django, Alexandr Shurigin.
