Django ORMにおけるN+1問題、なぜこんなに頻繁に言及されるのでしょうか?

Django ORMを使用していると、「N+1問題」という言葉をよく耳にします。 しかし、実際に経験しないとこの問題がどれほど深刻か実感しにくいものです。

簡単に言えば、「一度だけ実行すれば良いクエリが予想以上に多く実行される問題」です。 その結果、ページの読み込み速度が著しく遅くなり、データが増えるほどパフォーマンスの低下が深刻になります。 特にモデル間の関係があるデータを取得する際に多く発生するので、ORMを使用する際には必ず理解しておく必要があります。

💡 N+1問題、わかりやすく説明しましょう

例えば、ブログシステムを作ると仮定しましょう。 各著者(Author)は複数の投稿(Post)を書けます。 Djangoモデルで表現すると次のようになります。

from django.db import models

class Author(models.Model):
    name = models.CharField(max_length=100)

class Post(models.Model):
    title = models.CharField(max_length=255)
    content = models.TextField()
    author = models.ForeignKey(Author, on_delete=models.CASCADE)

著者(Author)は複数の投稿(Post)を書くことができるため、1:N関係です。

さて、すべての著者の名前と彼が書いた投稿のタイトルを出力する機能を実装すると仮定しましょう。 通常、以下のように書く可能性が高いです。

⚠️ N+1問題が発生するコード

authors = Author.objects.all()  # (1) 最初のクエリ (Authorテーブルの取得)

for author in authors:
    print(author.post_set.all())  # (N) 各AuthorのPostを個別に取得

一度実行してみると、予想以上に読み込みが遅くなるかもしれません。 データが少ない場合には目立たないかもしれませんが、 もし著者が100名、投稿が500件以上の場合は? 問題が徐々に見えてくることでしょう。

🧐 実際に実行されるSQLクエリ

SELECT * FROM author;  -- 1回実行
SELECT * FROM post WHERE author_id = 1;  -- N回実行
SELECT * FROM post WHERE author_id = 2;
SELECT * FROM post WHERE author_id = 3;
...

このように著者が増えるほどクエリの数が幾何級数的に増加します。 これがまさにN+1問題です。

🚀 N+1問題はどんな状況でよく発生するのでしょうか?

実際、この問題はDjango ORMを上手く使っても使わなくても、 二つ以上のモデルが関連したデータを取得する際にはほぼ必ず気をつけるべき問題です。 特に次のような場合でよく発生します。

1️⃣ テンプレートでループの中でデータを参照する場合

{% for author in authors %}
    {{ author.name }}
    {% for post in author.post_set.all %}
        {{ post.title }}
    {% endfor %}
{% endfor %}

テンプレートでループをしながらデータにアクセスすると、 各オブジェクト毎に追加のクエリが発生し、パフォーマンスが低下します。

2️⃣ ビュー関数でループ内で関連モデルを参照

def blog_view(request):
    authors = Author.objects.all()  # 最初のクエリ (Authorの取得)
    
    author_list = []
    for author in authors:
        author_list.append({
            'name': author.name,
            'posts': author.post_set.all()  # 追加的なN個のクエリが発生
        })
    
    return render(request, 'blog.html', {'authors': author_list})

テンプレートではなくビュー関数でデータを加工する場合でも同様の問題が発生する可能性があります。

💡 実際に経験したN+1問題

以前、get_absolute_url()というカスタムメソッドを作成したことがあります。 モデルの複数のフィールドを組み合わせてURLを生成する関数でしたが、 パフォーマンスが遅くなって初めて原因を見つけました。

class Post(models.Model):
    title = models.CharField(max_length=255)
    slug = models.SlugField(unique=True)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)

    def get_absolute_url(self):
        return f"/author/{self.author.name}/{self.slug}/"

このメソッドを呼び出すたびにself.author.nameが実行され、 N個の追加的なクエリが発生していました。 この問題を解決するまでにかなりの時間がかかりました。

✅ まとめ:N+1問題を必ず理解するべき理由

次回の投稿ではselect_relatedprefetch_relatedを使ってN+1問題を解決する方法について扱います。

Django ORMにおけるN+1クエリ問題

  • Django ORMを使用している限り、この問題はいつでも発生する可能性があります。
  • ページの速度が遅くなっている場合、ORMが予想以上に多くのクエリを実行している可能性があります。
  • テンプレート、ビュー関数、モデルメソッドのどこでも発生する可能性があります。
  • データが増えるほどパフォーマンスの低下が深刻になり得ます。
  • ORMが自動的に最適化を行うわけではないため、実行されるクエリを直接確認し、最適化する必要があります。