Python Django性能測試與優化指南

摘要:本文經過一個簡單的實例一步一步引導讀者對其進行全方位的性能優化。如下是譯文。git

 

唐納德·克努特(Donald Knuth)曾經說過:「不成熟的優化方案是萬惡之源。」然而,任何一個承受高負載的成熟項目都不可避免地須要進行優化。在本文中,我想談談優化Web項目代碼的五種經常使用方法。雖然本文是以Django爲例,但其餘框架和語言的優化原則也是相似的。經過使用這些優化方法,文中例程的查詢響應時間從原來的77秒減小到了3.7秒。github


Guide to Performance Optimization and Performance Testing With Python and Django

 

本文用到的例程是從一個我曾經使用過的真實項目改編而來的,是性能優化技巧的典範。若是你想本身嘗試着進行優化,能夠在GitHub上獲取優化前的初始代碼,並跟着下文作相應的修改。我使用的是Python 2,由於一些第三方軟件包還不支持Python 3。算法

 

示例代碼介紹

 

這個Web項目只是簡單地跟蹤每一個地區的房產價格。所以,只有兩種模型:數據庫

Python代碼 django

  1. # houses/models.py  
  2. from utils.hash import Hasher  
  3.   
  4.   
  5. class HashableModel(models.Model):  
  6.     """Provide a hash property for models."""  
  7.     class Meta:  
  8.         abstract = True  
  9.  
  10.     @property  
  11.     def hash(self):  
  12.         return Hasher.from_model(self)  
  13.   
  14.   
  15. class Country(HashableModel):  
  16.     """Represent a country in which the house is positioned."""  
  17.     name = models.CharField(max_length=30)  
  18.   
  19.     def __unicode__(self):  
  20.         return self.name  
  21.   
  22.   
  23. class House(HashableModel):  
  24.     """Represent a house with its characteristics."""  
  25.     # Relations  
  26.     country = models.ForeignKey(Country, related_name='houses')  
  27.   
  28.     # Attributes  
  29.     address = models.CharField(max_length=255)  
  30.     sq_meters = models.PositiveIntegerField()  
  31.     kitchen_sq_meters = models.PositiveSmallIntegerField()  
  32.     nr_bedrooms = models.PositiveSmallIntegerField()  
  33.     nr_bathrooms = models.PositiveSmallIntegerField()  
  34.     nr_floors = models.PositiveSmallIntegerField(default=1)  
  35.     year_built = models.PositiveIntegerField(null=True, blank=True)  
  36.     house_color_outside = models.CharField(max_length=20)  
  37.     distance_to_nearest_kindergarten = models.PositiveIntegerField(null=True, blank=True)  
  38.     distance_to_nearest_school = models.PositiveIntegerField(null=True, blank=True)  
  39.     distance_to_nearest_hospital = models.PositiveIntegerField(null=True, blank=True)  
  40.     has_cellar = models.BooleanField(default=False)  
  41.     has_pool = models.BooleanField(default=False)  
  42.     has_garage = models.BooleanField(default=False)  
  43.     price = models.PositiveIntegerField()  
  44.   
  45.     def __unicode__(self):  
  46.         return '{} {}'.format(self.country, self.address)  

 

抽象類HashableModel提供了一個繼承自模型幷包含hash屬性的模型,這個屬性包含了實例的主鍵和模型的內容類型。 這可以隱藏像實例ID這樣的敏感數據,而用散列進行代替。若是項目中有多個模型,並且須要在一個集中的地方對模型進行解碼並要對不一樣類的不一樣模型實例進行處理時,這可能會很是有用。 請注意,對於本文的這個小項目,即便不用散列也照樣能夠處理,但使用散列有助於展現一些優化技巧。編程

 

這是Hasher類:api

Python代碼 瀏覽器

  1. # utils/hash.py  
  2. import basehash  
  3.   
  4.   
  5. class Hasher(object):  
  6.     @classmethod  
  7.     def from_model(cls, obj, klass=None):  
  8.         if obj.pk is None:  
  9.             return None  
  10.         return cls.make_hash(obj.pk, klass if klass is not None else obj)  
  11.  
  12.     @classmethod  
  13.     def make_hash(cls, object_pk, klass):  
  14.         base36 = basehash.base36()  
  15.         content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False)  
  16.         return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % {  
  17.             'contenttype_pk': content_type.pk,  
  18.             'object_pk': object_pk  
  19.         })  
  20.  
  21.     @classmethod  
  22.     def parse_hash(cls, obj_hash):  
  23.         base36 = basehash.base36()  
  24.         unhashed = '%09d' % base36.unhash(obj_hash)  
  25.         contenttype_pk = int(unhashed[:-6])  
  26.         object_pk = int(unhashed[-6:])  
  27.         return contenttype_pk, object_pk  
  28.  
  29.     @classmethod  
  30.     def to_object_pk(cls, obj_hash):      
  31.         return cls.parse_hash(obj_hash)[1]  

 

因爲咱們想經過API來提供這些數據,因此咱們安裝了Django REST框架並定義如下序列化器和視圖:緩存

Python代碼 性能優化

  1. # houses/serializers.py  
  2. class HouseSerializer(serializers.ModelSerializer):  
  3.     """Serialize a `houses.House` instance."""  
  4.   
  5.     id = serializers.ReadOnlyField(source="hash")  
  6.     country = serializers.ReadOnlyField(source="country.hash")  
  7.   
  8.     class Meta:  
  9.         model = House  
  10.         fields = (  
  11.             'id',  
  12.             'address',  
  13.             'country',  
  14.             'sq_meters',  
  15.             'price'  
  16.         )  

 

Python代碼 

  1. # houses/views.py  
  2. class HouseListAPIView(ListAPIView):  
  3.     model = House  
  4.     serializer_class = HouseSerializer  
  5.     country = None  
  6.   
  7.     def get_queryset(self):  
  8.         country = get_object_or_404(Country, pk=self.country)  
  9.         queryset = self.model.objects.filter(country=country)  
  10.         return queryset  
  11.   
  12.     def list(self, request, *args, **kwargs):  
  13.         # Skipping validation code for brevity  
  14.         country = self.request.GET.get("country")  
  15.         self.country = Hasher.to_object_pk(country)  
  16.         queryset = self.get_queryset()  
  17.   
  18.         serializer = self.serializer_class(queryset, many=True)  
  19.   
  20.         return Response(serializer.data)  

 

如今,咱們將用一些數據來填充數據庫(使用factory-boy生成10萬個房屋的實例:一個地區5萬個,另外一個4萬個,第三個1萬個),並準備測試應用程序的性能。

 

性能優化其實就是測量

 

在一個項目中咱們須要測量下面這幾個方面:

 

  • 執行時間
  • 代碼的行數
  • 函數調用次數
  • 分配的內存
  • 其餘

可是,並非全部這些都要用來度量項目的執行狀況。通常來講,有兩個指標比較重要:執行多長時間、須要多少內存。

 

在Web項目中,響應時間(服務器接收由某個用戶的操做產生的請求,處理該請求並返回結果所需的總的時間)一般是最重要的指標,由於過長的響應時間會讓用戶厭倦等待,並切換到瀏覽器中的另外一個選項卡頁面。

 

在編程中,分析項目的性能被稱爲profiling。爲了分析API的性能,咱們將使用Silk包。在安裝完這個包,並調用/api/v1/houses/?country=5T22RI後,能夠獲得以下的結果:

Python代碼 

  1. 200 GET   
  2. /api/v1/houses/  
  3.   
  4. 77292ms overall  
  5. 15854ms on queries  
  6. 50004 queries  

 

 總體響應時間爲77秒,其中16秒用於查詢數據庫,總共有5萬次查詢。這幾個數字很大,提高空間也有很大,因此,咱們開始吧。

 

1. 優化數據庫查詢

 

性能優化最多見的技巧之一是對數據庫查詢進行優化,本案例也不例外。同時,還能夠對查詢作屢次優化來減少響應時間。

 

1.1 一次提供全部數據

 

仔細看一下這5萬次查詢查的是什麼:都是對houses_country表的查詢:

Python代碼 

  1. 200 GET   
  2. /api/v1/houses/  
  3.   
  4. 77292ms overall  
  5. 15854ms on queries  
  6. 50004 queries  

 

時間戳 表名 聯合 執行時間(毫秒)

+0:01 :15.874374 「houses_country」 0 0.176
+0:01 :15.873304 「houses_country」 0 0.218
+0:01 :15.872225 「houses_country」 0 0.218
+0:01 :15.871155 「houses_country」 0 0.198
+0:01 :15.870099 「houses_country」 0 0.173
+0:01 :15.869050 「houses_country」 0 0.197
+0:01 :15.867877 「houses_country」 0 0.221
+0:01 :15.866807 「houses_country」 0 0.203
+0:01 :15.865646 「houses_country」 0 0.211
+0:01 :15.864562 「houses_country」 0 0.209
+0:01 :15.863511 「houses_country」 0 0.181
+0:01 :15.862435 「houses_country」 0 0.228
+0:01 :15.861413 「houses_country」 0 0.174

 

這個問題的根源是,Django中的查詢是惰性的。這意味着在你真正須要獲取數據以前它不會訪問數據庫。同時,它只獲取你指定的數據,若是須要其餘附加數據,則要另外發出請求。

 

這正是本例程所遇到的狀況。當經過House.objects.filter(country=country)來得到查詢集時,Django將獲取特定地區的全部房屋。可是,在序列化一個house實例時,HouseSerializer須要房子的country實例來計算序列化器的country字段。因爲地區數據不在查詢集中,因此django須要提出額外的請求來獲取這些數據。對於查詢集中的每個房子都是如此,所以,總共是五萬次。

 

固然,解決方案很是簡單。爲了提取全部須要的序列化數據,你能夠在查詢集上使用select_related()。所以,get_queryset函數將以下所示:

Python代碼 

  1. def get_queryset(self):  
  2.     country = get_object_or_404(Country, pk=self.country)  
  3.     queryset = self.model.objects.filter(country=country).select_related('country')  
  4.     return queryset  

 

咱們來看看這對性能有何影響:

Python代碼 

  1. 200 GET  
  2. /api/v1/houses/  
  3.   
  4. 35979ms overall  
  5. 102ms on queries  
  6. 4 queries  

 

整體響應時間降至36秒,在數據庫中花費的時間約爲100ms,只有4個查詢!這是個好消息,但咱們能夠作得更多。

 

1.2 僅提供相關的數據

 

默認狀況下,Django會從數據庫中提取全部字段。可是,當表有不少列不少行的時候,告訴Django提取哪些特定的字段就很是有意義了,這樣就不會花時間去獲取根本用不到的信息。在本案例中,咱們只須要5個字段來進行序列化,雖然表中有17個字段。明確指定從數據庫中提取哪些字段是頗有意義的,能夠進一步縮短響應時間。

 

Django可使用defer()only()這兩個查詢方法來實現這一點。第一個用於指定哪些字段不要加載,第二個用於指定只加載哪些字段。

Python代碼 

  1. def get_queryset(self):  
  2.     country = get_object_or_404(Country, pk=self.country)  
  3.     queryset = self.model.objects.filter(country=country)\  
  4.         .select_related('country')\  
  5.         .only('id', 'address', 'country', 'sq_meters', 'price')  
  6.     return queryset  

 

這減小了一半的查詢時間,很是不錯。整體時間也略有降低,但還有更多提高空間。

Python代碼 

  1. 200 GET  
  2. /api/v1/houses/  
  3.   
  4. 33111ms overall  
  5. 52ms on queries  
  6. 4 queries  

 

2. 代碼優化

 

你不能無限制地優化數據庫查詢,而且上面的結果也證實了這一點。即便把查詢時間減小到0,咱們仍然會面對須要等待半分鐘才能獲得應答這個現實。如今是時候轉移到另外一個優化級別上來了,那就是:業務邏輯

 

2.1 簡化代碼

 

有時,第三方軟件包對於簡單的任務來講有着太大的開銷。本文例程中返回的序列化的房子實例正說明了這一點。

 

Django REST框架很是棒,包含了不少有用的功能。可是,如今的主要目標是縮短響應時間,因此該框架是優化的候選對象,尤爲是咱們要使用的序列化對象這個功能很是的簡單。

 

爲此,咱們來編寫一個自定義的序列化器。爲了方便起見,咱們將用一個靜態方法來完成這項工做。

Python代碼 

  1. # houses/serializers.py  
  2. class HousePlainSerializer(object):  
  3.     """ 
  4.     Serializes a House queryset consisting of dicts with 
  5.     the following keys: 'id', 'address', 'country', 
  6.     'sq_meters', 'price'. 
  7.     """  
  8.  
  9.     @staticmethod  
  10.     def serialize_data(queryset):  
  11.         """ 
  12.         Return a list of hashed objects from the given queryset. 
  13.         """  
  14.         return [  
  15.             {  
  16.                 'id': Hasher.from_pk_and_class(entry['id'], House),  
  17.                 'address': entry['address'],  
  18.                 'country': Hasher.from_pk_and_class(entry['country'], Country),  
  19.                 'sq_meters': entry['sq_meters'],  
  20.                 'price': entry['price']  
  21.             } for entry in queryset  
  22.         ]  
  23.   
  24.   
  25. # houses/views.py  
  26. class HouseListAPIView(ListAPIView):  
  27.     model = House  
  28.     serializer_class = HouseSerializer  
  29.     plain_serializer_class = HousePlainSerializer  # <-- added custom serializer  
  30.     country = None  
  31.   
  32.     def get_queryset(self):  
  33.         country = get_object_or_404(Country, pk=self.country)  
  34.         queryset = self.model.objects.filter(country=country)  
  35.         return queryset  
  36.   
  37.     def list(self, request, *args, **kwargs):  
  38.         # Skipping validation code for brevity  
  39.         country = self.request.GET.get("country")  
  40.         self.country = Hasher.to_object_pk(country)  
  41.         queryset = self.get_queryset()  
  42.   
  43.         data = self.plain_serializer_class.serialize_data(queryset)  # <-- serialize  
  44.   
  45.         return Response(data)  

 

Python代碼 

  1. 200 GET  
  2. /api/v1/houses/  
  3.   
  4. 17312ms overall  
  5. 38ms on queries  
  6. 4 queries  

 

如今看起來好多了,因爲沒有使用DRF序列化代碼,因此響應時間幾乎減小了一半。

 

另外還有一個結果:在請求/響應週期內完成的總的函數調用次數從15,859,427次(上面1.2節的請求次數)減小到了9,257,469次。這意味着大約有三分之一的函數調用都是由Django REST Framework產生的。

 

2.2 更新或替代第三方軟件包

 

上述幾個優化技巧是最多見的,無需深刻地分析和思考就能夠作到。然而,17秒的響應時間仍然感受很長。要減小這個時間,須要更深刻地瞭解代碼,分析底層發生了什麼。換句話說,須要分析一下代碼。

 

你能夠本身使用Python內置的分析器來進行分析,也可使用一些第三方軟件包。因爲咱們已經使用了silk,它能夠分析代碼並生成一個二進制的分析文件,所以,咱們能夠作進一步的可視化分析。有好幾個可視化軟件包能夠將二進制文件轉換爲一些友好的可視化視圖。本文將使用snakeviz

 

這是上文一個請求的二進制分析文件的可視化圖表:


Image of the view's dispatch method

從上到下是調用堆棧,顯示了文件名、函數名及其行號,以及該方法花費的時間。能夠很容易地看出,時間大部分都用在計算散列上(紫羅蘭色的__init__.pyprimes.py矩形)。

 

目前,這是代碼的主要性能瓶頸,但同時,這不是咱們本身寫的代碼,而是用的第三方包。

 

在這種狀況下,咱們能夠作的事情將很是有限:

 

  • 檢查包的最新版本(但願能有更好的性能)。
  • 尋找另外一個可以知足咱們需求的軟件包。
  • 咱們本身寫代碼,而且性能優於目前使用的軟件包。

幸運的是,咱們找到了一個更新版本的basehash包。原代碼使用的是v.2.1.0,而新的是v.3.0.4。

 

當查看v.3的發行說明時,這一句話看起來使人充滿但願:

「使用素數算法進行大規模的優化。」

 讓咱們來看一下!

Python代碼 

  1. pip install -U basehash gmpy2  

 

Python代碼 

  1. 200 GET  
  2. /api/v1/houses/  
  3.   
  4. 7738ms overall  
  5. 59ms on queries  
  6. 4 queries  

 

 響應時間從17秒縮短到了8秒之內。太棒了!但還有一件事咱們應該來看看。

 

2.3 重構代碼

 

到目前爲止,咱們已經改進了查詢、用本身特定的函數取代了第三方複雜而又泛型的代碼、更新了第三方包,可是咱們仍是保留了原有的代碼。但有時,對現有代碼進行小規模的重構可能會帶來意想不到的結果。可是,爲此咱們須要再次分析運行結果。


Image of profiling results

 仔細看一下,你能夠看到散列仍然是一個問題(絕不奇怪,這是咱們對數據作的惟一的事情),雖然咱們確實朝這個方向改進了,但這個綠色的矩形表示__init__.py花了2.14秒的時間,同時伴隨着灰色的__init__.py:54(hash)。這意味着初始化工做須要很長的時間。

 

咱們來看看basehash包的源代碼。

Python代碼 

  1. # basehash/__init__.py  
  2.   
  3. # Initialization of `base36` class initializes the parent, `base` class.  
  4. class base36(base):  
  5.     def __init__(self, length=HASH_LENGTH, generator=GENERATOR):  
  6.         super(base36, self).__init__(BASE36, length, generator)  
  7.   
  8.   
  9. class base(object):  
  10.     def __init__(self, alphabet, length=HASH_LENGTH, generator=GENERATOR):  
  11.         if len(set(alphabet)) != len(alphabet):  
  12.             raise ValueError('Supplied alphabet cannot contain duplicates.')  
  13.   
  14.         self.alphabet = tuple(alphabet)  
  15.         self.base = len(alphabet)  
  16.         self.length = length  
  17.         self.generator = generator  
  18.         self.maximum = self.base ** self.length - 1  
  19.         self.prime = next_prime(int((self.maximum + 1) * self.generator))  # `next_prime` call on each initialized instance  

 

 正如你所看到的,一個base實例的初始化須要調用next_prime函數,這是過重了,咱們能夠在上面的可視化圖表中看到左下角的矩形。

 

咱們再來看看Hash類:

Python代碼 

  1. class Hasher(object):  
  2.     @classmethod  
  3.     def from_model(cls, obj, klass=None):  
  4.         if obj.pk is None:  
  5.             return None  
  6.         return cls.make_hash(obj.pk, klass if klass is not None else obj)  
  7.  
  8.     @classmethod  
  9.     def make_hash(cls, object_pk, klass):  
  10.         base36 = basehash.base36()  # <-- initializing on each method call  
  11.         content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False)  
  12.         return base36.hash('%(contenttype_pk)03d%(object_pk)06d' % {  
  13.             'contenttype_pk': content_type.pk,  
  14.             'object_pk': object_pk  
  15.         })  
  16.  
  17.     @classmethod  
  18.     def parse_hash(cls, obj_hash):  
  19.         base36 = basehash.base36()  # <-- initializing on each method call  
  20.         unhashed = '%09d' % base36.unhash(obj_hash)  
  21.         contenttype_pk = int(unhashed[:-6])  
  22.         object_pk = int(unhashed[-6:])  
  23.         return contenttype_pk, object_pk  
  24.  
  25.     @classmethod  
  26.     def to_object_pk(cls, obj_hash):      
  27.         return cls.parse_hash(obj_hash)[1]  

 

 正如你所看到的,我已經標記了這兩個方法初始化base36實例的方法,這並非真正須要的。

因爲散列是一個肯定性的過程,這意味着對於一個給定的輸入值,它必須始終生成相同的散列值,所以,咱們能夠把它做爲類的一個屬性。讓咱們來看看它將如何執行:

Python代碼 

  1. class Hasher(object):  
  2.     base36 = basehash.base36()  # <-- initialize hasher only once  
  3.  
  4.     @classmethod  
  5.     def from_model(cls, obj, klass=None):  
  6.         if obj.pk is None:  
  7.             return None  
  8.         return cls.make_hash(obj.pk, klass if klass is not None else obj)  
  9.  
  10.     @classmethod  
  11.     def make_hash(cls, object_pk, klass):  
  12.         content_type = ContentType.objects.get_for_model(klass, for_concrete_model=False)  
  13.         return cls.base36.hash('%(contenttype_pk)03d%(object_pk)06d' % {  
  14.             'contenttype_pk': content_type.pk,  
  15.             'object_pk': object_pk  
  16.         })  
  17.  
  18.     @classmethod  
  19.     def parse_hash(cls, obj_hash):  
  20.         unhashed = '%09d' % cls.base36.unhash(obj_hash)  
  21.         contenttype_pk = int(unhashed[:-6])  
  22.         object_pk = int(unhashed[-6:])  
  23.         return contenttype_pk, object_pk  
  24.  
  25.     @classmethod  
  26.     def to_object_pk(cls, obj_hash):      
  27.         return cls.parse_hash(obj_hash)[1]  

 

Python代碼 

  1. **200 GET**  
  2.   
  3.   /api/v1/houses/  
  4.   
  5. 3766ms overall  
  6. 38ms on queries  
  7. 4 queries  

 

 最後的結果是在4秒鐘以內,比咱們一開始的時間要小得多。對響應時間的進一步優化能夠經過使用緩存來實現,可是我不會在這篇文章中介紹這個。

 

結論

 

性能優化是一個分析和發現的過程。 沒有哪一個硬性規定能適用於全部狀況,由於每一個項目都有本身的流程和瓶頸。 然而,你應該作的第一件事是分析代碼。 若是在這樣一個簡短的例子中,我能夠將響應時間從77秒縮短到3.7秒,那麼對於一個龐大的項目來講,就會有更大的優化潛力。

相關文章
相關標籤/搜索