N+1 クエリ問題とは
Django の ORM はリレーションをオブジェクトのプロパティとして自然に扱える便利な仕組みですが、利用方法によっては大量の SQL が発行されてパフォーマンスが急激に悪化することがあります。その代表例が「N+1 クエリ問題」です。
例えば、次のような Article モデルと、それに紐づく User モデルがあったとします。
class User(models.Model):
name = models.CharField(max_length=64)
class Article(models.Model):
title = models.CharField(max_length=64)
author = models.ForeignKey(User, on_delete=models.CASCADE)
記事一覧を取得して著者名を表示するつもりで、次のようなコードを書くとします。
articles = Article.objects.all()
for article in articles:
print(article.title, article.author.name)
このコードは記事一覧を取得する SELECT が 1 回、各記事の著者を取得する SELECT が記事の数 ( N ) ぶん発行されるため、合計で N+1 回の SQL が走ります。記事数が増えるほどクエリ数が線形に増えてしまい、ページの表示が大きく遅くなる原因になります。
本記事では、この N+1 問題を Django 標準の select_related と prefetch_related で解消するための使い分け方を解説します。
select_related: 一対一・多対一を JOIN で取得する
select_related は、ForeignKey や OneToOneField のような「単一の関連先」を SQL の JOIN を使って 1 回のクエリで取得する仕組みです。先ほどのコードは次のように書き換えられます。
articles = Article.objects.select_related("author")
for article in articles:
print(article.title, article.author.name)
発行されるクエリは次のように 1 本にまとまります。
SELECT article.*, user.*
FROM article
INNER JOIN user ON article.author_id = user.id;
JOIN によってデータベースとの往復が 1 回で済むため、関連先のフィールドを参照しても追加クエリは発生しません。さらに、author__profile のようにダブルアンダースコアで連結すれば、複数階層の関連も一度に取得できます。
prefetch_related: 一対多・多対多を IN 句で取得する
一方、ManyToManyField や逆参照の ForeignKey ( 1 件の親に対して複数件の子が存在する関係 ) を JOIN で取得すると、親のレコードが子の数だけ重複する直積になってしまいます。これを避けるために用意されているのが prefetch_related です。
例として、ユーザーごとに記事一覧を表示する場合を考えます。
users = User.objects.prefetch_related("article_set")
for user in users:
print(user.name, [article.title for article in user.article_set.all()])
このとき発行される SQL は 2 本です。
SELECT * FROM user;
SELECT * FROM article WHERE author_id IN (1, 2, 3, ...);
ユーザーを取得した後、得られた id を IN 句にまとめて記事を一括取得し、Python 側で親子の紐付けを行います。JOIN を使わないため重複が発生せず、関連件数が多いケースでも効率良くデータを取得できます。
使い分けの判断基準
2 つのメソッドはどちらも N+1 を解消しますが、適切な使い分けが重要です。判断基準は次のとおりです。
-
取得したいリレーションが「親 1 件に対して常に 1 件以下」なら select_related を使う
ForeignKey ( 多対一 ) や OneToOneField のように、親に対して関連先が単一に決まる関係です。JOIN で 1 クエリにまとまるため、最もコストが低くなります。 -
取得したいリレーションが「親 1 件に対して複数件あり得る」なら prefetch_related を使う
ManyToManyField や逆参照の ForeignKey が該当します。IN 句で別クエリを走らせることで、親レコードの重複を避けつつ一括取得できます。 -
両方を組み合わせることもできる
select_related("author").prefetch_related("comments") のように同時に指定すれば、多対一は JOIN で取り、一対多は別クエリで取るというハイブリッドな最適化が可能です。
Prefetch クラスでさらに細かく制御する
prefetch_related は文字列でリレーションを指定するだけでも十分機能しますが、django.db.models.Prefetch クラスを使うとプリフェッチするクエリセット自体をカスタマイズできます。
from django.db.models import Prefetch
recent_articles = Article.objects.filter(is_published=True).order_by("-created_at")
users = User.objects.prefetch_related(
Prefetch("article_set", queryset=recent_articles, to_attr="recent_articles"),
)
for user in users:
for article in user.recent_articles:
print(user.name, article.title)
queryset 引数で関連先のクエリに条件や並び順を加え、to_attr 引数で結果を格納する属性名を指定できます。テンプレート側で「公開中の記事だけを最新順で表示する」といった要件を満たしたい場合に有効です。
使用時の注意点
-
クエリの本数だけでなく内容も確認する
select_related や prefetch_related を付けたつもりでも、テンプレートやシリアライザで別のリレーションをたどっていると、そこで追加クエリが発生してしまいます。django-debug-toolbar などで実際に発行されている SQL を必ず確認しましょう。 -
取得カラムが多くなりすぎないように注意する
select_related は JOIN したテーブルのカラムをすべて取得します。関連先のテーブルが横に大きい場合は、only や defer と組み合わせて必要なカラムだけに絞ると、ネットワーク転送量とメモリ使用量を抑えられます。 -
プリフェッチ後にクエリを書き換えると効果が消える
prefetch_related で取得したリレーションに対して、ループ内で .filter() や .exclude() を呼び出すと新しいクエリが発行され、せっかくのプリフェッチが無駄になります。条件を絞りたい場合は Prefetch クラスを使い、プリフェッチ自体に条件を組み込んでください。
まとめ
N+1 クエリ問題は Django 開発で頻繁に遭遇する典型的なパフォーマンス劣化の原因ですが、select_related と prefetch_related を正しく使い分けることで、ほとんどのケースは数行の修正で解決できます。
多対一は select_related で JOIN、一対多と多対多は prefetch_related で別クエリ取得、これを基本ルールとして押さえつつ、必要に応じて Prefetch クラスでクエリをカスタマイズしていきましょう。
パフォーマンスチューニングを行う際は、まず django-debug-toolbar で発行されているクエリを観察するところから始めるのがおすすめです。
コメント