Django ORM クエリパフォーマンスの最適化技法

パフォーマンス計測と実行確認

開発中のアプリケーションにおいて、発生するデータベース操作の影響を確認することは不可欠です。Django が提供する標準的な機能を利用することで、各 SQL ステートメントの出力と所要時間を把握できます。

プロジェクトの設定で DEBUG モードを有効化している場合、デバッグ用のオブジェクトを通じて現在のセッション内で発行された全クエリの履歴を取得可能です。この情報は時系列に格納された配列形式であり、直近の処理結果はその末尾から参照することができます。

from django.db import connection
# 現在までの全 SQL と時間情報を取得
queries = connection.queries 
print(queries[-1])

複数のデータベース接続が構成されている環境では、特定のエイリアスに対して個別にクエリログを取得します。例えば、「sales_db」という別名を持つ接続先の情報を確認する場合:

from django.db import connections
db_alias_queries = connections['sales_db'].queries

既存の記録をクリアして状態をリセットしたい場合は、以下のヘルパー関数を使用します。

from django.db import reset_queries
reset_queries()

また、SELECT ステートメントの内部実行戦略を確認する機能も提供されています。これは MySQL の EXPLAIN コマンドと同様に、プランナーがどのインデックスを使用するか、または順次スキャンを行うかを判定する際の推定コストを表示します。

# 基本の実行計画の表示
queryset = NewsPost.objects.filter(status='active')
print(queryset.explain())

# より詳細な情報(実際の行数やプレニング時間など)を含めるには
print(queryset.explain(verbose=True, analyze=True))

スキーマレベルでの改善

ORM の使い方に依存せず、データベース側で行う基本的なチューニングは依然として重要です。頻繁にフィルタリング条件に使われるフィールドには索引を作成することを推奨します。

Django のモデル定義において、メタオプションの indexes を使用するか、フィールド属性自体に db_index を設定することで対応が可能です。特に WHERE 句や ORDER BY に利用されるカラムに対して索引を追加すれば、検索速度の劇的な向上が期待できます。

QuerySet の挙動とメモリ管理

Django のORM は、データ取得プロセスにおいて遅延評価の概念を採用しています。つまり、filter()exclude() メソッドを呼び出しても、実際にデータをフェッチするまで SQL は生成されません。

# SQL は未発行
q = InventoryItem.objects.filter(price__gte=100)
q = q.exclude(discontinued=True)
# ここで初めて DB と通信が行われ、結果がロードされる
results = list(q)

データが実際に読み込まれるタイミングとしては、ループ処理、リスト型への変換、長さの取得 (len()) が挙げられます。一度評価されてメモリ上に保存された結果は、そのクエリオブジェクト内にある限り再利用されます。しかし、意図せずに同一のクエリ式を複数回記述すると、無駄な IO が発生する可能性があります。

# 非推奨:同じ条件で 2 回 DB アクセスが発生
ids = [item.id for item in Product.objects.all()]
names = [item.name for item in Product.objects.all()]

正解としては、クエリ結果を変数に保持して重複アクセスを防ぎます。

# 推奨:1 回のアクセスで済む
qs = Product.objects.all()
ids = [item.id for item in qs]
names = [item.name for item in qs]

注意すべき点として、スライス ([index]) のみを使用して特定の要素を取り出す操作は、Cache を満たす前に DB を叩く場合があります。全体のリストを展開してからインデックスアクセスを行えば、キャッシュされたデータが利用されます。

キャッシュ特性に関する注意点

単体モデルインスタンスにおいても同様の仕組みが働きます。関連付けられている外部キー (Foreign Key) へのアクセスは、初回以降キャッシュされます。一方、Many-to-Many リレーションシップは毎回新たな SELECT を実行するため、これを避けるために prefetch_related() を活用するのが定石です。

# 外部キーへのアクセスは 1 回のみ DB 接続(2 回目以降キャッシュ)
post = Post.objects.get(pk=1)
author_name = post.author.profile.bio 
author_name_2 = post.author.profile.bio 

# ManyToMany は毎回再取得される
comments_count = post.comments.count()
comments_count_again = post.comments.count()

処理の分散と集約方法

Python 上でのループ処理よりも、データベースエンジンに処理を任せた方が高速になるケースが多々あります。

フィルタリングと除外: 全てのレコードを Python に持ってきて後処理をするのではなく、filter()exclude() を使って DB レベルで絞ってください。

F 式とアノテーション: フィールド同士の計算や集計が必要な場合、F() 式を使用したり、annotate() でグループごとに算出させたりします。

存在判定: 結果の数値が欲しい場合でも count() メソッドを使い、ただ存在の有無だけ知りたい場合は if queryset.exists(): と記述し、オブジェクトを生成するのを防ぎましょう。

# True/False 判定かつ最小限のオーバーヘッド
if Order.objects.filter(is_paid=False).exists():
    send_notification()

不要な情報の排除

必要以上のデータを読み込むと、メモリ使用量が増加しネットワーク転送が冗長化します。

特定の列データのみが必要なら values() または values_list() を使用します。テキストのような大型フィールドが含まれる場合は、defer()only() でそのフィールドの読み込みをスキップすることも効果的です。

並び替えは CPU とディスク負荷がかかります。必ず必要な時以外では order_by() を指定しないか、Meta クラスのデフォルト設定をキャンセルしてください。

バッチ処理の活用

大量データの更新や削除では、1 つずつ処理するのではなくセットアップされたバッチメソッドを使用します。同様に、新規作成やリレーション設定でも複数の引数を同時に渡すことで、トランザクション回数を減らすことができます。

# バッチ更新
Order.objects.filter(status='pending').update(shipped_date=datetime.now())

# 一括追加
members = Team.objects.get(id=5).members
members.add(member_a, member_b)

# 一括削除
Team.objects.filter(membership_score__lt=10).delete()

タグ: Django django-orm performance-tuning sql-indexing database-caching

7月17日 19:21 投稿