Django ORM-遅い? 最適化(ハードコア)

秋が来て、天気が良かったにもかかわらず、私たちの視聴者は新しいビデオコンテンツに手を伸ばしました。 ビデオ埋め込みを提供するバックエンドサーバーはCPUに置かれ始めました。 「aaaa」の叫びで、システム管理者は走り始め、開発部門からラップトップとデスクトップを選択し始め、「増幅のため」にデータセンターに置くと脅しました。 もちろん、この開発はすべてが好きではありませんでした みんな辞めた このがらくたは何かをすることにしました。


一般的に、ハイローダーの世界には多くのソリューションがあり、「Goで最も難しいことを書き直そう」から始まり、すべての平凡なキャッシングで終わります。 ただし、今回は、リライトまたはキャッシュしないもの、つまり動画の表示を許可するプロセスのビジネスロジックの高速化について説明します。


5年間のビジネス要件を実装するためにビデオを表示するロジックには、広告リンク、ビデオ出力プロトコル、統計カウンター、プレーヤーの外観などのターゲット設定が含まれています。ビデオメタデータ-合計で約10個のパラメーター。


その結果、特定のビューごとに、MySQLに対して多数のクエリを作成する必要がありますが、その形式のバージョンは画面に収まりません。 クエリは小さなテーブルに移動して迅速に実行されますが、ボトルネックはDjango ORMを使用したこのようなクエリの生成です。 プロファイラーと30分間座って結果を確認した後、著者は2つのことを発見しました。 1つ目は、このような洗練されたAPIでdjango-rest-frameworkを使用すると、プログラマーが同じリクエスト内で同じ関数をコピーして貼り付けて再呼び出しできるようになることです。 記録-6回リファラーURLをサブドメインとパスセグメントに解析します。 2番目はDjango ORMの内部です 本当に遅い リクエストの処理には最大で半分の時間がかかります。


私たちはすぐにORMを高速化すべきであることに到達し、先日、正気の実装を思いつきました。 その前に、さまざまなオプションが分解されました。


str.format()


クエリの文字列フォーマットを使用すると、すぐにルートのパフォーマンスの問題を解決できますが、数十ページのコードを書き直し、開発者に数か月間睡眠を奪わせる必要があり、マネージャーはいつか本番稼働することを望んでいます。 オプションはすぐに削除されました。


SQLAlchemy +ベイクドクエリ


プロジェクトの開始時に混同されていた場合、ORMを別のORMにすばやく変更し、キャッシュの組み込みサポートを使用して変更することは、優れたオプションです。 したがって、テストが利用できるにもかかわらず、書き直されたコードが同等であるという問題が生じます。 SQLAlchemyのBaked Queriesは、クエリを最初から作成する代わりに、プロセスメモリ内のキャッシュを使用して、一度フォーマットされたSQLクエリを他のパラメーターを使用した繰り返し呼び出しに再利用します。 このアイデアは、この記事の公開の理由となったオプションが登場するまで、最も「おいしい」ものの1つでした。


Django ORMのキャッシュ


Django ORM内でのキャッシュの実装が失敗することは明らかですが、既にフォーマットされたSQLクエリをキャッシュしようとするとどうなりますか? 理論的には、非常に単純に見えます。


 queryset = get_some_complex_sql(flag1=True, flag2=True) sql, params = queryset.query.sql_with_params() raw_queryset = Model.objects.raw(sql, params=params) 

メモ化手法を採用し、特定の値ではなく、その種類、存在/不在を考慮するように修正します(まあ、 (True, False, None, 0, 1)も考慮できます)。 SQLを記憶します。キャッシュヒットの場合、 RawQuerySet新しい値を代入し、キャッシュの準備ができました。


実際には、問題はほとんどすぐに始まります。 paramsは常にpython組み込み型のタプルであり、 flag2が含まれている場所とflag2が不可能な場所を区別します。 さらに、フィルタータイプfilter(value__in=[1,2,3])のフィルターは、フィルターに渡される値の数に応じてSQLクエリを変更します。これにより、キャッシュキーの組み合わせが爆発的に増加します。


途方もない量のコーヒーとCookieを用意したので、内部Query構造を調べて、そこで次のパラメーターが置換される場所を検索することで、実際のパラメーターと正式なパラメーターを一致させる問題を解決できました。 結果は機能しましたが、複雑なデザインでは恐ろしいものでした。


私はこのコードを恥じています...
 @cached(   'play_qualityrule',   #      licensed=not_null_and_negate('licensed'),   protected=not_null_and_negate('protected', default=False),   is_active={'exact': True},   # QualityRule.rightholders.filter(rightholder_id=...)   rightholders__rightholder_id=null_or_equal('rightholder'),   # QualityRule.user_agents.filter(useragent_id=...)   user_agents__useragent_id=null_or_equal('user_agent'),   # QualityRule.groups.filter(group=...)   groups__group_id=null_or_in('group'),   # QualityRule.alternative_sales_rule.filter(   #    alternativesalesrule=...)   alternative_sales_rule__alternativesalesrule_id=null_or_equal(   'alternative_sales_rule'),   # QualityRule.users.filter(user_id=...)   users__user_id=null_or_equal('user')) def get_filtered_query(self, **kwargs): .... 

つまり、実際には、SQLクエリの構築に関係するパラメータは、関数に渡される引数に関するその形成の規則とともに、デコレータに記述されている必要があります。 このコードの山は、最初の実装とその後のサポートの複雑さに適合しませんでした。


約束と遅延計算


次のアプローチは、 django.utils.functional.lazyデコレータに触発されました。 次のように機能します。


 def compute(param): return param ** 2 compute_lazy = lazy(compute, int) lazy_value = compute_lazy(43) 

lazy_valueをDjango ORMに渡すと、最後の瞬間までcompute()関数の呼び出しが延期されます。 この関数は、 Promiseオブジェクトが実際の値にキャストされるたびに呼び出されます。


このプロパティは、コンテキストマネージャから実際のパラメータ値を取得するために使用されました。


 def lazy_param(name): return ContextManager.instance.params[name] 

次のアイデアは単純なものでした:



残念ながら、うまくいきませんでした。 Django-ORMは、いくつかの汚い目的のために、 filter(a__in=[1,2,3])の構築段階でもリストの長さをキャストおよびチェックします。たとえば、 filter(a__in=[1,2,3])呼び出しfilter(a__in=[1,2,3])で、空の値をチェックし、対応するノードは、WHEREに新しい条件を追加する代わりに、要求を最適化するなどのEmptyResultSetスローしEmptyResultSet伝染


したがって、遅延計算の余地はありません。


怠け者!


そのような素晴らしいアイデアを投げることは残念でした。そこで、何が起きても、怠zyなままのクラスを書くことが決定されました。



このすべての「怠iness」のために、LazyラッパーをSQLクエリ形成の完了に導き、同時にリストへのエントリのチェックで可変数のプレースホルダーを使用して問題を解決することができました。


 sql, params = "SELECT * FROM bla WHERE a IN (%s) AND b = %s", ([1,2,3], 4) placeholders = get_placeholders(params) sql = sql % placeholders params = flatten(params) # SELECT * FROM bla WHERE a IN (%s, %s, %s) AND b = %s", (1, 2, 3, 4) 

実際のパラメーターを置き換える場合、式IN (%s)は、リスト内の実際の値の数に対応するプレースホルダーの数でIN (%s, %s, %s)置き換えられ、 paramsタプルはフラットになります。


コードのキャッシングがよりスムーズになりました。


  @cached def get_filtered_query(self, **params): ... return queryset def useful_method(self, **params): with LazyContext(**params): qs = self.get_filtered_query(**params) # query database return list(qs) 

その結果、SQLクエリのテキストをキャッシュし、キャッシュキーの数の組み合わせの爆発を整理し、ビジネスロジックをほとんど手付かずに保ち、データベースクエリを生成するためのDjango ORMのすべての機能を保持することで、遅いORMを無効にしました。


価格はいくらですか?


ビジネスロジックがそのまま残っているのはマーケティングです。 実際には、コードの深刻なリファクタリングを実施する必要がありました。これは主に、海外のすべての可能なキャッシングのブランチの削除に関連しています。 これが重要である理由は、例で示すことができます。


 @cached def get_queryset(user): if user and user.is_authenticated(): return Model.objects.exclude(author=user) return Model.objects.filter(public=True) 

SQLクエリは2つの事実に依存していることがわかります。



ユーザーが関数に転送されると、デコレータは状態を追跡できます。 ただし、ユーザーIDがプライムであるという事実など、許可を検証することは彼の責任ではありません。 転送されたオブジェクトのメソッド呼び出し、データベースへの追加クエリ、および関数のグローバルコンテキストの状態(yes、さらにはdatetime.now() )に基づいたこのような分岐のケースでは、これらすべてを角かっこからdatetime.now()必要があります。 幸いなことに、これのルールは非常に簡単です。



上記の例はかなり変更されています。


 @cached def get_queryset(user_id, is_authenticated): #    ,   Lazy-   if reveal(is_authenticated): return Model.objects.exclute(author_id=user_id) return Model.objects.filter(public=True) def caller(user): if user and user.is_authenticated(): user_id = user.pk is_authenticated = True else: user_id = None is_authenticated = False with LazyContext(user_id=user_id, is_authenticated=...): qs = get_queryset(user_id, is_authenticated) return list(qs) 

結論の代わりに


私たちの場合、バックエンドの最適化の話が突然始まりました。この問題にゆっくりと取り組むことができ、最初の解決策を台無しにしない別のボトルネックを見つけることができて非常に幸運でした。 ユニットテストは、それらがどこにあるか、大いに役立ちました。 本番環境では、パラノイアのレベルが徐々に低下して、変更が展開されます。



効率については、同じクエリを使用した合成実行で、ab-oneは1秒あたり31から44クエリに加速することができました。 結果は、まず特定のビジネスロジックで得られ、2番目に非常に合成的な状況で、3番目に出会った最初のマシンで得られました。 そして重要なことは、丸めを考えると、 42%の加速が得られました。


クエリキャッシュの実装はGitHubで利用できます



Source: https://habr.com/ru/post/J339420/


All Articles