11
5

More than 1 year has passed since last update.

【Django】prefetch_related の挙動を理解する

Posted at

Djangoのprefetch_relatedについて

prefetch_relatedはDjangoのN+1問題を回避するための機能です。
select_relatedと並び重要なメソッドですが、理解が曖昧な部分があったので整理したいと思います。

DjangoのSQLが実行されるタイミングについて

DjangoがDBを叩きにいくタイミングについて確認しておきます。

1.メソッドが呼ばれたタイミング

いくつかのメソッドはクエリセットは返さずに、呼び出されたタイミングでDBを叩きに行きます。
代表的なものをあげておきます。

メソッド名 機能
first() はじめの一つを取得する。返す値がない場合は None を返す
last() 最後の一つを取得する。対象がない場合は None を返す
get() 一つのオブジェクトを返す。対象が1つ以外の場合は models.DoesNotExists, models.MultipleObjectReturned エラーが起きる
count() SQLのCOUNT(*)。対象の列数を返す
exists() SQLのEXISTS()。対象が存在するかどうかをBooleanで返す

2. クエリが評価されたタイミング

一方で、多くのメソッドはクエリセットを返すだけで、呼び出されたタイミングではDBを叩きに行きません。発行されたクエリがDBを叩くきに行くのは「評価された」タイミングです。
以下のメソッドはその一例です。

メソッド名 機能
all() 全てを取得する
filter(**kwargs) kwargsの条件に一致する列を返す
exclude(**kwargs) kwargsの条件に一致しない列を返す
values(*args) 受け取ったフィールドのみを取得するクエリを発行します

上の説明で使った「評価される」というのは、例えば

  1. ループなど、イテレーターとして実行される
  2. python のスライスをステップ[::]で実行する
  3. len() や bool() に渡す
  4. ifなどの条件式として評価する

などの状況をさします。
そのほかにもいくつかの条件が公式ドキュメントに載っていますが、ここでは省略します。

prefetch_related の挙動

prefetch_relatedも評価されたタイミングでDBを検索しにいくメソッドです。
以下のようなモデル例に説明していきます。

db_test/models.py
class Author(models.Model):
    name = models.CharField(max_length=50)


class Book(models.Model):
    title = models.CharField(max_length=100)
    author = models.ForeignKey(
        Author, related_name='books', on_delete=models.CASCADE)

prefetch_relatedはDBを2回叩く

prefetch_relatedが付いたクエリセットを評価すると、DBが2回叩かれます。

authors = Author.objects.prefetch_related('books').filter(id__lt=3) # まだ評価されていないのでDBはアクセスされない
for author in authors: # ループに使われたのでDBが叩かれる
    titles = [book.title for book in author.books.all()]

この時、Djangoは以下のようなSQLを2回実行しにいきます。

-- クエリ1
SELECT `db_test_author`.`id`, `db_test_author`.`name` FROM `db_test_author` WHERE  `db_test_author`.`id` < 3;

-- クエリ2
SELECT `db_test_book`.`id`, `db_test_book`.`title`, `db_test_book`.`author_id` FROM `db_test_book` WHERE `db_test_book`.`author_id` IN (1, 2);

prefetch_related()では、事前にAuthorに紐づいたBookを取得してキャッシュしておきます。そうすることで、N+1を回避しています。
↓もしprefetch_related()を使わなかった場合

-- クエリ1
SELECT `db_test_author`.`id`, `db_test_author`.`name` FROM `db_test_author` WHERE  `db_test_author`.`id` < 3;

-- クエリ2
SELECT `db_test_book`.`id`, `db_test_book`.`title`, `db_test_book`.`author_id` FROM `db_test_book` WHERE `db_test_book`.`author_id` IN (1);

-- クエリ3
SELECT `db_test_book`.`id`, `db_test_book`.`title`, `db_test_book`.`author_id` FROM `db_test_book` WHERE `db_test_book`.`author_id` IN (2);

prefetch_related がうまく機能しない場合

しかし、このprefetch_related()ではうまく機能しない場合があります。
それは、all()意外のメソッドを呼び出す時です。

authors = Author.objects.all().prefetch_related('books')
for author in authors:
    titles = [book.title for book in author.books.filter(title__startswith='hoge')]

これは残念ながらN+1回SQLが発行されてしまいます。
filter()などのメソッドはあくまでもDBを叩くためのものです。そのため、filter()での絞り込みは事前に取得してキャッシュしたものは使われず、あらたにDBから取得してしまいます。
これに対応するためには、Prefetchオブジェクトを使用する必要があります。

from django.db.models import Prefetch
authors = Author.objects.all().preftch_related(
    Prefetch('books',
             queryset=Book.objects.filter(title__startswith='hoge'),
             to_attr='hoge_books'
    )
)
for author in authors:
    titles = [book.title for book in author.hoge_books]

Prefech オブジェクトを使用することで、all() 意外の結果のも効率よく取得することができます。

11
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
5