select_relatedとprefetch_related

models.pyにてOneToOneFieldやForeignKey、ManyToManyField(外部キー)を多用しすぎると次第にデータベースへの接続数が多くなりページの読み込みスピードに影響を与えるようになってきます。

例えば、objects.get()などで特定のデータを取得した後に、フィールドで指定してあるForeignKeyを指定するとDjangoはもう一度データベースに接続してデータを取得しようとします。合計で2回クエリが発生するようになります。

もしくはobjects.filter()で取得したデータをfor文でループして、フィールドを指定したとしましょう。データが10個あれば、初めの1回+10回で合計11回のクエリが発生することになります。
開発を行なっているときは、sqlite3を用いるケースが多いのでこれはさほど大きな問題にはなりませんが、サービスの実運用を開始しクラウドのデータベースを使用すると、処理時間が大幅に増加しサービスの運用に大きな影響を与えてきます。

データの外部キーを指定しても、それをfor文でループしてもクエリ回数が1回になれば処理にさほど時間がかかることはありません。
これをDjangoで実現するためにselect_releatedとprefetch_relatedを使い、そうやってクエリに要する処理時間を削減することをクエリの最適化と言います。

select_relatedを使用すると 対一の関係にある外部キーで指定してあるデータを初めのクエリで取得することができます。
対一とは例えば、OneToOneFieldであったり、ForeignKeyで指定している参照元のデータを指します。

models.py

from django.contrib.auth import get_user_model

class Article(models.Model):

    title = models.CharField(max_length=100)
    body = models.TextField()
    is_published = models.BooleanField(default=False)
    created = models.DateTimeField(auto_now_add=True)


class Comment(models.Model):

    article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name='comments')
    body = models.TextField()
    user = models.ForeignKey(get_user_models(), on_delete=models.SET_NULL)
    created = models.DateTimeField(auto_now_add=True)

このmodels.pyでCommentのデータからarticleを指定すると関係は対一になります。
このケースでselect_relatedを使うことでCommentのデータからArticleのデータを取得しても追加のクエリが発生しないようになります。

悪い例

from .models import Comment

# commentとarticleでクエリが2回発生してしまう。
comment = Comment.objects.get(id=5)
article = comment.article

良い例

from .models import Comment

# commentのクエリでarticleを引っ張ってきているためクエリは1回
comment = Comment.objects.select_related('article').get(id=5)
article = comment.article

良い例を見ていただくとわかりますが、数文字加えただけでクエリの最適化を行なっています。
select_relatedでは、引数の中に該当のフィールド名を指定することで該当するデータを引っ張ってくることができます。
データが複数の場合も同様です。

悪い例

from .models import Comment

# Commentが10個あれば11回のクエリが飛ぶ
comments = Comment.objects.all()
for comment in comments:
    print(comment.article)

良い例

from .models import Comment

# commentsのクエリでarticleを引っ張ってきているためクエリは1回
comments = Comment.objects.select_related('article').all()
for comment in comments:
    print(comment.article)

対多の外部キーでもクエリの最適化を行うことはできます。
この場合はprefetch_relatedを使用することでそれを実現できます。

ArticleからCommentを逆参照すると、そのArticleに紐づいているCommentを全て取得することができます。またこの場合、関係は対多になります。
1つのCommentは1つのArticleにしか紐づきませんが、1つのArticleには複数のCommentが存在する可能性があるためです。

Articleからは、Comment.articleのrelated_nameを指定することで逆参照を行うことができます。select_relatedで指定するとエラーになるので注意しましょう。

from .models import Article

article = Article.objects.prefetch_related('comments').get(id=5)
comments = article.comments.filter(created__gte='2020-10-01').order_by('-created')

これでArticleへのクエリでCommentのデータも引っ張ってくることができました。
逆参照でなくても、ManyToManyFieldでもこれは同様なので覚えておきましょう。

Prefetch

prefetch_relatedで対多の関係データを引っ張ってくることができましたがもう一つ問題があります。ArticleからCommentを取得して、そのCommentのuserを指定した場合、ここではForeignKeyを指定してあるのでまたさらにクエリが飛んでしまうことになります。

from .models import Article

article = Article.objects.prefetch_related('comments').get(id=5)
comments = article.comments.filter(created__gte='2020-10-01').order_by('-created')

for comment in comments:
    print(comment.user.username)

Commentの個数分クエリが飛んでしまうのでこれは最適だとは言えません。
この時には、追加のクエリを一つ飛ばしてさらに多くのデータを一緒に取得する方法を使います。DjangoではPrefetchという機能を使ってそれを実現することができます。

from django.db.models import Prefetch

from .models import Article, Comment

article = Article.objects.prefetch_related(Prefetch('comments', queryset=Comment.objects.select_related('user').filter(created__gte='2020-10-01').order_by('-created'), to_attr='article_comments')).get(id=5)
comments = article.article_comments

for comment in comments:
    print(comment.user.username)

Prefetchまでくるとかなり複雑になってしまいますが、テンプレ化してしまえば楽なのでここで覚えておきましょう。
prefetch_related(Prefetch(*related_name, queryset=*クエリ文, to_attr=*参照するための名前を設定))

ここまで使いこなせればサービスの実運用でデータの取得が極端に遅くなることはないと思います。
まずはselect_relatedとprefetch_relatedを使いこなしていってクエリの最適化を実現していきましょう。