Donald Knuth je to rekao 'Prerana optimizacija korijen je svega zla.' No, dođe vrijeme, obično u zrelim projektima s velikim opterećenjima, kada postoji neizbježna potreba za optimizacijom. U ovom bih članku želio razgovarati o tome pet uobičajenih metoda za optimizaciju koda vašeg web projekta. Koristit ću Django, ali principi bi trebali biti slični za druge okvire i jezike. U ovom ću članku koristiti ove metode kako bih smanjio vrijeme odgovora upita sa 77 na 3,7 sekundi.
Primjer koda prilagođen je stvarnom projektu s kojim sam radio i prikazuje tehnike optimizacije izvedbe. U slučaju da želite pratiti i sami vidjeti rezultate, možete ugrabiti kôd u početnom stanju GitHub i napravite odgovarajuće izmjene prateći članak. Koristit ću Python 2 jer neki paketi trećih strana još nisu dostupni za Python 3.
Naš web projekt jednostavno prati ponude nekretnina po zemljama. Stoga postoje samo dva modela:
# 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)
Sažetak HashableModel
pruža bilo koji model koji nasljeđuje od njega a hash
svojstvo koje sadrži primarni ključ instance i vrstu sadržaja modela. To skriva osjetljive podatke, poput ID-ova instance, zamjenjujući ih raspršivačem. To bi također moglo biti korisno u slučajevima kada vaš projekt ima više modela i ako vam treba centralizirano mjesto koje će se rasparčati i odlučiti što učiniti s različitim primjercima modela različitih klasa. Imajte na umu da za naš mali projekt raspršivanje zapravo nije potrebno jer se možemo nositi i bez njega, ali pomoći će u demonstriranju nekih tehnika optimizacije, pa ću ih zadržati tamo.
Ovdje je Hasher
razred:
# 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]
Kako bismo ove podatke htjeli poslužiti putem API krajnje točke, instaliramo Django REST Framework i definiramo sljedeće serializatore i prikaz:
# 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)
Sada našu bazu podataka popunjavamo nekim podacima (ukupno 100 000 kućnih primjera generiranih pomoću factory-boy
: 50 000 za jednu zemlju, 40 000 za drugu zemlju i 10 000 za treću zemlju) i spremni smo testirati izvedbu naše aplikacije .
Postoji nekoliko stvari koje možemo izmjeriti u projektu:
Ali nisu svi relevantni za mjerenje izvedbe našeg projekta. Općenito govoreći, dvije su najvažnije metrike: koliko dugo se nešto izvršava i koliko memorije treba.
U web projektu, vrijeme odziva (vrijeme potrebno da poslužitelj primi zahtjev generiran djelovanjem nekog korisnika, obradi ga i pošalje rezultat) obično je najvažnija metrika, jer ne dopušta korisnicima da se dosađuju dok čekaju odgovor i prebace se na drugi karticu u njihovom pregledniku.
U programiranju se naziva analiza izvedbe projekta profiliranje . Da bismo profilirali izvedbu naše API točke, koristit ćemo Svila paket. Nakon što ga instalirate i napravimo naš /api/v1/houses/?country=5T22RI
poziva (hash koji odgovara zemlji s 50 000 unosa u kuću), dobit ćemo ovo:
200 GET
/ api / v1 / kuće /
77292mssveukupno
15854msna upite
50004upiti
Ukupno vrijeme odziva je 77 sekundi, od čega se 16 sekundi troši na upite u bazi podataka, gdje je ukupno napravljeno 50 000 upita. S tako velikim brojevima ima puno prostora za poboljšanje, pa krenimo.
Jedan od najčešćih savjeta za optimizaciju izvedbe je osigurati optimizaciju upita za baze podataka. Ovaj slučaj nije iznimka. Štoviše, možemo učiniti nekoliko stvari u vezi s našim upitima kako bismo optimizirali vrijeme odziva.
Ako malo bolje proučite tih 50 000 upita, možete vidjeti da su to sve suvišni upiti za houses_country
stol:
200 GET
/ api / v1 / kuće /
77292mssveukupno
15854msna upite
50004upiti
Na | Stolovi | Pridružuje se | Vrijeme izvršenja (ms) |
---|---|---|---|
+0: 01: 15,874374 | 'kuće_zemlja' | 0 | 0,176 |
+0: 01: 15,873304 | 'kuće_zemlja' | 0 | 0,218 |
+0: 01: 15,872225 | 'kuće_zemlja' | 0 | 0,218 |
+0: 01: 15,871155 | 'kuće_zemlja' | 0 | 0,198 |
+0: 01: 15,870099 | 'kuće_zemlja' | 0 | 0,173 |
+0: 01: 15,869050 | 'kuće_zemlja' | 0 | 0,197 |
+0: 01: 15,867877 | 'kuće_zemlja' | 0 | 0,221 |
+0: 01: 15,866807 | 'kuće_zemlja' | 0 | 0,203 |
+0: 01: 15,865646 | 'kuće_zemlja' | 0 | 0,211 |
+0: 01: 15,864562 | 'kuće_zemlja' | 0 | 0,209 |
+0: 01: 15,863511 | 'kuće_zemlja' | 0 | 0,181 |
+0: 01: 15,862435 | 'kuće_zemlja' | 0 | 0,228 |
+0: 01: 15,861413 | 'kuće_zemlja' | 0 | 0,174 |
Izvor ovog problema je činjenica da su u Djangu skupovi upita lijen . To znači da se skup upita ne procjenjuje i ne pogađa bazu podataka dok zapravo ne trebate dobiti podatke. Istodobno dobiva samo podatke kojima ste mu rekli, upućujući naknadne zahtjeve ako su potrebni dodatni podaci.
Upravo se to dogodilo u našem slučaju. Kada se upit postavlja putem House.objects.filter(country=country)
, Django dobiva popis svih kuća u datoj zemlji. Međutim, prilikom serializacije a house
primjer, HouseSerializer
zahtijeva country
instanca kuće za izračunavanje serializatora country
polje. Budući da podaci o zemlji nisu prisutni u skupu upita, django daje dodatni zahtjev za dobivanje tih podataka. I to čini za svaku kuću u skupu upita - to je ukupno 50 000 puta.
Rješenje je vrlo jednostavno. Da biste izdvojili sve potrebne podatke za serializaciju, možete koristiti select_related()
metoda na skupu upita. Dakle, naš get_queryset
izgledat će kao:
def get_queryset(self): country = get_object_or_404(Country, pk=self.country) queryset = self.model.objects.filter(country=country).select_related('country') return queryset
Pogledajmo kako je to utjecalo na izvedbu:
200 GET
/ api / v1 / kuće /
35979mssveukupno
102msna upite
4upiti
Ukupno vrijeme odziva palo je na 36 sekundi, a vrijeme provedeno u bazi podataka je ~ 100 ms sa samo 4 upita! To su sjajne vijesti, ali možemo i više.
Django prema zadanim postavkama izdvaja sva polja iz baze podataka. Međutim, kada imate ogromne tablice s mnogo stupaca i redaka, ima smisla reći Djangu koja određena polja treba izdvojiti, tako da neće trošiti vrijeme na dobivanje informacija koje se uopće neće koristiti. U našem slučaju trebamo samo pet polja za serializaciju, ali imamo 17 polja. Ima smisla precizno odrediti koja polja izvaditi iz baze podataka, kako bismo dodatno smanjili vrijeme odziva.
Django ima defer()
i only()
metode postavljanja upita za izvođenje upravo ovog. Prva navodi koja polja ne za učitavanje, a drugo određuje koja polja treba učitati samo .
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 queryset
To je prepolovilo vrijeme provedeno na upite, što je dobro, ali 50 ms nije toliko. Ukupno je vrijeme također malo opalo, ali ima više prostora za rezanje.
200 GET
/ api / v1 / kuće /
33111mssveukupno
52msna upite
4upiti
Ne možete beskonačno optimizirati upite baze podataka, a naš posljednji rezultat upravo je to pokazao. Čak i ako hipotetički smanjimo vrijeme provedeno na upite na 0, i dalje ćemo se suočiti sa stvarnošću da čekamo pola minute da bismo dobili odgovor. Vrijeme je da prijeđemo na drugu razinu optimizacije: poslovna logika .
Ponekad paketi treće strane imaju puno dodatnih troškova za jednostavne zadatke. Jedan od takvih primjera je naš zadatak da vratimo serializirane kućne primjerke.
Django REST Framework je sjajan, s puno korisnih značajki već izravno. Međutim, naš je glavni cilj trenutno smanjiti vrijeme odziva, pa je sjajan kandidat za optimizaciju, pogotovo što su serializirani objekti prilično jednostavni.
Napišimo prilagođeni serializator u tu svrhu. Da bi bilo jednostavnije, imat ćemo jednu statičku metodu koja obavlja posao. U stvarnosti, možda biste željeli imati iste potpise klase i metode da biste mogli naizmjenično koristiti serializatore:
# 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 / kuće /
17312mssveukupno
38msna upite
4upiti
Ovo sada izgleda bolje. Vrijeme odziva gotovo je prepolovljeno zbog činjenice da nismo upotrijebili DRF kod serializatora.
Još jedan mjerljivi rezultat - broj ukupnih poziva upućenih tijekom ciklusa zahtjeva / odgovora - pao je sa 15.859.427 poziva (iz zahtjeva upućenog u odjeljku 1.2 gore) na 9.257.469 poziva. To znači da je oko 1/3 svih poziva funkcija uputio Django REST Framework.
Gore opisane tehnike optimizacije su najčešće, one koje možete učiniti bez temeljite analize i razmišljanja. Međutim, 17 sekundi i dalje izgleda prilično dugo; kako bismo smanjili taj broj, morat ćemo dublje zaroniti u svoj kod i analizirati što se događa ispod haube. Drugim riječima, morat ćemo profilirati naš kôd.
Možete sami napraviti profiliranje koristeći ugrađeni Python profiler ili možete koristiti neke pakete trećih strana za njega (koji koriste ugrađeni Python profiler). Kako već koristimo silk
, on može profilirati kôd i generirati binarnu datoteku profila, koju možemo dalje vizualizirati. Postoji nekoliko paketa vizualizacije koji pretvaraju binarni profil u neke pronicljive vizualizacije. Ja ću koristiti snakeviz
paket.
Evo vizualizacije binarnog profila zadnjeg zahtjeva odozgo, zakačenog za metodu otpreme prikaza:
Odozgo prema dolje nalazi se niz poziva koji prikazuje ime datoteke, naziv metode / funkcije s brojem retka i odgovarajuće kumulativno vrijeme provedeno u toj metodi. Sada je lakše vidjeti da je lavovski dio vremena posvećen računanju hasha (__init__.py
i primes.py
pravokutnici ljubičaste boje).
Trenutno je ovo glavno usko grlo u izvedbi našeg koda, ali istodobno i nije naše kod - to je paket treće strane.
U takvim situacijama postoji ograničen broj stvari koje možemo učiniti:
Na moju sreću, postoji novija verzija basehash
paket koji je odgovoran za raspršivanje. Kôd koristi v.2.1.0, ali postoji v.3.0.4. Takve su situacije kada ste u mogućnosti nadograditi na noviju verziju paketa vjerojatnije kada radite na postojećem projektu.
Kada provjeravate napomene o izdanju za v.3, postoji ova posebna rečenica koja zvuči vrlo obećavajuće:
Izvršen je masovni remont algoritama primarnosti. Uključujući (sic) podršku za gmpy2 ako je dostupan (sic) u sustavu za toliko veće povećanje.
Otkrijmo ovo!
pip install -U basehash gmpy2
200 GET
/ api / v1 / kuće /
7738mssveukupno
59msna upite
4upiti
Smanjili smo vrijeme odziva sa 17 na manje od 8 sekundi. Sjajan rezultat, ali još bismo jednu stvar trebali pogledati.
Do sada smo poboljšali svoje upite, zamijenili složeni i generički kôd treće strane vlastitim vrlo specifičnim funkcijama i ažurirali pakete trećih strana, ali svoj postojeći kôd ostavili smo netaknutim. Ali ponekad mala refaktorizacija postojećeg koda može donijeti impresivne rezultate. Ali za ovo moramo ponovno analizirati rezultate profiliranja.
Ako bolje pogledate, možete vidjeti da je raspršivanje i dalje problem (ne čudi što je to jedino što radimo s našim podacima), iako smo se popravili u tom smjeru. Međutim, zelenkasti pravokutnik koji kaže da __init__.py
troši 2,14 sekunde smeta mi, zajedno sa sivkastim __init__.py:54(hash)
to ide odmah nakon toga. To znači da neka inicijalizacija traje dugo.
Pogledajmo izvorni kod basehash
paket.
# 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
Kao što vidite, inicijalizacija a base
instanca zahtijeva poziv next_prime
funkcija; to je prilično teško kao što možemo vidjeti u donjim lijevim pravokutnicima gornje vizualizacije.
Pogledajmo moj Hash
razred opet:
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]
Kao što vidite, označio sam dvije metode koje inicijaliziraju base36
instance na svakom pozivu metode, što zapravo nije potrebno.
Kako je raspršivanje deterministički postupak, što znači da za datu ulaznu vrijednost mora uvijek generirati istu vrijednost raspršivanja, možemo ga pretvoriti u atribut klase bez straha da će nešto pokvariti. Provjerimo kako će se izvoditi:
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 / kuće /
3766mssveukupno
38msna upite
4upiti
Konačni rezultat je ispod četiri sekunde, što je puno manje od onoga s čime smo započeli. Daljnja optimizacija vremena odziva može se postići korištenjem predmemoriranja, ali u ovom se članku neću pozabaviti time.
Optimizacija izvedbe postupak je analize i otkrivanja. Ne postoje čvrsta pravila koja vrijede za sve slučajeve, jer svaki projekt ima svoj tijek i uska grla. Međutim, prvo što biste trebali učiniti je profiliranje koda. I ako bih u tako kratkom primjeru mogao smanjiti vrijeme odziva sa 77 na 3,7 sekundi, ogromni projekti imaju još veći potencijal za optimizaciju.
Ako ste zainteresirani za čitanje više članaka povezanih s Djangom, pogledajte 10 najboljih grešaka koje programeri Django čine od kolege ApeeScape Django programer Aleksander Šurigin.