Djangoのselect_related()とは?N+1問題とあわせて理解する

Django

Django ORMを使っていると、シンプルに見えるコードから想像以上の数のSQLが実行されることがあります。

特に注意したいのが、一覧画面や一覧APIで関連モデルを参照するときです。

books = Book.objects.all()

for book in books:
    print(book.author.name)

一見すると問題のないコードですが、Book の件数に応じて Author を取得するSQLが繰り返し実行される可能性があります。

いわゆる「N+1問題」です。

Djangoでは、ForeignKeyOneToOneField の関連であれば、select_related() を利用することで関連モデルをJOINして取得できます。

この記事では、

  • N+1問題とは何か
  • なぜN+1問題が発生するのか
  • select_related() でどう改善できるのか
  • prefetch_related() とは何が違うのか
  • Django REST Frameworkの一覧APIではどう使うのか
  • N+1問題をどうやって発見するのか

まで、実際の開発を意識して解説します。

N+1問題とは

N+1問題とは、最初に一覧を取得するSQLを1回実行したあと、取得したN件それぞれについて追加のSQLが実行される問題です。

たとえば100件のデータを取得した場合、

一覧を取得             1回
関連データを取得     100回
--------------------------
合計                 101回

となります。

データが10件程度なら気付かないこともあります。

しかし、データが100件、1,000件と増えたり、データベースとの通信に時間がかかったりすると、レスポンス時間への影響が大きくなってきます。

Djangoの公式ドキュメントでも、ループ内でデータベースクエリを繰り返すことを避け、必要なデータが分かっている場合には select_related()prefetch_related() を利用することが推奨されています。


Django ORMでN+1問題が発生する例

具体的なモデルで考えてみましょう。

from django.db import models


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


class Book(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
    )

Book から AuthorForeignKey で参照しています。

この状態で本の一覧を取得します。

books = Book.objects.all()

for book in books:
    print(book.title, book.author.name)

問題になるのは、

book.author.name

です。

まず、

Book.objects.all()

を評価したときにBookを取得するSQLが実行されます。

しかし、このQuerySetでは関連するAuthorまであらかじめ取得しているわけではありません。

そのため、ループの中で、

book.author

へ初めてアクセスすると、そのBookに対応するAuthorを取得するためのSQLが必要になります。

概念的には次のような動きになります。

SELECT ...
FROM book;

そして、取得したBookごとに、

SELECT ...
FROM author
WHERE id = ...;

が実行されます。

Bookが100件なら、最大で

Book取得      1回
Author取得  100回
-----------------
合計        101回

という状態になり得ます。

コードだけを見るとループしているだけなので、N+1問題はレビューでも見逃しやすいところです。


select_related()でN+1問題を改善する

今回のような ForeignKey の関連では、select_related() が利用できます。

先ほどのコードを変更してみます。

books = Book.objects.select_related("author")

for book in books:
    print(book.title, book.author.name)

違いは、

.select_related("author")

を追加しただけです。

しかし、データベースへのアクセス方法は大きく変わります。

DjangoはBookを取得するときに、関連するAuthorもSQLのJOINを利用して同時に取得します。

そのため、ループ内で、

book.author.name

へアクセスしても、Authorを取得するための追加SQLは必要ありません。

100件のBookを処理する例なら、考え方としては、

select_related()なし
1 + 100 = 101クエリ

select_related()あり
1クエリ

となります。

もちろん実際のアプリケーションでは、ほかの処理によるSQLも存在するため、画面全体のクエリ数が必ず1になるという意味ではありません。

重要なのは、BookごとにAuthorを取得するSQLが繰り返されなくなるという点です。


select_related()では何が起きているのか

select_related() は関連するテーブルをSQLのJOINで取得します。

先ほどの、

Book.objects.select_related("author")

であれば、概念的には次のようなSQLになります。

SELECT
    book.id,
    book.title,
    book.author_id,
    author.id,
    author.name
FROM book
INNER JOIN author
    ON book.author_id = author.id;

BookとAuthorを1回のSQLで取得しています。

なお、これは仕組みを説明するために単純化したSQLです。

実際に生成されるSQLやJOINの種類はモデル定義などによって異なります。たとえば、nullableな関連では LEFT OUTER JOIN が使われる場合があります。

実際のSQLを確認したい場合は、QuerySetの query を利用する方法もあります。

books = Book.objects.select_related("author")

print(books.query)

ORMを利用していても、ときどき生成されたSQLを確認してみると、パフォーマンス上の問題を理解しやすくなります。


select_related()を利用できる関連

select_related() は、基本的に「関連先が1件になるリレーション」で利用します。

代表的なのは、

ForeignKey
OneToOneField

です。

たとえば、

class Book(models.Model):
    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
    )

なら、

Book.objects.select_related("author")

とできます。

一方、

ManyToManyField
ForeignKeyの逆参照

のように、関連先が複数件になるものは select_related() の対象ではありません。

そこで登場するのが prefetch_related() です。


prefetch_related()との違い

select_related()prefetch_related() は、どちらも関連オブジェクトへのアクセスによって大量の追加クエリが発生することを防ぐために使います。

ただし、仕組みが異なります。

大まかには次のように考えると分かりやすいでしょう。

関連基本的な選択
ForeignKeyselect_related()
OneToOneFieldselect_related()
ManyToManyFieldprefetch_related()
ForeignKeyの逆参照prefetch_related()

select_related() はSQLのJOINを使います。

Book
  ↓ JOIN
Author

一方、prefetch_related() は基本的に別のSQLで関連データをまとめて取得し、Python側で関連付けます。

たとえばAuthorからBookを逆参照する場合を考えてみます。

class Book(models.Model):
    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="books",
    )

次の処理では、

authors = Author.objects.all()

for author in authors:
    for book in author.books.all():
        print(book.title)

AuthorごとにBookを取得するSQLが発生する可能性があります。

そこで、

authors = Author.objects.prefetch_related("books")

としておけば、関連するBookをまとめて取得できます。

prefetch_related() は必ずしも「1クエリにする」機能ではありません。

たとえばこの例では、

Authorを取得するSQL
Bookをまとめて取得するSQL

のように複数のSQLが実行されます。

しかし、

1 + N

ではなく、N件分の関連データをまとめて取得できることが重要です。

つまり、

select_related()
    → JOINして一緒に取得する

prefetch_related()
    → 別SQLでまとめて取得する

という違いがあります。


関連先の関連もまとめて取得する

select_related() では、さらに先の関連をたどることもできます。

たとえばAuthorがCompanyに所属しているとします。

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


class Author(models.Model):
    name = models.CharField(max_length=100)
    company = models.ForeignKey(
        Company,
        on_delete=models.CASCADE,
    )

一覧で、

for book in books:
    print(
        book.title,
        book.author.name,
        book.author.company.name,
    )

とするなら、

books = Book.objects.select_related(
    "author__company"
)

のように指定できます。

__ を使って関連をたどる点は、filter() などで関連モデルのフィールドを指定するときと同じです。

ただし、

Book.objects.select_related(
    "author",
    "author__company",
    "author__company__country",
    # ...
)

のように、関連を何でもJOINすればよいわけではありません。

JOINが増えればSQLは複雑になり、取得するデータ量も増えます。

必要な関連だけを指定することが重要です。


Django REST Frameworkの一覧APIでは特に注意

N+1問題に特に注意したいのが、Django REST Framework(DRF)を使った一覧APIです。

たとえば次のSerializerを考えてみます。

from rest_framework import serializers


class BookSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(
        source="author.name",
        read_only=True,
    )

    class Meta:
        model = Book
        fields = [
            "id",
            "title",
            "author_name",
        ]

author_name では、

source="author.name"

として関連するAuthorを参照しています。

View側が単純に、

queryset = Book.objects.all()

となっていると、Serializerが各Bookの author.name を評価するときに追加のSQLが発生し、N+1問題につながる可能性があります。

そこで、View側のQuerySetを、

queryset = Book.objects.select_related("author")

とします。

あるいは get_queryset() を使うなら、

def get_queryset(self):
    return Book.objects.select_related("author")

とできます。

Django REST Frameworkの公式ドキュメントでも、一覧取得でSerializerがORMの関連をたどる場合にはN+1問題が発生する可能性があり、select_related()prefetch_related() でQuerySetを最適化することが案内されています。

また、DRFがSerializerを解析して自動的に最適な select_related()prefetch_related() を追加してくれるわけではありません。

Serializerがどの関連データを参照するのかを確認し、それに合わせてQuerySetを設計する必要があります。

一覧APIを実装するときには、Serializerだけではなく、

Serializerが何を参照するか
        ↓
そのデータをQuerySetでどう取得するか

までセットで考えると、N+1問題を防ぎやすくなります。


select_related()を使えば使うほど速くなるわけではない

N+1問題を知ると、

Book.objects.select_related()

のように、できるだけ多くの関連を最初から取得しておきたくなるかもしれません。

しかし、select_related() は付ければ付けるほど速くなるものではありません。

Djangoでは引数なしの select_related() を使うと、たどることのできる非NULLのForeignKeyを自動的にたどることもできますが、公式ドキュメントでも多くの場合は推奨されていません。

JOINするテーブルが増えれば、

  • SQLが複雑になる
  • 不要なカラムまで取得する
  • データベース側の処理が重くなる

といった別の問題が発生する可能性があるからです。

そのため、

Book.objects.select_related("author")

のように、その画面やAPIで実際に使用する関連を明示するほうが分かりやすいでしょう。

最適化では、

クエリ数が少ない = 必ず高速

とは限りません。

実際のSQLや実行時間を確認して判断することが重要です。


N+1問題を見つける方法

select_related() の使い方を覚えること以上に重要なのが、

「N+1問題が発生していることに気付けるか」

です。

Django Debug Toolbarで確認する

開発環境ではDjango Debug Toolbarを利用すると、画面表示時に実行されたSQLやクエリ数を確認できます。

たとえば一覧画面を開いたとき、

SELECT ... FROM book
SELECT ... FROM author WHERE id = 1
SELECT ... FROM author WHERE id = 2
SELECT ... FROM author WHERE id = 3
SELECT ... FROM author WHERE id = 4
...

のような似たSQLが大量に並んでいたら、N+1問題を疑うきっかけになります。

テストでクエリ数を確認する

Djangoのテストでは、assertNumQueries() を利用してクエリ数を検証する方法もあります。

たとえば概念的には、

with self.assertNumQueries(1):
    books = list(
        Book.objects.select_related("author")
    )

    for book in books:
        _ = book.author.name

のように確認できます。

パフォーマンス上重要な処理であれば、単にレスポンス内容をテストするだけでなく、意図せずクエリ数が増えていないかをテストするのも有効です。

ただし、認証やミドルウェアなどを含むAPI全体のクエリ数は実装によって変わります。

「どんなAPIでも1クエリになる」というテストを書くのではなく、対象とする処理の範囲を明確にして検証することが大切です。


実務で意識したいチェックポイント

Djangoで一覧処理を書いたときは、ループの中を確認してみましょう。

たとえば、

for book in books:
    print(book.author.name)

を見つけたら、

book.author

は関連モデルへのアクセスなので、

この関連データは事前に取得されているだろうか?

と考えます。

Serializerでも同じです。

author_name = serializers.CharField(
    source="author.name"
)

を見つけたら、

このSerializerへ渡しているQuerySetはAuthorを事前取得しているだろうか?

と確認します。

そして関連の種類によって、

ForeignKey / OneToOneField
        ↓
select_related()を検討

ManyToMany / ForeignKey逆参照
        ↓
prefetch_related()を検討

と判断します。

ここで大切なのは、コードを見ただけで「N+1だ」と決めつけるのではなく、疑わしい箇所を見つけたら実際のSQLを確認することです。

Django ORMは便利だからこそ、Pythonコードだけを見ているとデータベースアクセスが見えにくくなります。

「このコードでは何回SQLが実行されるだろう?」

という視点を持っておくと、N+1問題を早い段階で発見しやすくなります。


まとめ

DjangoのN+1問題は、一覧処理で関連モデルを参照するときに発生しやすい問題です。

たとえば、

books = Book.objects.all()

for book in books:
    print(book.author.name)

のようなコードでは、BookごとにAuthorを取得する追加SQLが発生する可能性があります。

ForeignKeyOneToOneField の関連であれば、

books = Book.objects.select_related("author")

として、関連モデルをJOINでまとめて取得することを検討できます。

一方、ManyToManyやForeignKeyの逆参照のような複数オブジェクトとの関連では、

.prefetch_related(...)

が基本的な選択肢になります。

覚えておきたいのは、

select_related() = ForeignKey / OneToOneを中心にJOINで取得

prefetch_related() = 複数件の関連を別SQLでまとめて取得

という違いです。

ただし、最初からすべての関連を取得すればよいわけではありません。

実務では、

一覧処理を書く
    ↓
ループやSerializerで関連モデルを参照していないか確認
    ↓
N+1の可能性を疑う
    ↓
実際のSQL・クエリ数を確認
    ↓
関連に応じてselect_related() / prefetch_related()を検討
    ↓
改善後のSQL・クエリ数を再確認

という流れで考えるとよいでしょう。

特にDjango REST Frameworkの一覧APIでは、Serializerによる関連モデルへのアクセスがコード上では目立ちにくいため注意が必要です。

一覧画面や一覧APIが遅いと感じたときだけではなく、ループ内で関連モデルを参照しているコードを見つけた時点で、一度N+1問題を疑ってみる

これを習慣にしておくと、データ件数が増えてからパフォーマンス問題に悩むリスクを減らせます。

コメント

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