見出し画像

Djangoアプリの速度向上!N+1クエリ問題と高負荷時間への対処法

Djangoのオブジェクト・リレーショナル・マッピング(ORM)は、データベース操作を簡素化する強力で柔軟なAPIです。しかし、その内部動作を十分に理解していないと、意図せずに遅くて重いビューを作成してしまい、パフォーマンスの問題につながる可能性があります。この記事では、N+1クエリと高負荷時間という2つの一般的な問題に対する解決策を提供することを目的としています。これらの問題に対処することで、Djangoアプリケーションの効率を向上させ、全体的なユーザーエクスペリエンスを向上させることができます。

まず、プロファイリングを行う

確かに!プロファイリングは、アプリケーションのパフォーマンスを最適化する上で不可欠なステップです。コードの中で最も多くのリソースを消費し、実行速度の低下に寄与している部分を特定することで、最適化の取り組みを効果的に優先順位付けすることができます。

QuerySet.explain()

QuerySet.explain()は、Django ORMが提供する貴重なメソッドで、特定のQuerySetがデータベースによってどのように実行されるかを理解するのに役立ちます。インデックス、結合、その他の実行詳細に関する詳細な情報を提供し、クエリの最適化とパフォーマンスの向上に役立ちます。

Django Debug Toolbar

Django Debug Toolbarは、データベースクエリの直接監視を含む、Djangoアプリケーションのパフォーマンスに関する貴重な洞察を提供する非常に便利なツールです。再帰的なORMクエリリクエストのプロファイリング、重複クエリのグループ化、プロファイリング情報のプロファイリングされたビューへの返却などの機能を提供します。

Django Silk

Django Silkは、Djangoアプリケーションのパフォーマンスをプロファイリングおよび監視するための優れたツールです。すべてのリクエストに対して実行されたクエリを記録するミドルウェアを提供し、遅いリクエストを特定し、データベースパフォーマンスを最適化することができます。さらに、プロファイリングデータを視覚化および分析するためのユーザーフレンドリーなUIを提供します。

Nplusone

nplusoneパッケージは、DjangoアプリケーションでN+1の問題を検出するのに役立つツールです。Django ORMと統合され、N+1の問題によって過剰なデータベースクエリが発生した場合に警告を提供します。

永続的なデータベース接続を使用する

Djangoは、デフォルトでは各リクエストごとにデータベースへの新しい接続を確立し、実行後にそれを閉じます。この動作は各リクエストにクリーンで分離されたデータベース接続を保証しますが、リソース使用の面で追加のオーバーヘッドを引き起こす可能性があります。
Djangoのデータベース設定におけるCONN_MAX_AGE設定は、接続の再利用の動作を制御します。デフォルトでは、値は0に設定されており、これは各リクエスト後に接続を即座に閉じるべきであることを示しています。CONN_MAX_AGEをゼロ以外の値に設定すると、接続の最大寿命を秒単位で決定します。
出発点として、CONN_MAX_AGEを比較的小さな値、例えば60秒に設定することは合理的な選択です。これにより、接続を無期限に開いたままにすることなく、短期間接続を再利用することができます。ただし、アプリケーションのパフォーマンスを監視し、サイトのトラフィックと使用特性に基づいて値を調整することが重要です。

接続の再利用とリソース利用のバランスを取ることを忘れないでください。CONN_MAX_AGEを高く設定しすぎるとリソースの枯渇につながる可能性があり、低すぎると頻繁な接続の作成と破棄によるオーバーヘッドが増加する可能性があります。

DATABASES = {
    'default': {
        ...
        'CONN_MAX_AGE': 60
    }
}

また、pgBouncerやPgpoolなどの接続プーラーを使用することもできます。

標準的なDB最適化技術を使用する

インデックス

データベースのインデックス作成は、データ取得を高速化することでクエリのパフォーマンスを向上させる上で重要な役割を果たします。アプリケーションのデータのサイズと複雑さが増大するにつれて、効率的で高速なクエリ実行を確保するためにインデックス作成はさらに重要になります。インデックスを理解し、適切な列をインデックス化し、いくつかのインデックス作成戦略を使用してインデックスの使用とメンテナンスのオーバーヘッドのバランスを取ることで、より高速なデータ取得を実現できます。

from django.db import models

class MyModel(models.Model):
    name = models.CharField(max_length=100, db_index=True)
    age = models.IntegerField()
    email = models.EmailField()

    class Meta:
        indexes = [
            models.Index(fields=['age']),
            models.Index(fields=['email'], name='myapp_email_idx'),
        ]

上記の例では:

  • nameフィールドにはdb_index=Trueオプションがあり、このフィールドにインデックスを作成する必要があることを示しています。

  • ageフィールドは、Metaクラスのindexes属性を使用してインデックス化されています。これにより、ageフィールドに対して別個のインデックスが作成されます。

  • emailフィールドもindexes属性を使用してインデックス化されていますが、インデックスにカスタム名が指定されています。

QuerySetsを理解する

QuerySetsは、データベースとの対話やデータの取得のための強力で柔軟な方法を提供します。QuerySetsを理解することは、Djangoで良好なパフォーマンスを達成し、効率的なコードを書くために不可欠です。QuerySetsが遅延評価されること、いつ評価されるか、そしてデータがメモリにどのように保持されるかを理解することが重要です。

QuerySetsは遅延評価される

DjangoのQuerySetsは遅延評価を使用します。これは、QuerySetを定義したときにデータベースクエリが即座に実行されないことを意味します。代わりに、実際のクエリは、データを明示的に要求したとき、またはQuerySetの反復やフィルターの適用など、データを必要とするアクションを実行したときに実行されます。この遅延評価は、不要なデータベースクエリを最小限に抑え、パフォーマンスを最適化するのに役立ちます。

from django.contrib.auth.models import User

# Get all users without executing the query yet
users = User.objects.all()

# Perform some operations on the QuerySet
filtered_users = users.filter(is_staff=True)
sorted_users = filtered_users.order_by('-date_joined')

# Iterate over the QuerySet and retrieve the data
for user in sorted_users:
    print(user.username)

上記の例では:

  1. users = User.objects.all()の行は、データベースからすべてのUserオブジェクトを取得するQuerySetを定義します。しかし、この時点ではデータベースクエリは実行されません。

  2. 続く行filtered_users = users.filter(is_staff=True)は、QuerySetにフィルター条件を適用し、is_staffフィールドがTrueに設定されているユーザーのみに結果を絞り込みます。ここでもクエリは即座に実行されません。

  3. sorted_users = filtered_users.order_by('-date_joined')の行は、date_joinedフィールドに基づいて降順で結果を並べ替えることでQuerySetをさらに修正します。クエリはまだ実行されません。

  4. 最後に、for user in sorted_users:のループで、QuerySetを反復処理し、ユーザーオブジェクトを1つずつ取得します。この時点で、実際のデータベースクエリが実行され、フィルタリングされ並べ替えられた結果が取得されます。

次に、QuerySetsがいつ評価されるかを理解しましょう。

QuerySetsが評価されるタイミング

QuerySetを評価するような何かをするまで、実際にはデータベースの活動は発生しません。QuerySetsは以下の方法で評価されます:

  • 反復

  • 非同期反復

  • スライシング

  • ピクリング/キャッシング

  • repr()

  • len()

  • list()

  • bool()

データがメモリにどのように保持されるか

すべてのQuerySetには、データベースアクセスを最小限に抑えるためのキャッシュが含まれています。Djangoはクエリ結果をQuerySetのキャッシュに保存し、明示的に要求された結果を返します。

print([u.username for u in User.objects.all()])
print([u.email for u in User.objects.all()])

ここでは、同じデータベースクエリが2回実行されます。

この問題を避けるには、QuerySetを保存して再利用してください。

queryset = User.objects.all()
print([u.username for u in queryset])  # Evaluate the query set.
print([u.email for u in queryset])  # Reuse the cache from the evaluation.

QuerySetがキャッシュされない場合

Djangoのクエリキャッシュは、QuerySet全体の粒度で機能し、個別のアイテムや部分的な結果をキャッシュしません。具体的には、配列スライスやインデックスを使用してQuerySetを制限しても、キャッシュは作成されません。

queryset = User.objects.all() # Retrieve all users from the database
print(queryset[5])  # Queries the database
print(queryset[5])  # Queries the database again

ただし、QuerySet全体が既に評価されている場合は、代わりにキャッシュがチェックされます。

queryset = User.objects.all()
[entry for entry in queryset]  # Queries the database
print(queryset[5])  # Uses cache
print(queryset[5])  # Uses cache

以下は、QuerySet全体が評価され、キャッシュが作成される他のアクションの例です:

>>> [entry for entry in queryset]
>>> bool(queryset)
>>> entry in queryset
>>> list(queryset)

iterator()の使用

多数のオブジェクトを扱う場合、QuerySetのキャッシュ動作は大量のメモリを消費する可能性があります。このような状況では、iterator()メソッドを使用することで、過剰なメモリ使用を軽減できます。
以下のコードでは、すべてのユーザーがデータベースからフェッチされ、メモリにロードされてから、各ユーザーを反復処理します。

users = User.objects.all()
for user in users:
    print(user.username)

iterator()を使用すると、DjangoはSQLコネクションを開いたままにし、各行を読み取り、次の行を読み取る前にdo_something()を呼び出します。

users = User.objects.all().iterator()
for user in users:
     print(user.username)

さらに、iterator()を使用する際は、データベーストランザクションとリソース管理に注意する必要があります。データベースカーソルは、反復が完了するか明示的に閉じられるまで開いたままになります。したがって、反復をトランザクションブロック内に配置するか、withステートメントを使用して適切なリソース処理を確保することをお勧めします。

Pythonではなくデータベースでデータベース作業を行う

データベース関連の作業をPythonではなく直接データベースで実行することで、アプリケーションのパフォーマンスと効率を大幅に向上させることができます。高度なクエリ、集計、最適化などのデータベースの機能を活用することで、処理負荷をPythonからオフロードし、データベースとアプリケーション間で転送されるデータ量を削減できます。

最も基本的なレベルでは、filterとexcludeを使用してデータベースでフィルタリングを行います。

F expressionsを使用して、同じモデル内の他のフィールドに基づいてフィルタリングします。

user = User.objects.get(name="Binod")
user.contracts += 1
user.save()

ここでは、customer.contractsの値をデータベースからメモリに取り込み、馴染みのあるPython演算子を使用して操作し、オブジェクトをデータベースに保存し直しています。しかし、次のようにすることもできます:

from django.db.models import F

user = Customer.objects.get(name="Binod")
user.contracts = F("contracts") + 1
user.save()

DjangoがF()のインスタンスに遭遇すると、標準のPython演算子をオーバーライドして、カプセル化されたSQL式を作成します。この場合、user.contractsが表すデータベースフィールドをインクリメントするように指示します。
user.contractsの値が何であれ、Pythonはそれを知ることはありません - それは完全にデータベースによって処理されます。Pythonが行うのは、DjangoのF()クラスを通じて、フィールドを参照するためのSQL構文を作成し、操作を記述することだけです。

annotateを使用してデータベースで集計を行う

オブジェクトのコレクションを要約または集計して得られる値を取得する必要がある場合があります。Djangoは、aggregate()メソッドを使用して、オブジェクトのコレクションを要約または集計して派生値を取得する便利な方法を提供しています。これにより、すべてのオブジェクトをフェッチすることなく、データベースから直接集計値を取得できます。

# Handling in python, don't do this
most_contracts = 0
for user in User.objects.all():
    if user.contracts > most_contracts:
        most_contracts = user.contracts

上記のコードでは、ユーザーの最大契約数を取得しています。Pythonでロジックを処理する代わりに、集計関数を利用できます。

most_contracts = User.objects.all().aggregate(Max('contracts'))['contracts__max']

aggregate()は辞書を返すため、適切なキーを使用して集計値にアクセスする必要があることに注意してください。

一意のインデックス付きカラムを使用して個別のオブジェクトを取得する

get()を使用して個別のオブジェクトを取得する際に、一意の制約またはデータベースインデックスを持つカラムを使用すると、パフォーマンスが向上します。

user = User.objects.get(id=10)

これは以下よりも高速です:

user = User.objects.get(name="Binod")

必要なものがわかっている場合は、一度にすべてを取得する

単一のデータセットの全ての部分が必要な場合、複数のクエリでデータベースからデータを取得するのではなく、一度のクエリですべてをフェッチすることで効率が向上します。これは特にループ内でクエリを実行する場合に重要で、不必要な複数のデータベースクエリにつながる可能性があります。
この問題に対処し、効率を向上させるために、Djangoでは「イーガーローディング」の概念を利用できます。イーガーローディングを使用すると、関連するすべてのデータを単一のクエリでフェッチでき、データベースへのアクセス回数を減らし、全体的なパフォーマンスを向上させることができます。
QuerySet.select_related()とQuerySet.prefetch_related()の使用
DjangoのQuerySet APIにあるselect_related()とprefetch_related()メソッドは、関連オブジェクトを効率的にフェッチしてデータベースクエリを最適化するための強力なツールです。
select_related()メソッドは、テーブルの単純なJOIN操作を実行し、外部キーまたは一対一のモデルフィールドに使用できます。これは、親オブジェクトと同じデータベースクエリで関連オブジェクトを取得します。これにより、後で関連オブジェクトにアクセスする際に追加のクエリが必要なくなります。

# Retrieve parent objects with related child objects using select_related()
users = User.objects.select_related('order')

# Access the related child objects without additional queries
for user in users:
    print(user.order)

prefetch_related()メソッドは、多対多または逆外部キー関係を最適化するために使用されます。これは、関連オブジェクトを別のクエリで取得し、多対多オブジェクトの取得を最適化します。これにより、後で関連オブジェクトにアクセスする際のデータベースへのアクセス回数が減少します。

# Retrieve parent objects with related many-to-many objects using prefetch_related()
users = User.objects.prefetch_related('many_to_many_field')

# Access the related many-to-many objects without additional queries
for user in users:
    print(users.many_to_many_field.all())

select_related()とprefetch_related()は、複数レベルの関係を最適化し、クエリのパフォーマンスをさらに向上させるためにチェーンすることもできます。

必要のないものを取得しない

  • QuerySet.values()とQuerySet.values_list()の使用 DjangoのQuerySet APIにあるvalues()とvalues_list()メソッドを使用すると、QuerySet内のオブジェクトから特定のフィールドまたはフィールドの組み合わせを取得できます。これは、ORMモデルオブジェクト自体が不要で、単に辞書またはリストの値が必要な場合に便利です。

  • QuerySet.defer()とQuerySet.only()の使用 defer()とonly()メソッドは、大量のテキストデータのロードを避けたり、Pythonに変換するのに多くの処理が必要なフィールドがある場合に最も有用です。常に、まずプロファイリングを行い、その後最適化してください。 defer()メソッドは、指定されたフィールドのデータベースからのロードを延期するために使用されます。明示的にアクセスされるまで、取得されたオブジェクトから指定されたフィールドを除外します。 only()メソッドは、データベースから指定されたフィールドのみをロードするために使用されます。指定されたフィールドのみを取得し、モデル内の他のすべてのフィールドを無視します。

  • QuerySet.contains(obj)の使用 オブジェクトがQuerySetに含まれているかどうかを確認したい場合は、次のようにします:

# Do
queryset.contains(obj)

# Don't
if obj in queryset:
  do_something()
  • QuerySet.count()の使用 QuerySetのオブジェクト数のみを取得する必要がある場合、DjangoのQuerySet APIのcount()メソッドを使用する方が、len(queryset)を使用するよりも効率的です。count()メソッドは、すべてのオブジェクトをメモリにロードすることなく、直接データベースからカウントを取得する最適化されたSQLクエリを実行します。

queryset = MyModel.objects.filter(some_field='some_value')

# Do
count = queryset.count()

# Don't
count = len(queryset)
  • QuerySet.update()とQuerySet.delete()を使用する

大量のオブジェクトを更新または削除する必要がある場合、各オブジェクトを個別に変更して保存するよりも、DjangoのQuerySet APIが提供する一括更新および削除操作を使用する方が効率的です。update()およびdelete()メソッドを使用すると、これらの操作を単一のSQLステートメントで実行でき、データベースの往復を最小限に抑え、パフォーマンスを向上させることができます。

  • 外部キーの値を直接使用する:外部キーの値だけが必要な場合、関連オブジェクト全体を取得してその主キーを取得するのではなく、すでに持っているオブジェクトの外部キーの値を直接使用します。つまり、user.order.idではなくuser.order_idを使用します。

# Don't. Needs database hit
order_id = User.objects.get(id=200).order.id

# Do. The foreign key is already cached, so no database hit
order_id = User.objects.get(id=200).order_id

# Do. No database hit
order_id = User.objects.select_related('order').get(id=200).order.id
  • 必要でない場合は結果を並べ替えない

クエリ結果に特定の順序が必要ない場合は、モデルのMetaクラスで定義されているデフォルトの並べ替えを削除することをお勧めします。不要な並べ替えを削除することで、クエリを最適化し、データベースの負荷を軽減できます。並べ替えには無視できないコストがあり、並べ替える各フィールドはデータベースが実行しなければならない操作です。モデルにデフォルトの並べ替え(Meta.ordering)があり、それが不要な場合は、パラメータなしでorder_by()を呼び出してQuerySetから削除します。データベースにインデックスを追加すると、並べ替えのパフォーマンスが向上する場合があります。

  • QuerySet.exists()を使用する:クエリセットに少なくとも1つの結果が存在するかどうかを確認するには、exists()メソッドを使用できます。

queryset = MyModel.objects.filter(some_field='some_value')

# Do
if queryset.exists():
  do_something()

# Don't
if queryset:
  do_something()

contains()、count()、exists()の過剰使用を避ける
QuerySetから他のデータも必要な場合は、即座に評価してください。
例えば、UserとのMany-to-Many関係を持つGroupモデルを想定すると、以下のコードが最適です:

members = group.members.all()

if display_group_members:
    if members:
        if current_user in members:
            print("You and", len(members) - 1, "other users are members of this group.")
        else:
            print("There are", len(members), "members in this group.")

        for member in members:
            print(member.username)
    else:
        print("There are no members in this group.")

このコードが最適である理由:

  1. QuerySetは遅延評価されるため、display_group_membersがFalseの場合、データベースクエリは実行されません。

  2. group.members.all()の結果をmembers変数に格納することで、その結果キャッシュを再利用できます。

  3. if members:の行によりQuerySet.__bool__()が呼び出され、group.members.all()クエリがデータベースで実行されます。結果がない場合はFalseを、それ以外の場合はTrueを返します。

  4. if current_user in members:の行は、ユーザーが結果キャッシュ内にいるかどうかをチェックするため、追加のデータベースクエリは発行されません。

  5. len(members)の使用はQuerySet.__len__()を呼び出し、結果キャッシュを再利用するため、再びデータベースクエリは発行されません。

  6. for memberループは結果キャッシュを反復処理します。

このコードは、合計で1回または0回のデータベースクエリを実行します。唯一の意図的な最適化はmembers変数の使用です。if文に QuerySet.Exists()を、inQuerySet.contains()を、カウントにQuerySet.count()を使用すると、それぞれ追加のクエリが発生します。
Djangoでのデータベースアクセスの最適化についてのこの記事をお読みいただき、ありがとうございます。この記事があなたの好奇心を刺激し、さらに深く掘り下げる意欲を喚起することを願っています。
質問、考え、または追加の洞察がありましたら、遠慮なくコメントしてください。皆様からのフィードバックをお待ちしております。
次回まで、探求を続け、学び続け、そして変化を起こし続けてください。


この記事は、弊社のシニア ソフトウェア エンジニアである Binod Kafle によって 2023 年 7 月に執筆されました。
英語版を読むには、ここをクリックしてください。
https://articles.wesionary.team/database-access-optimization-in-django-74b71ea95cb4


採用情報

私たちはプロダクト共創の仕組み化に取り組んでいます。プロダクト共創をリードするプロダクト・マネージャー、そして、私たちのビジョンを市場に届ける営業メンバーを募集しています!


開発パートナーをお探しの企業様へ

弊社は、グローバル開発のメリットを活かし、高い費用対効果と品質を両立しています。経験豊富で多様性のあるチームが、課題を正しく理解し、最適なシステムと優れた体験を実現します。業務システムの開発、新規事業の開発、業務効率化やDX化に関するお困りごと、ぜひ弊社にご相談ください。

この記事が気に入ったらサポートをしてみませんか?