Python Django 效能測試與優化指南
唐納德·克努特(Donald Knuth)曾經說過:“不成熟的優化方案是萬惡之源。”然而,任何一個承受高負載的成熟專案都不可避免地需要進行優化。在本文中,我想談談優化Web專案程式碼的五種常用方法。雖然本文是以Django為例,但其他框架和語言的優化原則也是類似的。通過使用這些優化方法,文中例程的查詢響應時間從原來的77秒減少到了3.7秒。
本文用到的例程是從一個我曾經使用過的真實專案改編而來的,是效能優化技巧的典範。如果你想自己嘗試著進行優化,可以在GitHub上獲取優化前的初始程式碼,並跟著下文做相應的修改。我使用的是Python 2,因為一些第三方軟體包還不支援Python 3。
示例程式碼介紹
這個Web專案只是簡單地跟蹤每個地區的房產價格。因此,只有兩種模型:
Python12345678910111213141516171819202122232425262728293031323334353637383940414243444546 | # houses/models.pyfromutils.hashimportHasherclassHashableModel(models.Model):"""Provide a hash property for models."""classMeta:abstract=True@propertydefhash(self):returnHasher. |
抽象類HashableModel
提供了一個繼承自模型幷包含hash
屬性的模型,這個屬性包含了例項的主鍵和模型的內容型別。 這能夠隱藏像例項ID這樣的敏感資料,而用雜湊進行代替。如果專案中有多個模型,而且需要在一個集中的地方對模型進行解碼並要對不同類的不同模型例項進行處理時,這可能會非常有用。 請注意,對於本文的這個小專案,即使不用雜湊也照樣可以處理,但使用雜湊有助於展示一些優化技巧。
這是Hasher
類:
12345678910111213141516171819202122232425262728293031 | # utils/hash.pyimportbasehashclassHasher(object):@classmethoddeffrom_model(cls,obj,klass=None):ifobj.pk isNone:returnNonereturncls.make_hash(obj.pk,klass ifklass isnotNoneelseobj)@classmethoddefmake_hash(cls,object_pk,klass):base36=basehash.base36()content_type=ContentType.objects.get_for_model(klass,for_concrete_model=False)returnbase36.hash('%(contenttype_pk)03d%(object_pk)06d'%{'contenttype_pk':content_type.pk,'object_pk':object_pk})@classmethoddefparse_hash(cls,obj_hash):base36=basehash.base36()unhashed='%09d'%base36.unhash(obj_hash)contenttype_pk=int(unhashed[:-6])object_pk=int(unhashed[-6:])returncontenttype_pk,object_pk@classmethoddefto_object_pk(cls,obj_hash):returncls.parse_hash(obj_hash)[1] |
由於我們想通過API來提供這些資料,所以我們安裝了Django REST框架並定義以下序列化器和檢視:
Python12345678910111213141516 | # houses/serializers.pyclassHouseSerializer(serializers.ModelSerializer):"""Serialize a `houses.House` instance."""id=serializers.ReadOnlyField(source="hash")country=serializers.ReadOnlyField(source="country.hash")classMeta:model=Housefields=('id','address','country','sq_meters','price') |
1234567891011121314151617181920 | # houses/views.pyclassHouseListAPIView(ListAPIView):model=Houseserializer_class=HouseSerializercountry=Nonedefget_queryset(self):country=get_object_or_404(Country,pk=self.country)queryset=self.model.objects.filter(country=country)returnquerysetdeflist(self,request,*args,**kwargs):# Skipping validation code for brevitycountry=self.request.GET.get("country")self.country=Hasher.to_object_pk(country)queryset=self.get_queryset()serializer=self.serializer_class(queryset,many=True)returnResponse(serializer.data) |
現在,我們將用一些資料來填充資料庫(使用factory-boy
生成10萬個房屋的例項:一個地區5萬個,另一個4萬個,第三個1萬個),並準備測試應用程式的效能。
效能優化其實就是測量
在一個專案中我們需要測量下面這幾個方面:
- 執行時間
- 程式碼的行數
- 函式呼叫次數
- 分配的記憶體
- 其他
但是,並不是所有這些都要用來度量專案的執行情況。一般來說,有兩個指標比較重要:執行多長時間、需要多少記憶體。
在Web專案中,響應時間(伺服器接收由某個使用者的操作產生的請求,處理該請求並返回結果所需的總的時間)通常是最重要的指標,因為過長的響應時間會讓使用者厭倦等待,並切換到瀏覽器中的另一個選項卡頁面。
在程式設計中,分析專案的效能被稱為profiling。為了分析API的效能,我們將使用Silk包。在安裝完這個包,並呼叫/api/v1/houses/?country=5T22RI
後,可以得到如下的結果:
123456 | 200GET/api/v1/houses/77292msoverall15854mson queries50004queries |
整體響應時間為77秒,其中16秒用於查詢資料庫,總共有5萬次查詢。這幾個數字很大,提升空間也有很大,所以,我們開始吧。
1. 優化資料庫查詢
效能優化最常見的技巧之一是對資料庫查詢進行優化,本案例也不例外。同時,還可以對查詢做多次優化來減小響應時間。
1.1 一次提供所有資料
仔細看一下這5萬次查詢查的是什麼:都是對houses_country
表的查詢:
123456 | 200GET/api/v1/houses/77292msoverall15854mson queries50004queries |
時間戳 表名 聯合 執行時間(毫秒)
+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
函式將如下所示:
1234 | defget_queryset(self):country=get_object_or_404(Country,pk=self.country)queryset=self.model.objects.filter(country=country).select_related('country')returnqueryset |
我們來看看這對效能有何影響:
Python123456 | 200GET/api/v1/houses/ |