ドナルド・クヌースは言った 「時期尚早の最適化はすべての悪の根源です。」 しかし、通常、負荷の高い成熟したプロジェクトでは、最適化が避けられない時期があります。この記事では、私はについて話したいと思います Webプロジェクトのコードを最適化するための5つの一般的な方法。 Djangoを使用しますが、原則は他のフレームワークや言語でも同様である必要があります。この記事では、これらのメソッドを使用して、クエリの応答時間を77秒から3.7秒に短縮します。
サンプルコードは、私が取り組んだ実際のプロジェクトを基にしたものであり、パフォーマンス最適化手法を示しています。自分で結果を確認したい場合は、コードを初期状態で取得できます。 GitHub 記事に沿ってフォローしながら、対応する変更を加えます。一部のサードパーティパッケージはまだPython3で利用できないため、Python2を使用します。
私たちのウェブプロジェクトは、国ごとの不動産オファーを追跡するだけです。したがって、モデルは2つだけです。
# 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
を使用して生成された合計100,000の家のインスタンス:ある国で50,000、別の国で40,000、第3の国で10,000)を入力し、アプリのパフォーマンスをテストする準備が整いました。 。
プロジェクトで測定できることがいくつかあります。
しかし、それらのすべてが、私たちのプロジェクトのパフォーマンスの測定に関連しているわけではありません。一般的に、最も重要な2つの主要なメトリックがあります。それは、実行時間と必要なメモリ量です。
Webプロジェクトでは、 反応時間 (サーバーがユーザーのアクションによって生成された要求を受信し、それを処理して結果を送り返すのに必要な時間)は通常、最も重要なメトリックです。これは、ユーザーが応答を待っている間に退屈して別のユーザーに切り替えることができないためです。ブラウザのタブ。
プログラミングでは、プロジェクトのパフォーマンスの分析は プロファイリング 。 APIエンドポイントのパフォーマンスをプロファイルするために、 シルク パッケージ。インストールして/api/v1/houses/?country=5T22RI
を作成した後呼び出し(50,000の家のエントリがある国に対応するハッシュ)、次のようになります。
200 GET
/ api / v1 / houses /
77292ms全体
15854msクエリについて
50004クエリ
全体の応答時間は77秒で、そのうち16秒がデータベース内のクエリに費やされ、合計50,000回のクエリが実行されました。このように膨大な数で、改善の余地は十分にあるので、始めましょう。
パフォーマンスの最適化に関する最も頻繁なヒントの1つは、データベースクエリが最適化されていることを確認することです。この場合も例外ではありません。さらに、応答時間を最適化するために、クエリについていくつかのことを行うことができます。
これらの50,000クエリが何であるかを詳しく見ると、これらはすべてhouses_country
に対する冗長なクエリであることがわかります。テーブル:
200 GET
/ api / v1 / houses /
77292ms全体
15854msクエリについて
50004クエリ
で | テーブル | 参加する | 実行時間(ミリ秒) |
---|---|---|---|
+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はそのデータを取得するために追加のリクエストを行います。そして、クエリセット内のすべての家に対してこれを行います。これは合計で50,000回です。
ただし、解決策は非常に簡単です。シリアル化に必要なすべてのデータを抽出するには、 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
これがパフォーマンスにどのように影響したかを見てみましょう。
javascriptは関数ではありません
200 GET
/ api / v1 / houses /
35979ms全体
102msクエリについて
4クエリ
全体的な応答時間は36秒に短縮され、データベースで費やされる時間はわずか4クエリで約100ミリ秒です。それは素晴らしいニュースですが、もっと多くのことができます。
デフォルトでは、Djangoはデータベースからすべてのフィールドを抽出します。ただし、列と行が多い巨大なテーブルがある場合は、抽出する特定のフィールドをDjangoに指示するのが理にかなっているため、まったく使用されない情報を取得するために時間を費やすことはありません。この場合、シリアル化に必要なフィールドは5つだけですが、17のフィールドがあります。データベースから抽出するフィールドを正確に指定することは理にかなっているため、応答時間をさらに短縮できます。
Djangoには defer()
そしてその only()
これを正確に行うためのクエリセットメソッド。最初のものはどのフィールドを指定します ない ロードするフィールドと2番目のフィールドはロードするフィールドを指定します のみ 。
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
これにより、クエリに費やされる時間が半分に短縮されました。これは良いことですが、50ミリ秒はそれほど多くありません。全体の時間もわずかに減少しましたが、それをカットするためのより多くのスペースがあります。
200 GET
/ api / v1 / houses /
33111ms全体
52msクエリについて
4クエリ
データベースクエリを無限に最適化することはできません。最後の結果はそれを示しています。仮にクエリに費やす時間を0に減らしたとしても、応答を得るまで30分待つという現実に直面することになります。別のレベルの最適化に切り替えるときが来ました。 ビジネスの論理 。
サードパーティのパッケージには、単純なタスクのために多くのオーバーヘッドが伴う場合があります。そのような例の1つは、シリアル化されたハウスインスタンスを返すタスクです。
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全体
38msクエリについて
4クエリ
これは今良く見えます。 DRFシリアライザーコードを採用しなかったため、応答時間はほぼ半分になりました。
もう1つの測定可能な結果、つまり要求/応答サイクル中に行われた関数呼び出しの総数は、15,859,427呼び出し(上記のセクション1.2で行われた要求から)から9,257,469呼び出しに減少しました。これは、すべての関数呼び出しの約3分の1がDjango RESTFrameworkによって行われたことを意味します。
上記の最適化手法は最も一般的であり、徹底的な分析と思考なしで実行できる手法です。ただし、17秒はまだかなり長く感じます。この数を減らすには、コードをさらに深く掘り下げて、内部で何が起こっているかを分析する必要があります。つまり、コードのプロファイルを作成する必要があります。
組み込みのPythonプロファイラーを使用して自分でプロファイリングを行うことも、(組み込みのPythonプロファイラーを使用している)サードパーティのパッケージを使用することもできます。すでにsilk
を使用しているので、コードをプロファイリングしてバイナリプロファイルファイルを生成し、さらに視覚化することができます。バイナリプロファイルを洞察に満ちた視覚化に変換する視覚化パッケージがいくつかあります。私は使用します snakeviz
パッケージ。
これは、ビューのディスパッチメソッドにフックされた、上からの最後のリクエストのバイナリプロファイルの視覚化です。
上から順にコールスタックが表示され、ファイル名、メソッド/関数名とその行番号、およびそのメソッドで費やされた対応する累積時間が表示されます。これで、時間の大部分がハッシュ(紫色の__init__.py
およびprimes.py
長方形)の計算に費やされていることがわかりやすくなりました。
現在、これはコードの主なパフォーマンスのボトルネックですが、同時に実際にはそうではありません 私たちの コード-サードパーティのパッケージです。
このような状況では、私たちができることは限られています。
私にとって幸運なことに、basehash
の新しいバージョンがありますハッシュを担当するパッケージ。コードはv.2.1.0を使用していますが、v.3.0.4があります。このような状況は、パッケージの新しいバージョンに更新できる場合、既存のプロジェクトで作業しているときに発生する可能性が高くなります。
v.3のリリースノートを確認すると、非常に有望に聞こえる次の特定の文があります。
素数アルゴリズムを使用して大規模なオーバーホールが行われました。 (原文のまま)のサポートを含む gmpy2 それがシステム上で利用可能であれば(原文のまま)、それ以上の増加が可能です。
これを見つけましょう!
pip install -U basehash gmpy2
200 GET
/ api / v1 / houses /
検索は機能ではありません
7738ms全体
59msクエリについて
4クエリ
応答時間を17秒から8秒未満に短縮しました。素晴らしい結果ですが、もう1つ確認する必要があります。
これまでのところ、クエリを改善し、サードパーティの複雑で汎用的なコードを独自の非常に特殊な関数に置き換え、サードパーティのパッケージを更新しましたが、既存のコードはそのままにしました。ただし、既存のコードを少しリファクタリングすると、印象的な結果が得られる場合があります。ただし、このためには、プロファイリングの結果を再度分析する必要があります。
よく見ると、ハッシュはまだ問題であることがわかります(当然のことながら、データを使用して行うのはそれだけです)。ただし、その方向で改善しました。ただし、__init__.py
という緑がかった長方形灰色がかった__init__.py:54(hash)
とともに、2.14秒を消費します。それはその直後に行きます。これは、初期化に時間がかかることを意味します。
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
を初期化する2つのメソッドにラベルを付けました。各メソッド呼び出しのインスタンス。これは実際には必要ありません。
ハッシュは決定論的な手順であるため、特定の入力値に対して常に同じハッシュ値を生成する必要があるため、何かが壊れることを恐れずにクラス属性にすることができます。パフォーマンスを確認しましょう。
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全体
38msクエリについて
4クエリ
最終結果は4秒未満で、最初の結果よりもはるかに小さくなっています。キャッシュを使用すると、応答時間をさらに最適化できますが、この記事では取り上げません。
パフォーマンスの最適化は、分析と発見のプロセスです。各プロジェクトには独自のフローとボトルネックがあるため、すべてのケースに適用される厳格なルールはありません。ただし、最初にすべきことは、コードのプロファイルを作成することです。そして、そのような短い例で、応答時間を77秒から3.7秒に短縮できれば、巨大なプロジェクトにはさらに最適化の可能性があります。
Django関連の記事をもっと読むことに興味がある場合は、チェックしてください Django開発者が犯す間違いトップ10 仲間のApeeScapeによる Django開発者 アレクサンドルシュリギン。