唐納德·克努特(Donald Knuth)曾經說過:“不成熟的優化方案是萬惡之源。”然而,任何一個承受高負載的成熟項目都不可避免地需要進行優化。在本文中,我想談談 優化Web項目代碼的五種常用方法 。雖然本文是以Django為例,但其他框架和語言的優化原則也是類似的。通過使用這些優化方法,文中例程的查詢響應時間從原來的77秒減少到了3.7秒。
本文用到的例程是從一個我曾經使用過的真實項目改編而來的,是性能優化技巧的典范。如果你想自己嘗試著進行優化,可以在 GitHub 上獲取優化前的初始代碼,并跟著下文做相應的修改。我使用的是Python 2,因為一些第三方軟件包還不支持Python 3。
這個Web項目只是簡單地跟蹤每個地區的房產價格。因此,只有兩種模型:
# 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
類:
# 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框架并定義以下序列化器和視圖:
# 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)
現在,我們將用一些數據來填充數據庫(使用 factory-boy
生成10萬個房屋的實例:一個地區5萬個,另一個4萬個,第三個1萬個),并準備測試應用程序的性能。
在一個項目中我們需要測量下面這幾個方面:
但是,并不是所有這些都要用來度量項目的執行情況。一般來說,有兩個指標比較重要:執行多長時間、需要多少內存。
在Web項目中, 響應時間 (服務器接收由某個用戶的操作產生的請求,處理該請求并返回結果所需的總的時間)通常是最重要的指標,因為過長的響應時間會讓用戶厭倦等待,并切換到瀏覽器中的另一個選項卡頁面。
在編程中,分析項目的性能被稱為 profiling 。為了分析API的性能,我們將使用 Silk 包。在安裝完這個包,并調用 /api/v1/houses/?country=5T22RI
后,可以得到如下的結果:
200 GET
/api/v1/houses/
77292ms overall
15854ms on queries
50004 queries
整體響應時間為77秒,其中16秒用于查詢數據庫,總共有5萬次查詢。這幾個數字很大,提升空間也有很大,所以,我們開始吧。
性能優化最常見的技巧之一是對數據庫查詢進行優化,本案例也不例外。同時,還可以對查詢做多次優化來減小響應時間。
仔細看一下這5萬次查詢查的是什么:都是對 houses_country
表的查詢:
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
函數將如下所示:
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
我們來看看這對性能有何影響:
200 GET
/api/v1/houses/
35979ms overall
102ms on queries
4 queries
總體響應時間降至36秒,在數據庫中花費的時間約為100ms,只有4個查詢!這是個好消息,但我們可以做得更多。
默認情況下,Django會從數據庫中提取所有字段。但是,當表有很多列很多行的時候,告訴Django提取哪些特定的字段就非常有意義了,這樣就不會花時間去獲取根本用不到的信息。在本案例中,我們只需要5個字段來進行序列化,雖然表中有17個字段。明確指定從數據庫中提取哪些字段是很有意義的,可以進一步縮短響應時間。
Django可以使用 defer()
和 only()
這兩個查詢方法來實現這一點。第一個用于指定哪些字段 不要加載 ,第二個用于指定 只加載 哪些字段。
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
這減少了一半的查詢時間,非常不錯??傮w時間也略有下降,但還有更多提升空間。
200 GET
/api/v1/houses/
33111ms overall
52ms on queries
4 queries
你不能無限制地優化數據庫查詢,并且上面的結果也證明了這一點。即使把查詢時間減少到0,我們仍然會面對需要等待半分鐘才能得到應答這個現實?,F在是時候轉移到另一個優化級別上來了,那就是: 業務邏輯 。
有時,第三方軟件包對于簡單的任務來說有著太大的開銷。本文例程中返回的序列化的房子實例正說明了這一點。
Django REST框架非常棒,包含了很多有用的功能。但是,現在的主要目標是縮短響應時間,所以該框架是優化的候選對象,尤其是我們要使用的序列化對象這個功能非常的簡單。
為此,我們來編寫一個自定義的序列化器。為了方便起見,我們將用一個靜態方法來完成這項工作。
# 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/houses/
17312ms overall
38ms on queries
4 queries
現在看起來好多了,由于沒有使用DRF序列化代碼,所以響應時間幾乎減少了一半。
另外還有一個結果:在請求/響應周期內完成的總的函數調用次數從15,859,427次(上面1.2節的請求次數)減少到了9,257,469次。這意味著大約有三分之一的函數調用都是由Django REST Framework產生的。
上述幾個優化技巧是最常見的,無需深入地分析和思考就可以做到。然而,17秒的響應時間仍然感覺很長。要減少這個時間,需要更深入地了解代碼,分析底層發生了什么。換句話說,需要分析一下代碼。
你可以自己使用Python內置的分析器來進行分析,也可以使用一些第三方軟件包。由于我們已經使用了 silk
,它可以分析代碼并生成一個二進制的分析文件,因此,我們可以做進一步的可視化分析。有好幾個可視化軟件包可以將二進制文件轉換為一些友好的可視化視圖。本文將使用 snakeviz
。
這是上文一個請求的二進制分析文件的可視化圖表:
從上到下是調用堆棧,顯示了文件名、函數名及其行號,以及該方法花費的時間??梢院苋菀椎乜闯?,時間大部分都用在計算散列上(紫羅蘭色的 __init__.py
和 primes.py
矩形)。
目前,這是代碼的主要性能瓶頸,但同時,這不是我們自己寫的代碼,而是用的第三方包。
在這種情況下,我們可以做的事情將非常有限:
幸運的是,我們找到了一個更新版本的 basehash
包。原代碼使用的是v.2.1.0,而新的是v.3.0.4。
當查看v.3的發行說明時,這一句話看起來令人充滿希望:
“使用素數算法進行大規模的優化。”
讓我們來看一下!
pip install -U basehash gmpy2
200 GET
/api/v1/houses/
7738ms overall
59ms on queries
4 queries
響應時間從17秒縮短到了8秒以內。太棒了!但還有一件事我們應該來看看。
到目前為止,我們已經改進了查詢、用自己特定的函數取代了第三方復雜而又泛型的代碼、更新了第三方包,但是我們還是保留了原有的代碼。但有時,對現有代碼進行小規模的重構可能會帶來意想不到的結果。但是,為此我們需要再次分析運行結果。
仔細看一下,你可以看到散列仍然是一個問題(毫不奇怪,這是我們對數據做的唯一的事情),雖然我們確實朝這個方向改進了,但這個綠色的矩形表示 __init__.py
花了2.14秒的時間,同時伴隨著灰色的 __init__.py:54(hash)
。這意味著初始化工作需要很長的時間。
我們來看看 basehash
包的源代碼。
# 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
類:
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
實例的方法,這并不是真正需要的。
由于散列是一個確定性的過程,這意味著對于一個給定的輸入值,它必須始終生成相同的散列值,因此,我們可以把它作為類的一個屬性。讓我們來看看它將如何執行:
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/houses/
3766ms overall
38ms on queries
4 queries
最后的結果是在4秒鐘之內,比我們一開始的時間要小得多。對響應時間的進一步優化可以通過使用緩存來實現,但是我不會在這篇文章中介紹這個。
性能優化是一個分析和發現的過程。 沒有哪個硬性規定能適用于所有情況,因為每個項目都有自己的流程和瓶頸。 然而,你應該做的第一件事是分析代碼。 如果在這樣一個簡短的例子中,我可以將響應時間從77秒縮短到3.7秒,那么對于一個龐大的項目來說,就會有更大的優化潛力。
原文轉自:https://www.toptal.com/python/performance-optimization-testing-django