【Django】N+1問題とselect_related・prefetch_relatedでの解決方法

Django

こんにちは、かつコーチです。

前回はフォーム(ModelForm)でのバリデーション実装を解説しました。

今回は、DjangoのORMを使う上で避けて通れない「N+1問題」を、select_relatedprefetch_relatedの比較を中心に掘り下げます。

前提知識としてForeignKey・ManyToManyとQuerySetの基本を押さえている方向けの内容です。パフォーマンス改善の実務でそのまま使えるレベルまで解説します。

N+1問題が発生する仕組み

一覧表示のコードに潜む落とし穴

まず、N+1問題が発生する典型的なコードを見てみましょう。

# views.py
def article_list(request):
    articles = Article.objects.all()  # 1回目:記事一覧のクエリ
    return render(request, "article_list.html", {"articles": articles})
<!-- article_list.html -->
{% for article in articles %}
  <p>{{ article.title }}(カテゴリ:{{ article.category.name }})</p>
{% endfor %}

一見何の問題もないコードですが、記事が100件あると101回のSQLクエリが発行されます。

Article.objects.all()で1回、そしてテンプレート内でarticle.category.nameにアクセスするたびに、記事ごとにカテゴリを取得するクエリが追加で発行されるためです。

これが「N+1問題」と呼ばれる、リレーション先へのアクセスのたびにクエリが発行され続けてしまう性能問題です。

なぜ気づきにくいのか

筆者は開発環境の少ないテストデータでは全く問題に気づかず、本番投入後にレスポンスが数秒かかるようになって初めて気づいた経験があります。

原因をdjango-debug-toolbar(発行されたSQLを可視化するツール)で確認したところ、SQLクエリ数が実に120件を超えていました。

開発中のデータ件数が少ないと体感できず、本番でデータが増えて初めて表面化するのがN+1問題の厄介なところです。

実装段階からdjango-debug-toolbarなどでクエリ数を確認する習慣をつけておくことを強くおすすめします。

select_relatedとprefetch_relatedの違い

比較表で見る使い分けの基準

DjangoのORMには、N+1問題を解決するための2つのメソッドが用意されています。

項目select_relatedprefetch_related
対象のリレーションForeignKeyOneToOneField(1対1・多対1)ManyToManyField、逆参照のForeignKey(多対多・1対多)
SQLの発行方法JOINで1回のクエリにまとめるメインのクエリ+関連先のクエリ、計2回程度で取得しPython側で結合する
適したケース「記事に対して1つのカテゴリ」のような単一の関連先「記事に対して複数のタグ」のような複数件の関連先

一言でいえば、「関連先が1件に決まるならselect_related、複数件になりうるならprefetch_related」という判断軸で選びます。

select_relatedの実装例

# ❌ Before:N+1が発生するコード
articles = Article.objects.all()
# category.nameへのアクセスのたびに追加クエリが発行される

# ✅ After:select_relatedでJOINしてまとめて取得
articles = Article.objects.select_related("category")
# SQLは1回のJOINクエリにまとまる

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

そのため、関連先が必ず1件(ForeignKeyの先)である場合にしか使えません

prefetch_relatedの実装例

# ❌ Before:タグ一覧を表示するたびにN+1が発生
articles = Article.objects.all()

# ✅ After:prefetch_relatedで関連先をまとめて取得
articles = Article.objects.prefetch_related("tags")

prefetch_relatedは、まず記事一覧を1回のクエリで取得し、続けて関連するタグをWHERE article_id IN (...)のような1回のクエリでまとめて取得し、Python側でメモリ上で結合します。

JOINではなく2回のクエリに分けて発行する点がselect_relatedとの大きな違いです。

両方を組み合わせて使う

実務でよくあるパターン

カテゴリ(1対多)とタグ(多対多)の両方を持つ記事一覧では、両方を組み合わせるのが定番です。

articles = (
    Article.objects
    .select_related("category")
    .prefetch_related("tags")
)
{% for article in articles %}
  <h2>{{ article.title }}</h2>
  <p>カテゴリ:{{ article.category.name }}</p>
  <p>タグ:
    {% for tag in article.tags.all %}
      {{ tag.name }}
    {% endfor %}
  </p>
{% endfor %}

このように書けば、記事が何件あってもSQLの発行回数は数回に収まり、件数が増えてもクエリ数は増えません。

ネストしたリレーションを一括取得する

さらにリレーションが深い場合、文字列に__をつなげることでネストしたリレーション先まで一括取得できます。

# コメントの投稿者情報までまとめて取得する
comments = Comment.objects.select_related("article", "article__category")
# 記事に紐づくコメントとその投稿者情報をまとめて取得する
articles = Article.objects.prefetch_related("comments__author")

深いリレーションになるほどN+1が発生しやすいため、テンプレート側でどこまでリレーション先をたどっているかを意識しながら書くのがコツです。

つまずきやすいポイント:件数計算で余計なクエリが発生する

countとfilterの組み合わせに注意

prefetch_relatedを使っていても、集計方法によっては効果が消えてしまうことがあります。

❌ Before:テンプレート側でフィルタして数える

<p>公開中タグの数:{{ article.tags.all|length }}</p>
{% for tag in article.tags.all %}
  {% if tag.is_active %}...{% endif %}
{% endfor %}

一見prefetch_related("tags")が効いているように見えても、filter()をテンプレート内やビューで別途呼ぶと、その時点で新たなクエリが発行されてキャッシュされたデータが使われなくなります。

✅ After:Prefetchオブジェクトで事前にフィルタ条件を含める

from django.db.models import Prefetch

articles = Article.objects.prefetch_related(
    Prefetch("tags", queryset=Tag.objects.filter(is_active=True), to_attr="active_tags")
)
{% for tag in article.active_tags %}
  {{ tag.name }}
{% endfor %}

Prefetchオブジェクトを使うと、絞り込み条件をあらかじめ含めた状態でキャッシュでき、to_attrで指定した属性名からアクセスすればN+1を防いだまま条件付きの関連データを扱えます。

ベストプラクティス:N+1問題を仕組みで防ぐ

開発段階でのチェック方法

N+1問題は気合いで防ぐのではなく、仕組みで検知するのが現実的です。

  • django-debug-toolbarを開発環境に常時導入し、画面遷移のたびにSQLクエリ数を確認する
  • django.test.utils.CaptureQueriesContextassertNumQueries()を使い、テストコードでクエリ数を固定して回帰を防ぐ
from django.test import TestCase

class ArticleListViewTest(TestCase):
    def test_query_count(self):
        with self.assertNumQueries(3):  # 想定クエリ数を明示しておく
            response = self.client.get("/articles/")

このように、意図したクエリ数をテストコードに残しておくと、後から誰かがリレーションを追加してN+1を再発させたときに、テストが落ちて気づけるようになります。

select_relatedとprefetch_relatedの使い分けチェックリスト

  • リレーション先が「必ず1件」ならselect_relatedForeignKeyOneToOneField
  • リレーション先が「複数件になりうる」ならprefetch_relatedManyToManyField/逆参照のForeignKey
  • 絞り込み条件を含めて取得したいならPrefetchオブジェクトを使う
  • 実装後は必ずdjango-debug-toolbarかテストでクエリ数を確認する

まとめ

この記事のポイント

  • N+1問題は、一覧表示でリレーション先に個別アクセスするたびにクエリが増える現象
  • select_relatedはJOINで1回にまとめる方式、ForeignKey側で使う
  • prefetch_relatedは2回程度のクエリに分けてPython側で結合する方式、ManyToManyField側で使う
  • 絞り込み条件付きで取得したいときはPrefetchオブジェクトを使う
  • クエリ数は感覚に頼らず、django-debug-toolbarassertNumQueries()で仕組みとして監視する

次に読むべき記事

パフォーマンスを意識したORMの書き方を押さえたら、次は認証・セキュリティ分野に進み、Django標準の認証システムでログイン機能を実装する方法を解説します。

→ 次の記事:Django標準の認証システム(Userモデル)でログイン機能を作る

タグ: #Django #上級者向け #データベース

タイトルとURLをコピーしました