唐納德·克努特(Donald Knuth)曾經說過:“不成熟的優化方案是萬惡之源。”然而,任何一個承受高負載的成熟專案都不可避免地需要進行優化。在本文中,我想談談優化Web專案程式碼的五種常用方法。雖然本文是以Django為例,但其他框架和語言的優化原則也是類似的。通過使用這些優化方法,文中例程的查詢響應時間從原來的77秒減少到了3.7秒。
本文用到的例程是從一個我曾經使用過的真實專案改編而來的,是效能優化技巧的典範。如果你想自己嘗試著進行優化,可以在GitHub上獲取優化前的初始程式碼,並跟著下文做相應的修改。我使用的是Python 2,因為一些第三方軟體包還不支援Python 3。
示例程式碼介紹
這個Web專案只是簡單地跟蹤每個地區的房產價格。因此,只有兩種模型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
# 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
提供了一個繼承自模型幷包含hash
屬性的模型,這個屬性包含了例項的主鍵和模型的內容型別。 這能夠隱藏像例項ID這樣的敏感資料,而用雜湊進行代替。如果專案中有多個模型,而且需要在一個集中的地方對模型進行解碼並要對不同類的不同模型例項進行處理時,這可能會非常有用。 請注意,對於本文的這個小專案,即使不用雜湊也照樣可以處理,但使用雜湊有助於展示一些優化技巧。
這是Hasher
類:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
# 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] |
由於我們想通過API來提供這些資料,所以我們安裝了Django REST框架並定義以下序列化器和檢視:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# 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' ) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# 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) |
現在,我們將用一些資料來填充資料庫(使用factory-boy
生成10萬個房屋的例項:一個地區5萬個,另一個4萬個,第三個1萬個),並準備測試應用程式的效能。
效能優化其實就是測量
在一個專案中我們需要測量下面這幾個方面:
- 執行時間
- 程式碼的行數
- 函式呼叫次數
- 分配的記憶體
- 其他
但是,並不是所有這些都要用來度量專案的執行情況。一般來說,有兩個指標比較重要:執行多長時間、需要多少記憶體。
在Web專案中,響應時間(伺服器接收由某個使用者的操作產生的請求,處理該請求並返回結果所需的總的時間)通常是最重要的指標,因為過長的響應時間會讓使用者厭倦等待,並切換到瀏覽器中的另一個選項卡頁面。
在程式設計中,分析專案的效能被稱為profiling。為了分析API的效能,我們將使用Silk包。在安裝完這個包,並呼叫/api/v1/houses/?country=5T22RI
後,可以得到如下的結果:
1 2 3 4 5 6 |
200 GET /api/v1/houses/ 77292ms overall 15854ms on queries 50004 queries |
整體響應時間為77秒,其中16秒用於查詢資料庫,總共有5萬次查詢。這幾個數字很大,提升空間也有很大,所以,我們開始吧。
1. 優化資料庫查詢
效能優化最常見的技巧之一是對資料庫查詢進行優化,本案例也不例外。同時,還可以對查詢做多次優化來減小響應時間。
1.1 一次提供所有資料
仔細看一下這5萬次查詢查的是什麼:都是對houses_country
表的查詢:
1 2 3 4 5 6 |
200 GET /api/v1/houses/ 77292ms overall 15854ms on queries 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
函式將如下所示:
1 2 3 4 |
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 |
我們來看看這對效能有何影響:
1 2 3 4 5 6 |
200 GET /api/v1/houses/ 35979ms overall 102ms on queries 4 queries |
總體響應時間降至36秒,在資料庫中花費的時間約為100ms,只有4個查詢!這是個好訊息,但我們可以做得更多。
1.2 僅提供相關的資料
預設情況下,Django會從資料庫中提取所有欄位。但是,當表有很多列很多行的時候,告訴Django提取哪些特定的欄位就非常有意義了,這樣就不會花時間去獲取根本用不到的資訊。在本案例中,我們只需要5個欄位來進行序列化,雖然表中有17個欄位。明確指定從資料庫中提取哪些欄位是很有意義的,可以進一步縮短響應時間。
Django可以使用defer()
和only()
這兩個查詢方法來實現這一點。第一個用於指定哪些欄位不要載入,第二個用於指定只載入哪些欄位。
1 2 3 4 5 6 |
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 |
這減少了一半的查詢時間,非常不錯。總體時間也略有下降,但還有更多提升空間。
1 2 3 4 5 6 |
200 GET /api/v1/houses/ 33111ms overall 52ms on queries 4 queries |
2. 程式碼優化
你不能無限制地優化資料庫查詢,並且上面的結果也證明了這一點。即使把查詢時間減少到0,我們仍然會面對需要等待半分鐘才能得到應答這個現實。現在是時候轉移到另一個優化級別上來了,那就是:業務邏輯。
2.1 簡化程式碼
有時,第三方軟體包對於簡單的任務來說有著太大的開銷。本文例程中返回的序列化的房子例項正說明了這一點。
Django REST框架非常棒,包含了很多有用的功能。但是,現在的主要目標是縮短響應時間,所以該框架是優化的候選物件,尤其是我們要使用的序列化物件這個功能非常的簡單。
為此,我們來編寫一個自定義的序列化器。為了方便起見,我們將用一個靜態方法來完成這項工作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
# 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) |
1 2 3 4 5 6 |
200 GET /api/v1/houses/ 17312ms overall 38ms on queries 4 queries |
現在看起來好多了,由於沒有使用DRF序列化程式碼,所以響應時間幾乎減少了一半。
另外還有一個結果:在請求/響應週期內完成的總的函式呼叫次數從15,859,427次(上面1.2節的請求次數)減少到了9,257,469次。這意味著大約有三分之一的函式呼叫都是由Django REST Framework產生的。
2.2 更新或替代第三方軟體包
上述幾個優化技巧是最常見的,無需深入地分析和思考就可以做到。然而,17秒的響應時間仍然感覺很長。要減少這個時間,需要更深入地瞭解程式碼,分析底層發生了什麼。換句話說,需要分析一下程式碼。
你可以自己使用Python內建的分析器來進行分析,也可以使用一些第三方軟體包。由於我們已經使用了silk
,它可以分析程式碼並生成一個二進位制的分析檔案,因此,我們可以做進一步的視覺化分析。有好幾個視覺化軟體包可以將二進位制檔案轉換為一些友好的視覺化檢視。本文將使用snakeviz
。
這是上文一個請求的二進位制分析檔案的視覺化圖表:
從上到下是呼叫堆疊,顯示了檔名、函式名及其行號,以及該方法花費的時間。可以很容易地看出,時間大部分都用在計算雜湊上(紫羅蘭色的__init__.py
和primes.py
矩形)。
目前,這是程式碼的主要效能瓶頸,但同時,這不是我們自己寫的程式碼,而是用的第三方包。
在這種情況下,我們可以做的事情將非常有限:
- 檢查包的最新版本(希望能有更好的效能)。
- 尋找另一個能夠滿足我們需求的軟體包。
- 我們自己寫程式碼,並且效能優於目前使用的軟體包。
幸運的是,我們找到了一個更新版本的basehash
包。原始碼使用的是v.2.1.0,而新的是v.3.0.4。
當檢視v.3的發行說明時,這一句話看起來令人充滿希望:
“使用素數演算法進行大規模的優化。”
讓我們來看一下!
1 |
pip install -U basehash gmpy2 |
1 2 3 4 5 6 |
200 GET /api/v1/houses/ 7738ms overall 59ms on queries 4 queries |
響應時間從17秒縮短到了8秒以內。太棒了!但還有一件事我們應該來看看。
2.3 重構程式碼
到目前為止,我們已經改進了查詢、用自己特定的函式取代了第三方複雜而又泛型的程式碼、更新了第三方包,但是我們還是保留了原有的程式碼。但有時,對現有程式碼進行小規模的重構可能會帶來意想不到的結果。但是,為此我們需要再次分析執行結果。
仔細看一下,你可以看到雜湊仍然是一個問題(毫不奇怪,這是我們對資料做的唯一的事情),雖然我們確實朝這個方向改進了,但這個綠色的矩形表示__init__.py
花了2.14秒的時間,同時伴隨著灰色的__init__.py:54(hash)
。這意味著初始化工作需要很長的時間。
我們來看看basehash
包的原始碼。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
# 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 |
正如你所看到的,一個base
例項的初始化需要呼叫next_prime
函式,這是太重了,我們可以在上面的視覺化圖表中看到左下角的矩形。
我們再來看看Hash
類:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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] |
正如你所看到的,我已經標記了這兩個方法初始化base36
例項的方法,這並不是真正需要的。
由於雜湊是一個確定性的過程,這意味著對於一個給定的輸入值,它必須始終生成相同的雜湊值,因此,我們可以把它作為類的一個屬性。讓我們來看看它將如何執行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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] |
1 2 3 4 5 6 7 |
**200 GET** /api/v1/houses/ 3766ms overall 38ms on queries 4 queries |
最後的結果是在4秒鐘之內,比我們一開始的時間要小得多。對響應時間的進一步優化可以通過使用快取來實現,但是我不會在這篇文章中介紹這個。
結論
效能優化是一個分析和發現的過程。 沒有哪個硬性規定能適用於所有情況,因為每個專案都有自己的流程和瓶頸。 然而,你應該做的第一件事是分析程式碼。 如果在這樣一個簡短的例子中,我可以將響應時間從77秒縮短到3.7秒,那麼對於一個龐大的專案來說,就會有更大的優化潛力。