Python ve Django ile Performans Testi ve Optimizasyon Kılavuzu

Yayınlanan: 2022-03-11

Donald Knuth, “erken optimizasyon tüm kötülüklerin köküdür” dedi. Ancak, genellikle yüksek yüklere sahip olgun projelerde, kaçınılmaz olarak optimize etme ihtiyacının olduğu bir zaman gelir. Bu yazıda, web projenizin kodunu optimize etmek için yaygın olarak kullanılan beş yöntemden bahsetmek istiyorum. Django kullanacağım, ancak ilkeler diğer çerçeveler ve diller için benzer olmalıdır. Bu yazımda, bir sorgunun yanıt süresini 77 saniyeden 3,7 saniyeye düşürmek için bu yöntemleri kullanacağım.

Python ve Django ile Performans Optimizasyonu ve Performans Testi Kılavuzu

Örnek kod, birlikte çalıştığım gerçek bir projeden uyarlanmıştır ve performans optimizasyon tekniklerini gösterir. Takip etmek ve sonuçları kendiniz görmek isterseniz, kodu GitHub'da ilk durumunda alabilir ve makaleyi takip ederken ilgili değişiklikleri yapabilirsiniz. Bazı üçüncü taraf paketleri henüz Python 3 için mevcut olmadığından Python 2 kullanacağım.

Uygulamamızın Tanıtımı

Web projemiz sadece ülke bazında emlak tekliflerini takip ediyor. Bu nedenle, sadece iki model var:

 # 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)

Soyut HashableModel , kendisinden örneğin birincil anahtarını ve modelin içerik türünü içeren bir hash özelliği miras alan herhangi bir model sağlar. Bu, örnek kimlikleri gibi hassas verileri bir karma ile değiştirerek gizler. Projenizin birden fazla modeli olduğunda ve farklı sınıfların farklı model örnekleriyle ne yapacağınıza karar veren ve karmaları kaldıran merkezi bir yere ihtiyacınız olduğunda da yararlı olabilir. Küçük projemiz için hash'e gerçekten gerek olmadığını, onsuz da halledebileceğimizi unutmayın, ancak bu, bazı optimizasyon tekniklerini göstermeye yardımcı olacaktır, bu yüzden onu orada tutacağım.

İşte Hasher sınıfı:

 # 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]

Bu verileri bir API uç noktası üzerinden sunmak istediğimiz için, Django REST Framework'ü kuruyoruz ve aşağıdaki serileştiricileri tanımlıyoruz ve görüntülüyoruz:

 # 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)

Şimdi, veritabanımızı bazı verilerle dolduruyoruz ( factory-boy kullanılarak oluşturulan toplam 100.000 ev örneği: bir ülke için 50.000, başka bir ülke için 40.000 ve üçüncü bir ülke için 10.000) ve uygulamamızın performansını test etmeye hazırız.

Performans Optimizasyonu Tamamen Ölçümle İlgilidir

Bir projede ölçebileceğimiz birkaç şey vardır:

  • Uygulama vakti
  • Kod satırı sayısı
  • İşlev çağrısı sayısı
  • Tahsis edilen bellek
  • Vb.

Ancak hepsi projemizin ne kadar iyi performans gösterdiğini ölçmekle ilgili değil. Genel olarak konuşursak, en önemli olan iki ana ölçü vardır: bir şeyin ne kadar süreyle yürütüldüğü ve ne kadar belleğe ihtiyaç duyduğu.

Bir web projesinde, yanıt süresi (sunucunun, bir kullanıcının eylemi tarafından oluşturulan bir isteği alması, işlemesi ve sonucu geri göndermesi için gereken süre) genellikle en önemli ölçüdür, çünkü kullanıcıların beklerken sıkılmasına izin vermez. bir yanıt için ve tarayıcılarında başka bir sekmeye geçin.

Programlamada proje performansının analizine profil oluşturma denir. API uç noktamızın performansının profilini çıkarmak için Silk paketini kullanacağız. Kurulumunu yaptıktan ve /api/v1/houses/?country=5T22RI (50.000 ev girişi olan ülkeye karşılık gelen hash) çağrımızı yaptıktan sonra şunu elde ederiz:

200 GET
/api/v1/evler/

toplam 77292ms
sorgularda 15854 ms
50004 sorgu

Toplam yanıt süresi 77 saniye olup, bunun 16 saniyesi toplam 50.000 sorgunun yapıldığı veritabanındaki sorgulara harcanmaktadır. Bu kadar büyük sayılarla, iyileştirme için çok yer var, o yüzden başlayalım.

1. Veritabanı Sorgularını Optimize Etme

Performans optimizasyonuyla ilgili en sık kullanılan ipuçlarından biri, veritabanı sorgularınızın optimize edildiğinden emin olmaktır. Bu durum bir istisna değildir. Ayrıca, yanıt süresini optimize etmek için sorgularımızla ilgili birkaç şey yapabiliriz.

1.1 Tüm verileri bir kerede sağlayın

Bu 50.000 sorgunun ne olduğuna daha yakından bakarsanız, bunların tümünün houses_country tablosu için gereksiz sorgular olduğunu görebilirsiniz:

200 GET
/api/v1/evler/

toplam 77292ms
sorgularda 15854 ms
50004 sorgu

saat tablolar birleşir Yürütme Süresi (ms)
+0:01:15.874374 "evler_ülke" 0 0.176
+0:01:15.873304 "evler_ülke" 0 0.218
+0:01:15.872225 "evler_ülke" 0 0.218
+0:01:15.871155 "evler_ülke" 0 0.198
+0:01:15.870099 "evler_ülke" 0 0.173
+0:01:15.869050 "evler_ülke" 0 0.197
+0:01:15.867877 "evler_ülke" 0 0.221
+0:01:15.866807 "evler_ülke" 0 0.203
+0:01:15.865646 "evler_ülke" 0 0.211
+0:01:15.864562 "evler_ülke" 0 0.209
+0:01:15.863511 "evler_ülke" 0 0.181
+0:01:15.862435 "evler_ülke" 0 0.228
+0:01:15.861413 "evler_ülke" 0 0.174

Bu sorunun kaynağı, Django'da sorgu kümelerinin tembel olmasıdır. Bu, bir sorgu kümesinin değerlendirilmediği ve verileri gerçekten almanız gerekene kadar veritabanına ulaşmadığı anlamına gelir. Aynı zamanda, yalnızca kendisine söylediğiniz verileri alır ve herhangi bir ek veriye ihtiyaç duyulduğunda sonraki taleplerde bulunur.

Bizim durumumuzda tam olarak böyle oldu. House.objects.filter(country=country) aracılığıyla ayarlanan sorguyu alırken, Django verilen ülkedeki tüm evlerin bir listesini alır. Ancak, bir house örneğini serileştirirken, HouseSerializer , seri hale getiricinin country alanını hesaplamak için evin country örneğini gerektirir. Ülke verileri sorgu kümesinde bulunmadığından, Django bu verileri almak için ek bir istekte bulunur. Ve bunu sorgu kümesindeki her ev için yapar - bu, toplamda 50.000 katıdır.

Çözüm çok basit ama. Serileştirme için gerekli tüm verileri çıkarmak için, sorgu kümesinde select_related() yöntemini kullanabilirsiniz. Böylece get_queryset görünecek:

 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

Bunun performansı nasıl etkilediğini görelim:

200 GET
/api/v1/evler/

toplam 35979ms
sorgularda 102 ms
4 sorgu

Toplam yanıt süresi 36 saniyeye düştü ve veritabanında harcanan süre sadece 4 sorgu ile ~100ms oldu! Bu harika bir haber ama daha fazlasını yapabiliriz.

1.2 Yalnızca ilgili verileri sağlayın

Varsayılan olarak, Django tüm alanları veritabanından çıkarır. Bununla birlikte, çok sayıda sütun ve satır içeren büyük tablolarınız olduğunda, Django'ya hangi belirli alanların çıkarılacağını söylemek mantıklıdır, böylece hiç kullanılmayacak bilgileri almak için zaman harcamaz. Bizim durumumuzda, serileştirme için sadece beş alana ihtiyacımız var, ancak 17 alanımız var. Veritabanından tam olarak hangi alanların çıkarılacağını belirtmek mantıklıdır, böylece yanıt süresini daha da kısaltırız.

Django, tam olarak bunu yapmak için defer() ve only() sorgu kümesi yöntemlerine sahiptir. Birincisi hangi alanların yüklenmeyeceğini , ikincisi ise sadece hangi alanların yükleneceğini belirtir.

 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

Bu, sorgulara harcanan süreyi yarıya indirdi, bu iyi, ancak 50ms o kadar da değil. Toplam süre de biraz düştü, ancak kesmek için daha fazla alan var.

200 GET
/api/v1/evler/

toplam 33111 ms
sorgularda 52 ms
4 sorgu

2. Kodunuzu Optimize Etme

Veritabanı sorgularını sonsuz olarak optimize edemezsiniz ve son sonucumuz bunu gösterdi. Sorgulara harcanan süreyi varsayımsal olarak 0'a indirsek bile, yanıt almak için yarım dakika bekleme gerçeğiyle karşı karşıya kalırız. Başka bir optimizasyon düzeyine geçmenin zamanı geldi: iş mantığı .

2.1 Kodunuzu basitleştirin

Bazen, üçüncü taraf paketleri, basit görevler için çok fazla ek yük ile birlikte gelir. Böyle bir örnek, serileştirilmiş ev örneklerini döndürme görevimizdir.

Django REST Framework, kutudan çıktığı gibi birçok kullanışlı özellik ile harika. Ancak şu anki asıl amacımız yanıt süresini kısaltmak, bu nedenle özellikle seri hale getirilmiş nesnelerin oldukça basit olması nedeniyle optimizasyon için harika bir aday.

Bunun için özel bir serileştirici yazalım. Basit tutmak için, işi yapan tek bir statik yöntemimiz olacak. Gerçekte, serileştiricileri birbirinin yerine kullanabilmek için aynı sınıf ve yöntem imzalarına sahip olmak isteyebilirsiniz:

 # 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/evler/

toplam 17312ms
sorgularda 38ms
4 sorgu

Bu şimdi daha iyi görünüyor. DRF serileştirici kodunu kullanmadığımız için yanıt süresi neredeyse yarı yarıya azaldı.

Bir başka ölçülebilir sonuç—istek/yanıt döngüsü sırasında yapılan toplam işlev çağrılarının sayısı—15.859.427 çağrıdan (yukarıdaki 1.2 bölümünde yapılan istekten) 9.257.469 çağrıya düştü. Bu, tüm işlev çağrılarının yaklaşık 1/3'ünün Django REST Framework tarafından yapıldığı anlamına gelir.

2.2 Üçüncü taraf paketlerini güncelleme/değiştirme

Yukarıda açıklanan optimizasyon teknikleri en yaygın olanlarıdır, kapsamlı analiz ve düşünmeden yapabileceğinizlerdir. Ancak, 17 saniye hala oldukça uzun geliyor; Bu sayıyı azaltmak için kodumuzun derinliklerine inmemiz ve kaputun altında neler olduğunu analiz etmemiz gerekecek. Başka bir deyişle, kodumuzu profillememiz gerekecek.

Yerleşik Python profil oluşturucuyu kullanarak profil oluşturmayı kendiniz yapabilirsiniz veya bunun için (yerleşik Python profil oluşturucuyu kullanan) bazı üçüncü taraf paketleri kullanabilirsiniz. Zaten silk kullandığımız için, kodun profilini çıkarabilir ve daha fazla görselleştirebileceğimiz bir ikili profil dosyası oluşturabilir. İkili bir profili bazı anlaşılır görselleştirmelere dönüştüren birkaç görselleştirme paketi vardır. snakeviz paketini kullanacağım.

Görünümün gönderme yöntemine bağlı olarak, yukarıdan gelen son isteğin ikili profilinin görselleştirilmesi:

Görünümün gönderme yönteminin resmi

Yukarıdan aşağıya, dosya adını, satır numarasıyla birlikte yöntem/işlev adını ve bu yöntemde harcanan karşılık gelen kümülatif süreyi gösteren çağrı yığını bulunur. Artık bir aslanın zaman payının hash'i (mor rengin __init__.py ve primes.py dikdörtgenleri) hesaplamaya ayrıldığını görmek daha kolay.

Şu anda, kodumuzdaki ana performans darboğazı budur, ancak aynı zamanda bu gerçekten bizim kodumuz değildir—bir üçüncü taraf paketidir.

Bu gibi durumlarda yapabileceğimiz sınırlı sayıda şey vardır:

  • Paketin yeni bir sürümünü kontrol edin (umarım daha iyi bir performansa sahiptir).
  • İhtiyacımız olan görevlerde daha iyi performans gösteren başka bir paket bulun.
  • Şu anda kullandığımız paketin performansını geçecek kendi uygulamamızı yazın.

Neyse ki benim için, karmadan sorumlu olan basehash paketinin daha yeni bir sürümü var. Kod v.2.1.0 kullanıyor, ancak bir v.3.0.4 var. Bir paketin daha yeni bir sürümüne güncelleme yapabildiğiniz zaman bu tür durumlar, mevcut bir proje üzerinde çalışırken daha olasıdır.

v.3 için sürüm notlarını kontrol ederken, kulağa çok umut verici gelen şu özel cümle var:

Asallık algoritmaları ile büyük bir revizyon yapıldı. Çok daha fazla bir artış için sistemde mevcutsa (sic) gmpy2 için (sic) desteği dahil.

Bunu bulalım!

 pip install -U basehash gmpy2

200 GET
/api/v1/evler/

toplam 7738 ms
sorgularda 59 ms
4 sorgu

Tepki süresini 17 saniyeden 8 saniyenin altına indirdik. Harika sonuç, ancak bakmamız gereken bir şey daha var.

2.3 Kendi kodunuzu yeniden düzenleyin

Şimdiye kadar, sorgularımızı geliştirdik, üçüncü taraf karmaşık ve genel kodu kendi çok özel işlevlerimizle değiştirdik ve üçüncü taraf paketlerini güncelledik, ancak mevcut kodumuza dokunmadık. Ancak bazen mevcut kodun küçük bir yeniden düzenlemesi etkileyici sonuçlar getirebilir. Ancak bunun için profilleme sonuçlarını tekrar analiz etmemiz gerekiyor.

Profil oluşturma sonuçlarının resmi

Daha yakından bakıldığında, bu yönde ilerleme kaydetmiş olsak da, hash'in hala bir sorun olduğunu görebilirsiniz (şaşırtıcı olmayan bir şekilde, verilerimizle yaptığımız tek şey budur). Ancak, __init__.py'nin 2,14 saniye tükettiğini söyleyen yeşilimsi dikdörtgen ve hemen ardından gelen grimsi __init__.py __init__.py:54(hash) beni rahatsız ediyor. Bu, bazı başlatma işlemlerinin uzun zaman alacağı anlamına gelir.

Şimdi basehash paketinin kaynak koduna bir göz atalım.

 # 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

Gördüğünüz gibi, bir base örneğin başlatılması, next_prime işlevinin çağrılmasını gerektirir; Bu, yukarıdaki görselleştirmenin sol alt dikdörtgenlerinde gördüğümüz gibi oldukça ağırdır.

Hash tekrar bir göz atalım:

 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]

Gördüğünüz gibi, gerçekten gerekli olmayan her yöntem çağrısında bir base36 örneğini başlatan iki yöntemi etiketledim.

Hashing deterministik bir prosedür olduğundan, yani belirli bir girdi değeri için her zaman aynı hash değerini üretmesi gerektiği için, bir şeyi kıracağından korkmadan onu bir sınıf niteliği yapabiliriz. Bakalım nasıl performans gösterecek:

 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/evler/

toplam 3766 ms
sorgularda 38ms
4 sorgu

Nihai sonuç, başladığımızdan çok daha küçük olan dört saniyenin altındadır. Önbelleğe alma kullanılarak yanıt süresinin daha fazla optimizasyonu sağlanabilir, ancak bu makalede bunu ele almayacağım.

Çözüm

Performans optimizasyonu, bir analiz ve keşif sürecidir. Her projenin kendi akışı ve darboğazları olduğundan, tüm durumlar için geçerli olan katı kurallar yoktur. Ancak, yapmanız gereken ilk şey, kodunuzun profilini çıkarmaktır. Ve bu kadar kısa bir örnekte yanıt süresini 77 saniyeden 3,7 saniyeye indirebilirsem, devasa projelerin daha da fazla optimizasyon potansiyeli var.

Django ile ilgili daha fazla makale okumakla ilgileniyorsanız, Toptal Django Geliştiricisi Alexandr Shurigin'in Django Geliştiricilerinin Yaptığı İlk 10 Hataya göz atın.