Django ORM の N+1 問題を select_related と prefetch_related で解決する

Django ORM は便利ですが、リレーション先のデータにアクセスするたびに SQL が発行されるという落とし穴があります。これが N+1 問題です。1 回のクエリで N 件取得し、それぞれのリレーション先にアクセスすると追加で N 回のクエリが走る——合計 N+1 回のクエリが発行されることからこの名前が付いています。

N+1 問題の具体例

次のコードを見てみましょう。

# 1 回目のクエリ: 記事一覧を取得
articles = Article.objects.all()

for article in articles:
    # 記事ごとに追加クエリ: カテゴリを取得
    print(article.category.name)

記事が 100 件あれば、合計 101 回の SQL が発行されます。記事一覧の取得に 1 回、各記事のカテゴリ取得に 100 回です。開発中は気付きにくいものの、本番環境ではレスポンスの著しい低下を引き起こします。

select_related で JOIN する(多対一・一対一)

select_related は SQL の JOIN を使って、リレーション先のデータを 1 回のクエリでまとめて取得します。ForeignKey と OneToOneField に対して使えます。

# JOIN で 1 回のクエリにまとめる
articles = Article.objects.select_related('category').all()

for article in articles:
    # 追加クエリは発行されない
    print(article.category.name)

生成される SQL は次のようになります。

SELECT article.*, category.*
FROM article
INNER JOIN category ON article.category_id = category.id;

1 回のクエリで記事とカテゴリの両方を取得するため、N+1 問題は解消されます。

複数のリレーションを同時に解決したい場合は、引数を並べるだけです。

articles = Article.objects.select_related(
    'category', 'author'
).all()

リレーションが深い場合はアンダースコア 2 つで辿れます。

# article → category → parent_category の 2 段階
articles = Article.objects.select_related(
    'category__parent_category'
).all()

prefetch_related で別クエリを最適化する(多対多・逆参照)

prefetch_related は JOIN ではなく、追加のクエリを発行してから Python 側で結合します。ManyToManyField や逆方向の ForeignKey(related_name でのアクセス)に適しています。

# 記事一覧とタグを 2 回のクエリで取得
articles = Article.objects.prefetch_related('tags').all()

for article in articles:
    # 追加クエリは発行されない
    for tag in article.tags.all():
        print(tag.name)

prefetch_related は内部で 2 回のクエリを発行します。

-- 1 回目: 記事一覧
SELECT * FROM article;

-- 2 回目: 関連するタグを IN で一括取得
SELECT * FROM tag
INNER JOIN article_tags ON tag.id = article_tags.tag_id
WHERE article_tags.article_id IN (1, 2, 3, ...);

N 件の記事があっても 2 回のクエリで済むため、N+1 問題を回避できます。

select_related

JOIN で 1 回のクエリに結合する。ForeignKey と OneToOneField に使う

prefetch_related

別クエリを発行して Python 側で結合する。ManyToManyField と逆参照に使う

Prefetch オブジェクトで条件を絞る

prefetch_related でプリフェッチするデータに条件を付けたい場合は、Prefetch オブジェクトを使います。

from django.db.models import Prefetch

articles = Article.objects.prefetch_related(
    Prefetch(
        'comments',
        queryset=Comment.objects.filter(
            is_approved=True
        ).order_by('-created_at')
    )
).all()

この例では、記事ごとのコメントのうち承認済みのものだけを新しい順にプリフェッチしています。単純に prefetch_related('comments') と書くと全コメントを取得してしまうため、Prefetch で絞り込むことでデータ量を抑えられます。

select_related と prefetch_related を併用する

両者は排他的ではなく、併用できます。

articles = Article.objects.select_related(
    'category'
).prefetch_related(
    'tags'
).all()

ForeignKey のリレーション(category)は select_related で JOIN し、ManyToManyField のリレーション(tags)は prefetch_related で別クエリにする——これが最も効率的な組み合わせです。

Django Debug Toolbar で確認する

N+1 問題の厄介なところは、コードの見た目からは判別しにくい点にあります。Django Debug Toolbar を導入すると、各ページで発行された SQL の回数と内容をブラウザ上で確認でき、パフォーマンス上の問題を早期に発見できます。

pip install django-debug-toolbar

開発時に SQL の回数を常に意識する習慣をつけておくと、本番環境でのパフォーマンス問題を未然に防げます。select_relatedprefetch_related は Django ORM を使う限り避けて通れない最重要の最適化手法です。