【Django入門13】N+1問題とselect_related・prefetch_relatedでの解決

DjangoにおけるN+1問題の解説ページアイキャッチ

このページでは N + 1 問題について解説していきます。

いきなりですが、みなさん、Django でのウェブアプリ開発を楽しんでいらっしゃいますか?

Django を利用すればウェブアプリの開発を簡単に行うことができますが、それでもウェブアプリ開発に難しさを感じておられる方もいらっしゃるかもしれません。でも、このページを訪れてくださったという方は、きっとウェブアプリの開発を楽しいと感じられていたり、これからウェブアプリの開発を楽しみたいという方が多いのではないかと思います。実際、ウェブアプリに機能を追加したり、ウェブアプリの見た目を自分好みのものに仕立てていく作業は楽しいと思います。

が、今回は少し現実的な話をしていきます。今回は、ページのタイトルにもなっている N + 1 問題を扱っていきます。この N + 1 問題は機能追加や見た目を綺麗にするようなものではなく、ウェブアプリのパフォーマンス(反応速度や処理速度など)に関わるものになります。なので、このページを読むことで、あなたがウェブアプリで実現可能な機能が増えるというわけではありません。

ですが、今回説明する N + 1 問題は、ウェブアプリを公開していく上では必ず解決しておかなければならない問題となります。そして、この N + 1 問題は Django 利用者が陥りやすい問題です。

もしかしたら、つまらない題材だと感じる方もおられるかもしれませんが、できるだけ分かりやすく解説していきますので、是非 N + 1 問題について、そしてこの問題の解決方法について学んでいっていただければと思います。

N + 1 問題

では、早速 N + 1 問題について解説していきたいと思います。

N + 1 問題:件数に比例してクエリ発行回数が増加すること

N + 1 問題とは、簡単に言ってしまえばページに表示するレコードの件数に比例してデータベースへのクエリ発行回数が増加してしまう問題のことを言います。レコードの件数を N として考えれば、Nに比例してクエリ発行回数が増加する問題のことを N + 1 問題と言います。

それなら、N 問題と呼ぶ方スッキリ理解できそうなものですが、この + 1 の意味合いに関しては後述で解説します。

クエリとは、データベースへの問い合わせや要求を表す言葉で、N + 1 問題の場合のクエリはレコードの取得の要求と考えて良いでしょう。N + 1 問題が発生すると表示するレコードの件数に比例してデータベースへの問い合わせが多くなるため、例えばウェブアプリの登録者やウェブアプリに登録されているコメントなどが増えれば増えるほどウェブアプリのパフォーマンスが低下することになります。

公開当初はウェブアプリに登録されているコメントが少なくて快適に利用できていたウェブアプリも、人気が出て登録されているコメントが多くなるとウェブアプリの処理時間が増加し、いわゆる重いウェブアプリとなってしまいます。重いとストレスが溜まるので、ウェブアプリの利用者が減ってしまうかもしれないですね…。

そういったことにならないためにも、N + 1 問題は解決しておく必要があります。

スポンサーリンク

N + 1 問題の解決:件数に比例したクエリの発行回数の増加を解消すること

前述の通り、N + 1 問題とはクエリの発行回数が表示するレコードの件数に比例して多くなることです。そのため、N + 1 問題の解決とは、この “表示するレコードの件数に比例してクエリ発行回数が増えること” を解消することになります。

なので、このページでは、まず N + 1 問題が発生する原因について説明した後、”表示するレコードの件数に比例してクエリ発行回数が増えること” を解消する方法について解説を行なっていきます。

実は、N + 1 問題と聞くと難しそうに感じますが、発生する原因も、それを解決する方法も単純ですので安心してください。

Django では N + 1 問題が発生しても気づきにくい

この N + 1 問題は、Django を利用している場合、この問題が発生していても気付きにくいという点に注意が必要です。Django を利用していればクエリを発行する処理を記述しなくてもオブジェクト(モデル)を扱うことでデータベースの操作が行えます。なので、クエリを意識しなくてもデータベースにレコードを登録したり、データベースからレコードを取得したりできます。ですが、実際にはウェブアプリからデータベースにクエリが発行されており、気付かぬうちに N + 1 問題も発生していることが多いです。

なので、Django だけには限らないのですが、クエリを発行する処理を記述しなくてもデータベースの操作が実現可能なフレームワークやライブラリを利用している場合、N + 1 問題の発生に気づきにくく、特に N + 1 問題に注意が必要となります。

N + 1 問題の発生原因

前述の通り、N + 1 問題とはクエリの発行回数が件数に比例して増加してしまうことになります。

では、この問題は何が原因で発生するのでしょうか?

この原因について、必要な内容を復習しながら解説をしていきたいと思います。

スポンサーリンク

モデルとテーブルの関係

前述の通り、この N + 1 問題はデータベースに関する問題となります。

このデータベースに密接に関わるのが Django のモデルとなります。

Django では models.py にモデルクラスを定義し、そのモデルクラスや、そのインスタンスを利用してデータベースのテーブルの操作を行うことになります。下記ページでも解説しているとおり、このモデルクラスがテーブルであり、そのインスタンスが、そのテーブルのレコードに対応します。

モデルの解説ページアイキャッチ【Django入門6】モデルの基本

例えば、下記のように models.pyComment というモデルクラスを定義したとします。

Comment
from django.db import models

class Comment(models.Model):
    text = models.CharField(max_length=256)

このように models.py を変更したのちにマイグレーションを実行すれば、Comment に対応するテーブルがデータベースに作成されることになります(id 等のプライマリーキーに関しては、フィールドとして定義しなくても自動的にテーブルに追加されます)。

マイグレーションによってデータベースにモデルクラスの定義に応じたテーブルが作成される様子

そして、この Comment のインスタンスを生成し、このインスタンスに save メソッドを実行させれば、Comment のテーブルに新たなレコードが登録されていくことになります。

モデルクラスとそのインスタンスの関係図

このように、モデルクラスはテーブルに対応し、そのインスタンスはレコードに対応しています。

ちなみに、マイグレーションによって作成されるテーブル名の形式は アプリ名_モデルクラス名 (全て小文字) となります。例えば、アプリ名が app の場合、Comment のテーブルの名前は app_comment となります。以降でクエリの例などを示していきますが、全てアプリ名が app の時の例となります。

クエリの発行

N + 1 問題を理解する上で重要なのは、こういったデータベースに保存されているレコードの情報はデータベースから取得しないと利用できない、例えば表示などを行うことができないという点になります。

ウェブアプリとデータベースは別物であり、あらかじめウェブアプリがデータベースに保存されているレコードを所持しているというわけではありません。従って、ウェブアプリでレコードの情報を表示したいのであれば、そのレコードをデータベースから取得する必要があります。そして、その取得などの、データベースへの要求がクエリとなります。

クエリの説明図

レコードの取得が必要になったタイミングで発行される

ここで、このクエリが発行されるタイミングや発行されるクエリの内容について確認するため、ビューで下記のような処理を行うことについて考えたいと思います。Comment は先程紹介したものと同じモデルクラスとします。

この処理が実行されれば、Comment のテーブルの全レコードの text フィールドを print 関数で出力することができます。レコードの情報が表示されるということは、データベースからレコードの取得が行われているということになります。つまり、この処理のどこかでレコードを取得するためのクエリが発行されることになります。

Commentの全レコード表示
comments = Comment.objects.all()
for comment in comments:
    print(comment.text)

上記の処理を直感的に捉えれば、1行目で Comment テーブルの全レコードを取得し、次の for ループで取得した全レコードの text をフィールドを表示していると考えることができます。

なので、1行目を実行した際にクエリが発行されるようにも思えますが、実はクエリが発行されるのは2行目の for comment in comments: が実行されるタイミングとなります。そして、この時に発行されるクエリは下記のようなものになります。これは、Comment テーブルの全てのフィールドを含む全レコードを取得するためのクエリとなります。

SELECT "app_comment"."id",
       "app_comment"."text",
  FROM "app_comment"

ポイントは、Django ではクエリはレコードが必要になったタイミングで自動的に発行されるようになっているという点になります。もっと正確に言えばクエリセットが評価されるタイミングで自動的に発行されるのですが、まずはレコードが必要になったタイミングでクエリが発行されるとイメージしていただければ良いと思います。

そのため、上記では comments = Comment.objects.all() がコードとしてはレコードを取得する処理のようにも思えますが、この時点ではまだレコードが必要でないため、プログラムの動作としては、comments = Comment.objects.all() が実行されてもクエリは発行されません。例えば、下記のような処理のみを行ってもクエリは発行されません。

???
comments = Comment.objects.all()

ですが、for comment in comments: では全レコードに対するループを実現するため、ループ回数等を決定するためにレコードが必要となります。ですが、この時点でウェブアプリはレコードを所持していません。そのため、この時点で全レコードを取得するためのクエリが発行されることになります。そして、このクエリの発行によってウェブアプリは Comment のテーブルの全レコードを所持することになります。

レコードが必要になったタイミングでクエリが発行される様子

レコードの取得が不要であればクエリは発行されない

さらに、ループ内の処理では各レコードの text フィールドを出力することになります。当然、この処理でも Comment のレコードが必要になるのですが、既にウェブアプリは for comment in comments: でのクエリの発行によって Comment の全レコードが取得されているため、その取得済みのレコードを参照して各レコードの text フィールドを出力することが可能となります。したがって、このループ内の処理ではクエリの発行は行われません。

ループ内の処理では既に取得済みのレコードを利用する様子

このように、レコードが必要になった際には Django フレームワークが裏側で自動的にクエリを発行するようになっています。逆に、レコードが不要なのであればクエリの発行は行われません。

クエリは自動的に発行される

ここで特に重要なのは、Django ではクエリの発行はレコードが必要になった際に自動的に Django フレームワークから発行されるという点になります。そのため、Django を利用すれば、クエリの発行に関しては意識しなくてもウェブアプリを開発することが可能となります。

ですが、Django ではクエリの発行を意識しなくても良いため、開発者が意図していないタイミングでクエリが発行されたとしても、それに気づきにくいです。先程の例のような処理を記述したことがあっても、for comment in comments: の行でクエリが発行されることを知らなかった人も多いのではないかと思います。そして、クエリの発行を意識しなくても良いため、今回扱っている N + 1 問題が発生したとしても、それに気付けない開発者が多くなります。

クエリの発行タイミングに関しては少し難しい話に感じるかもしれませんが、N + 1 問題を理解する上で重要なのは、結局ウェブアプリは所持していないレコードの情報を表示するためにはデータベースからのレコードの取得が必要となり、その際にクエリが発行されるという点になります。

逆に、必要なレコードをウェブアプリが既に所持しているのであればクエリは発行されません

その例が、上記の例であり、for comment in comments: で必要なレコードを取得しておき、さらに for ループ内部で取得したレコードの情報を表示するだけであれば、for ループ内部でレコードを取得するためのクエリは発行されることはありません。ですが、for ループ内部で “取得済みのレコード以外” のレコードの情報を表示しようとすると for ループ内部でクエリが発行されることになってしまいます。そして、この for ループ内部でのクエリの発行が行われる際に N + 1 問題が発生することになります。

MEMO

実は、正確には、レコードを既に取得している場合でもクエリが発行される場合があります

その点については後述で補足しますので、まずは、レコードを既に取得していれば、そのレコードを取得するためのクエリは発行されないという “当たり前” の前提を頭に入れて記事を読み進めていただければと思います

じゃあ、どんな時に for ループ内部で “取得済みのレコード以外” のレコードの情報を表示することになるのか、次はその点について考えていきたいと思います。ズバリ言うと、これはリレーションを設定している場合になります。

と言うことで、次の節ではリレーションのおさらいをしていきたいと思います。が、ここで少し、ここまでの説明に補足を加えておきたいと思います。

発行するクエリの情報の更新

ここまでの説明で、下記ではクエリは発行されないと説明しました。では、下記では何が行われるのでしょうか?

???
comments = Comment.objects.all()

上記で行われるのは、直感的にはインスタンス(レコード)の取得と考えて良いと思います。コード的にはそのように考えて問題ありません。

ですが、実際の動作としては、上記で行われるのはクエリの生成のみとなります。もっと正確に言えば、クエリを生成するための情報の設定のみが行われることになるのですが、簡単に “クエリの生成” と考えて説明していきたいと思います。

具体的には、上記では Comment の全レコードを取得するためのクエリの生成が行われることになります。ですが、前述の通り、この時点ではクエリは発行されません。クエリが発行されるのはレコードが必要になったタイミングであり、さらにクエリは、必要なレコードのみを取得するように上記で生成されたクエリを後から更新して発行される可能性があります。

例えば、下記の場合であれば、Comment.objects.all() で生成されたクエリが for comment in comments: で発行されることになります。Comment.objects.all() では Comment のテーブルの全レコードを取得するためのクエリが生成されます。

Commentの全レコード表示
comments = Comment.objects.all()
for comment in comments:
    print(comment.text)

ですが、下記の場合、for ループを実現するのに必要となる Comment のレコードは Comment のテーブルの全てではなく、先頭の 5 件分のみで良いことになります。

Commentの5件分のレコード表示
comments = Comment.objects.all()
for comment in comments[:5]:
    print(comment.text)

したがって、この場合は Comment.objects.all() で全レコードを取得するために生成されたクエリを for comment in comments[:5]: で先頭の 5 件のレコードのみを取得するクエリに上書き、そのクエリが発行されることになります。必要なレコードが 5 件だけなのに全てのレコードを取得するのは無駄なので、実際に必要なレコードのみが取得されるよう Django フレームワークが自動的にクエリを更新してくれるというわけです。

このように、クエリは必要なレコードのみの取得に絞られるようにどんどん更新され、最終的に必要なレコードのみが取得されるようにクエリが発行されることになります。

発行されるクエリの確認方法

また、クエリは Django フレームワークから自動的に発行されるため、どのタイミングでどんなクエリが発行されるかが通常の Django 使い方だと分かりにくいです。

もし、クエリが発行されるタイミングや発行されるクエリの内容を確認したい場合、下記ページで紹介している django-debug-toolbar を利用することをオススメします。

クエリの情報の確認ページアイキャッチ【Django】発行されるクエリの確認方法(django-debug-toolbar)

django-debug-toolbar を利用すれば、ページ表示後に、そのページを表示するために発行されたクエリの内容や、どの処理を実行した時にクエリが発行されたかを確認することができるようになります。このページの本題である N + 1 問題が発生しているかどうかも確認しやすくなるため、ウェブアプリを公開する前に一度は django-debug-toolbar を利用して自身のウェブアプリのクエリの情報を確認しておくことをオススメします。

リレーション

さて、少し話が逸れましたが、次はリレーションについておさらいしていきます。このリレーションを利用している場合、N + 1 問題が発生する可能性があります。

Django では、モデルクラスに OneToOneFieldForeignKeyManyToManyField のフィールドを持たせることで、他のモデルクラスとの間に関係性を持たせることができます。このモデルクラス同士に関係性を持たせることをリレーションの設定と呼んでいます。

例えば、先程紹介した models.py に下記のように User を追加したとしましょう。この場合、CommentUser は互いに独立した存在になります。つまり関係性がありません。

Userの追加
from django.db import models

class User(models.Model):
    name = models.CharField(max_length=256)

class Comment(models.Model):
    text = models.CharField(max_length=256)

それに対し、下記のように Commentuser フィールドを追加した場合、CommentUser の間に関連性が生まれることになります。

Userの追加
from django.db import models

class User(models.Model):
    name = models.CharField(max_length=256)

class Comment(models.Model):
    text = models.CharField(max_length=256)
    user = models.ForeignKey(User, on_delete=models.CASCADE)

ForeignKey は1対多の関係性を設定するためのフィールドであり、その ForeignKey のフィールドを持たせたモデルクラス(上記の場合は Comment)が1対多の関係性における “多” の関係となり、ForeignKey の第1引数で指定したモデルクラス(上記の場合は User)が1対多の関係性における “1” の関係となります。つまり、1つの User のインスタンスに対して、複数の Comment を関連づけることができるようになります。

どういう関係性であるかはウェブアプリによって異なりますが、上記の場合は、Comment のインスタンスは掲示板アプリに投稿されたコメントを表し、その user フィールドは、そのコメントを投稿したユーザーを管理するフィールドであると考えると関係性がイメージしやすくなると思います。

UserとCommentの関連性を示す図

テーブルで考えると、下の図のように ForeignKey を持たせた側のモデルクラス(上記の場合は Comment)のテーブルに、相手側のモデルクラス(上記の場合は User)のレコードとの関連性を管理するためのフィールドが追加されます。そして、このフィールドでは、関連性を持つレコードのプライマリーキーが格納されることになります。

今回の例の場合は、Comment のテーブルに user_id というフィールドが追加され、このフィールドには User のレコードのプライマリーキー(id)が設定されます。そのため、このフィールドから、そのレコードと関連付けられているレコードを特定することができるようになります。

他のモデルクラスとの関連性を持たせるためのフィールドが追加される様子

そして、このように関連性を持たせたインスタンスは互いにデータ属性から他方のインスタンスにアクセスすることができるようになります。例えば下記のような処理を行えば、Comment のインスタンスから投稿者の名前を表示するようなことが可能です。

userへのアクセス
# commentはCommentのインスタンス

print(comment.user.name)

ここで重要な点は2つあって、1つ目はリレーションの設定を行うことで特定のインスタンスから、そのインスタンスと関連性を持つ “他のモデルクラスの” インスタンスにアクセスすることができるようになるという点になります。リレーションの設定が行われていなければ、そのモデルクラスのインスタンスからは自身のフィールドにしかアクセスすることができません。ですが、リレーションを設定することで、自身のインスタンスのフィールドだけでなく、他のモデルクラスのインスタンスにアクセスできるようになります。

2つ目は、リレーションの設定によって複数のモデルクラスの間に関連性を持たせることができるものの、結局これらのテーブルは別のものという点になります。

スポンサーリンク

N + 1 問題

さて、上記のようにリレーションの設定を行なった場合、N + 1 が発生することがあります。

まずは、下記の処理について考えてみましょう。この処理では、クエリは何回発行されることになるでしょうか?

全Commentのインスタンスのtextの表示
comments = Comment.objects.all()
for comment in comments:
    print(comment.text)

答えは 1 回ですね!

これは クエリの発行 でも確認した例で、2行目の for comment in comments: が実行されるタイミングで Comment のテーブルのレコードを全て取得するためのクエリが 1 回発行されます。そして、for ループ内の処理は既に取得済みの Comment のレコードのみで実現することができるため、for ループ内の処理ではクエリは発行されません。そのため、クエリの発行回数は合計 1 回のみとなります。

それに対し、下記の例ではクエリは何回発行されるでしょうか?

全Commentのインスタンスの投稿者の表示
comments = Comment.objects.all()
for comment in comments:
    print(comment.user.name)

この場合も、先ほどと同様に comment のデータ属性を print 関数で出力しているだけです。先ほどと同様の感覚でこの処理も記述することが可能ですが、先程の例とは決定的な違いがあります。もうここまで解説を読んでくださった方であればお気づきだと思いますが、それは、comment.user.name の出力を行うためには2つのレコードが必要になるという点になります。

まず、commentComment のテーブルのレコードとなるため、Comment のテーブルのレコードが必要となります。さらに、comment.usercomment と関連性を持つ User のテーブルのレコードとなりますので、User のテーブルのレコードも必要になります。comment.user.nameUser のテーブルのレコードの name フィールドですので、User のテーブルのレコードを取得すれば、この name フィールドは出力可能となります。

クエリの発行 (1 回)

そして、上記の処理では、これまで通り for comment in comments: の実行時にクエリが発行され、Comment のテーブルの全レコードが取得されることになります。つまり、ここでクエリが 1 回発行されることになります。

ループ処理に入る前にCommentのテーブルのレコードを全て取得する様子

ここで Comment のテーブルの全レコードが取得されるため、for ループ内部の処理では Comment のレコードを取得するためのクエリの発行は不要となります。

クエリの発行 (N 回)

ですが、ここで取得されるのはあくまでも Comment のテーブルのレコードのみであり、User のテーブルのレコードは取得されません。

そのため、for ループ内部の処理に移る前に取得されたレコードのみでは print(comment.user.name) を実現することはできません。別途 User のテーブルのレコードが必要となります。なので、print(comment.user.name) が実行されるタイミングで User のテーブルのレコードを取得するためのクエリが発行されることになります。

ここで必要になるレコードは comment.user で関連付けられる User のテーブルのレコードととなります。もっと具体的にいうと、その Comment のレコードの “user_id フィールドと id フィールドが一致する User のテーブルのレコード” となります。例えば、その Comment のレコードの user_id フィールドが 7 であれば、id7 である User のレコードが必要となります。

そのため、この user_id に応じたレコードの取得を行うためのクエリが発行されることになります。そして、クエリを発行してレコードを取得した後に print(comment.user.name) よって出力が行われることになります。

実現したい処理を行うためにレコードが足りないので新たにクエリを発行する様子

ここで発行されるクエリは下記のようなものになります。下記における WHERE "app_user"."id" = '45' が、”user_id と一致する id を持つレコード” を要求するために指定する条件となります。45 の部分は user_id によって変化します。

SELECT "app_user"."id",
       "app_user"."name"
  FROM "app_user"
 WHERE "app_user"."id" = '45'
 LIMIT 21

そして、次の週のループに移った際には、comment は先ほどとは異なる Comment のテーブルのレコードとなるため、comment.user (user_id)によって関連付けられる User のレコードも異なる可能性があります。そのため、再度 comment.user に応じたレコードの取得を行うためのクエリが発行されることになります。次以降も同様ですね!

つまり、ループ内部の処理が実行されるたびに User のテーブルのレコードを取得するためのクエリが発行されることになります。ループ内部の処理はループ回数分実行されることになるため、今回は Comment のテーブルのレコード数分だけループ内部の処理でクエリが発行されることになります。Comment のテーブルのレコード数を N とすれば、N 回クエリが発行されることになります。

ループの内部の処理で繰り返されるクエリの発行回数を示す図

さらに、ループに入る前に Comment のテーブルの全レコードを取得するために 1 回クエリが発行されるため、合計で N + 1 回のクエリが発行されることになります。つまり、まさに上記の例は、N + 1 問題が発生する例となります。

テンプレートでのクエリの発行

また、上記ではビューで print によって comment.user.name の出力を行うことを想定した例となっていますが、同様の問題はテンプレートを利用した場合にも発生します。

例えばビューで下記のように Comment.objects.all() の結果をコンテキストの 'users' キーにセットして render 関数を実行するものとし、

ビューの例
comments = Comment.objects.all()

context = {
    'comments' : comments
}
ret = render(request, 'app/comments.html', context)

さらにテンプレートファイルで下記のように for ループの中で comment.user.name を出力するものとした場合も同様に N + 1 問題が発生することになります。

ビューの例
{% for comment in comments %}
<p>{{ comment.user.name }}</p>
{% endfor %}

この場合は、下記で 1 回クエリが発行されることになり、

クエリの発行箇所(1回分)
{% for comment in comments %}

さらにループ内部の下記で N 回クエリが発行されることになります。

クエリの発行箇所(N回分)
<p>{{ comment.user.name }}</p>

当たり前と言えば当たり前なのですが、テンプレートを利用したとしても、結局ウェブアプリが所持していないレコードの情報は利用することができません。そのため、ウェブアプリが所持していないレコードを利用しようとする段階で、そのレコードを取得するためのクエリが発行されることになります。

そして、上記のように、タグ for を利用してレコードを複数表示するようなテンプレートファイルは多くの方が当たり前のように実装されている形式のコードになると思います。例えばユーザー一覧・コメント一覧などの一覧リストを表示するときは上記のような形式に自然となると思います。前述の通り、そのレコードが登録されているテーブルが独立したものであれば問題ないですが、他のテーブルとリレーションが設定されている場合、N + 1 問題が発生している可能性があります。ですが、Django フレームワークが裏側で自動的にクエリを発行してくれているので、N + 1 問題が発生していたとしても気づきにくいことになります。

もし、あなたがウェブアプリを開発したことがあるのであれば、是非 N + 1 問題が発生していないかどうかを確認してみていただければと思います。特にリレーションを利用している場合、自然と for ループ内部で “for ループに入る前に取得したレコード以外” のレコードを利用している可能性があり、その場合は N + 1 問題が発生しているはずです。

N + 1 問題の解決方法(select_relatedprefetch_related

N + 1 問題については大体理解していただけたでしょうか?

次は、N + 1 問題の解決方法について説明していきます。

N + 1 問題が発生する原因は、結局は for ループ内部で “for ループに入る前に取得したレコード以外” のレコードを利用しようとすることになります。先程の例でいえば、for ループに入る前に Comment のテーブルのレコードは取得したものの、for ループ内部で Comment のテーブルのレコードだけでなく User のテーブルのレコードを利用しようとしているため N + 1 問題が発生します。そして、このような N + 1 問題は、リレーションの設定によって互いに関連付けられたテーブルのレコードから他方のレコードに for ループ内部でアクセスするような場合に発生します。

であれば、for ループに入る前にリレーションの設定によって関連付けられた一方のテーブルのレコードを取得するだけでなく、それらのレコードと関連付けられている他方のテーブルのレコードを取得しておけば、この N + 1 問題は解決可能となります。

ただし、実はそれらのレコードを別々のクエリを発行して取得しておくだけではダメで、Django から提供されるメソッドを利用して取得する必要があります。これらのメソッドが select_relatedprefetch_related となります。

関連しているレコード同士を結合した状態でレコードを取得してしまおう!という考え方で N + 1 問題を解決するのが前者の select_related になります。

select_relatedの動作の説明図

レコードは結合はしないものの、関連性の持つレコードも一緒に取得しておき、一方のレコードから他方のレコードを参照できるように関連性も管理しておこう!という考え方で N + 1 問題を解決するのが後者の prefetch_related になります。

prefetch_relatedの動作の説明図

つまり、ループに入る前にレコードを取得する際に上記の select_related や prefetch_related を利用すれば、ループ内部で必要となる情報を全て含むレコードが事前に取得されることになるため、ループ内部でのクエリの発行を防ぐことができます。したがって、これらを利用した場合に必要となるクエリは N に比例して増えるのではなく、定数回で済むことになります。つまり N + 1 問題が解消されます。

select_related による N + 1 問題の解決

先程少し説明しましたが、select_related はリレーションが設定された2つのテーブルを結合するメソッドとなります。

select_related の基本的な使い方

select_related は下記のような形式で使用することになります。

select_relatedの使い方
モデルクラス.objects.select_related('フィールド名')

フィールド名 には、他のモデルクラスとのリレーションを設定しているフィールドの名前を指定します。

例えば、モデルクラス に下記の Comment を指定した場合、フィールド名 には ForeignKey となる user を指定することになります。

UserとComment
from django.db import models

class User(models.Model):
    name = models.CharField(max_length=256)

class Comment(models.Model):
    text = models.CharField(max_length=256)
    user = models.ForeignKey(User, on_delete=models.CASCADE)

select_related の効果

select_related を利用することで、モデルクラス で指定したモデルクラスのテーブルの全レコードと フィールド名 で指定したフィールドで関連付けられるレコードが “結合されたレコード” を取得するためのクエリが生成されることになります。あくまでもクエリは生成されるだけで、クエリの発行はレコードが必要になったタイミングで実行されます。また、後からクエリを上書きして取得するレコードの条件等を加えることも可能です。

例えば、Comment のテーブルと User のテーブルがそれぞれ下の図のような状態である場合、

例として扱うテーブル2つの紹介図

下記を実行した場合は、単に Comment のテーブルの全レコードを取得するクエリが生成されるだけになります。

Commentのテーブルのレコードの取得
comments = Comment.objects.all()

具体的には、これによって生成されるクエリが発行されると下の図のようなレコードが取得されることになります。

通常のクエリの発行だと1つのテーブルのレコードのみが取得されてしまう様子

それに対し、下記のように select_related を利用した場合、単に Comment のテーブルの全レコードを取得するのではなく、各レコードと、そのレコードの user_id フィールドと一致するプライマリーキーが設定された User のテーブルのレコードとを結合することを要求するクエリが発行されることになります。

select_relatedの利用例
comments = Comment.objects.select_related('user')

そして、クエリが発行された際には、それらが結合された状態のレコード取得されることになります。具体的には、下の図のようなレコードが取得されることになります。

select_relatedの動作の説明図

select_related により N + 1 問題が解決される理由

で、ここで N + 1 問題が発生していた例で考えると、下記のように select_related を利用するようにすれば、上の図のような Comment のテーブルと User のテーブルとが結合された状態のレコードが取得されることになり、ループ内部での処理でクエリの発行が必要なくなります。なぜなら、ループに入る前に取得するレコードには Username フィールドが含まれているからです。ループ内部で必要になる情報が全て取得済みのレコードに含まれているため、別途クエリを発行する必要はありません。

全Commentのインスタンスの投稿者の表示
comments = Comment.objects.select_related('user')
for comment in comments:
    print(comment.user.name)

そして、この場合にクエリが発行されるタイミングは for comment in comments: のみとなり、発行されるクエリは下記のようなものとなります。

SELECT "app_comment"."id",
       "app_comment"."text",
       "app_comment"."user_id",
       "app_user"."id",
       "app_user"."name"
  FROM "app_comment"
 INNER JOIN "app_user"
    ON ("app_comment"."user_id" = "app_user"."id")

上記は Comment のテーブル(app_comment)と User のテーブル(app_user)の2つのテーブルを結合し、その上でお互いのテーブルの全フィールドを含む全レコードを取得するクエリとなっています。テーブルを結合する際には、Comment のレコードに関連する User のレコードが横に並べられる形で結合されます。どのレコード同士が関連するかは Comment のレコードの user_id によって決まります。

上記のクエリを 1 回発行すれば、先ほど示したループの内部の処理ではクエリの発行が不要となるため、select_related を利用することでクエリの発行回数が N + 1 回から 1 回に削減されることになります。

クエリの発行回数が定数回となっているため、クエリの発行回数が表示するレコードの件数に比例して増加することを防ぎ、N + 1 問題を解決することができています。

select_related の様々な使い方

ちなみに、下記の2行ではどちらも同じクエリが生成されることになります。なので、どちらを使用しても良いです。

全レコードを取得するクエリ
Comment.objects.select_related('user')
Comment.objects.select_related('user').all()

また、全レコードではなく、条件を満たすレコードのみを取得するためのクエリを生成するのであれば、下記のように filter と組み合わせて利用すれば良いことになります。この場合、select_related で2つのテーブルを結合した状態で全レコードを取得するクエリを生成し、そのクエリを filter によって更新することになります。

条件を満たすレコードを取得するクエリ
Comment.objects.select_related('user').filter(条件)

スポンサーリンク

prefetch_related による N + 1 問題の解決

次は prefetch_related について説明していきます。

prefetch_related の基本的な使い方

prefetch_related はリレーションが設定された2つのテーブルを取得するメソッドとなります。prefetch_related は下記のような形式で使用することになります。

prefetch_relatedの使い方
モデルクラス.objects.prefetch_related('フィールド名')

フィールド名 には、他のモデルクラスとのリレーションを設定しているフィールドの名前を指定します。

例えば、モデルクラス に下記の Comment を指定した場合、フィールド名 には ForeignKey となる user を指定することになります。

UserとComment
from django.db import models

class User(models.Model):
    name = models.CharField(max_length=256)

class Comment(models.Model):
    text = models.CharField(max_length=256)
    user = models.ForeignKey(User, on_delete=models.CASCADE)

prefetch_related の効果

prefetch_related を利用することで、モデルクラス で指定したモデルクラスのテーブルの全レコードを取得するためのクエリと、フィールド名 で指定したフィールドで関連付けられるレコードを取得するためのクエリが生成されることになります。

例えば、Comment のテーブルと User のテーブルがそれぞれ下の図のような状態である場合、

prefetch_relatedの説明を行うにあたり例として扱うテーブル

下記のように prefetch_related を実行すると2つのクエリが生成されることになります。1つ目が Comment のテーブルの全レコードを取得するクエリで、2つ目が、そのクエリによって取得される Comment のテーブルの “各レコードと関連づけられている User のテーブルのレコード” を取得するクエリとなります。

prefetch_relatedの利用例
comments = Comment.objects.prefetch_related('user')

したがって、上の図のようなテーブルの場合、Comment.objects.prefetch_related('user') によって生成されるクエリが発行されると下の図のような2つのテーブルのレコードが取得されることになります。

prefetch_relatedの動作の説明図

User のテーブルの id : 3 のレコードが取得されないのは、そのレコードと関連づけられている Comment のテーブルのレコードが存在しないから、すなわち、user_id3 であるレコードが存在しないからになります。

また、prefetch_related を利用した場合、2つのテーブルに対するクエリが別々に生成されることになりますが、これらのクエリの発行によって取得されたレコードはそれぞれ独立した状態ではなく、各レコードの関連性を保ったまま Django フレームワークによって管理されるようになります。後述でも例を示しますが、prefetch_related を利用せずに2つのテーブルに対して別々にクエリを発行すると、それぞれ独立したレコードとして扱われてしまうため、N + 1 問題を解決することができません。

MEMO

select_related では、テーブルの結合はデータベース側で行われます

そのため、テーブルの結合を行うためのクエリが発行されることになります

それに対し、prefetch_related では、イメージとしては2つのテーブルのレコードを別々に取得し、取得したレコードを別途 Django フレームワークで結合すると考えると分かりやすいと思います

そのため、prefetch_related ではテーブルの結合を行うためのクエリではなく、2つのテーブルのレコードを取得するためのクエリが発行されることになります

そして、2つのテーブルのレコードを結合することで、それらのレコードの関連性を保ったまま Django フレームワーク内でレコードを管理することができるようになります

prefetch_related により N + 1 問題が解決される理由

そして、ここで重要になるのは、prefetch_related を実行して生成されたクエリが発行されることでループ内で必要となるレコードが全て取得できる点と、これらの各レコードの関連性が Django フレームワークで管理されるという点になります。このため、N + 1 問題を prefetch_related によって解決することが可能となります。

例えば、下記のように prefetch_related を実行するようにすれば、prefetch_related で生成されたクエリが for comment in comments: 実行時に発行されることになります。そして、その発行によって取得されるのは上の図のような Comment のテーブルのレコードと User のテーブルのレコードとなります。これらはクエリ発行時には独立した状態で取得されますが、別途 Django 内部で各レコードの関連性が管理されるようになります。

全Commentのインスタンスの投稿者の表示
comments = Comment.objects.prefetch_related('user')
for comment in comments:
    print(comment.user.name)

したがって、ループ内部で Comment のレコードから User のレコードを参照しようとした際には、クエリを発行してレコードを取得するのではなく、既に取得したレコードの中から、その Comment のレコードと関連付けられている User のレコードを参照するようになります。したがって、別途クエリを発行することが不要となり、発行されるクエリは prefetch_related で生成される2つのクエリのみで済むことになります。

prefetch_relatedで取得されたレコードがループ内の処理で参照される様子

そのため、上記のループ処理を行なったとしても、クエリの発行回数は 2 回のみとなり、N + 1 問題を解消することができることになります。

関連性が管理されていない場合のクエリの発行

で、ここで重要になるのが prefetch_related を利用した場合は各レコードの関連性が Django フレームワークによって管理された状態になるという点になります。

例えば、少し無理矢理な例になりますが、下記のような処理を行なった場合、レコードの件数を求めるために全レコードが必要になるため、len(comments)Comment の全レコードを取得するためのクエリが、len(users)User の全レコードを取得するためのクエリがそれぞれ発行されることになります。したがって、for ループの処理は CommentUser の全レコードが取得された状態で実行されることになります。

2つのテーブルのレコードを別々に取得する例
comments = Comment.objects.all()
users = User.objects.all()

len(comments)
len(users)

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

ですが、この場合、Django フレームワークでは、Comment の各レコードと User の各レコードは独立したものとして扱われてしまいます。そのため、print(comment.user.username) 実行時には comment に関連付けられているレコードが未取得であると判断され、クエリが発行されることになります。つまり N + 1 問題が発生することになります。

このように、ループ内部の処理で必要になるレコードを全て取得していたとしても、それぞれのレコードの間の関連性が管理されていないと N + 1 問題が発生することになります。したがって、これらの関連性が管理されるようにレコードの取得を行う必要があり、それを行うためのメソッドが prefetch_related になります。

このページの前半の クエリは自動的に発行される の MEMO で、必要なレコードを取得していたとしてもクエリが発行されることがあると説明しましたが、まさに上記のように prefetch_related を利用せずにレコードを取得した場合がそれに該当します。

prefetch_related の様々な使い方

また、select_related 同様に、下記のように all と併用した場合も prefetch_related 単体で実行した場合と同じクエリが生成されることになります。

prefetch_relatedの利用例2
comments = Comment.objects.prefetch_related('user').all()

さらに、下記のように filter と併用して取得する Comment のレコードの条件を指定することも可能です。

prefetch_relatedの利用例3
comments = Comment.objects.prefetch_related('user').filter(条件)

select_relatedprefetch_related の使い分け

さて、ここまで N + 1 問題の解決方法として select_related を利用する方法と prefetch_related を利用する方法の2つを紹介してきました。では、この2つはどのようにして使い分ければ良いでしょうか?

selected_related を利用するケース

どちらの方法でもクエリの発行回数が固定となって N + 1 問題は解決可能です。ですが、select_related の方がクエリ発行回数が 1 回のみで prefetch_related の場合は 2 回となり、クエリ発行回数で考えれば selected_related の方が良さそうですね!

なので、selected_related を使えるのであれば selected_related を使うので良いと思います。ですが、実は select_related は利用できるケースが限られているので注意してください。

具体的には、selected_related が利用可能なのは、このメソッドを実行するモデルクラスが、リレーション設定相手となるモデルクラスに対して “1の関係” にある場合のみとなります。

select_relatedが利用可能な条件を説明する図

Django ではモデルクラス間に設定できるリレーションは下記の3種類となります。

  • 1対1(OneToOneField
  • 1対多(ForeignKey
  • 多対多(ManyToManyField

分かりやすいのが1対1と多対多の関係で、1対1の場合、2つのモデルクラスにおいて、相手側のモデルクラスは両方とも “1の関係” となります。つまり、1対1のリレーションが設定されている場合は何も考えずに select_related が利用してしまえばよいです。

1対1の関係の例

逆に多対多の場合は、どちらのモデルクラスも必ず相手のモデルクラスに対して “多の関係” にあることになります。したがって、この場合は select_related は利用不可であるため、prefetch_related を利用することになります。

多対多の関係を示す図

では1対多の場合はどうでしょう?

この場合は、子モデルのモデルクラスに対してのみ select_related が利用可能となります。

この子モデルとは、下記のように1対多のリレーションを設定する場合、ForeignKey を持つ側のモデルクラスのことを言っています。逆に、ForeignKey の第一引数に指定される側のモデルクラスは親モデルとなります。

親モデルと子モデル
from django.db import models

class 子モデル(models.Model):
    フィールド名 = models.ForeignKey(親モデル, 略)

この場合、子モデル側は ForeignKey のフィールドには1つの親モデルしか参照させることができません。ですが、複数の子モデルのインスタンスが同じ親モデルのインスタンスを参照する可能性があります。つまり、1つの親モデルのインスタンスに対し、複数の子モデルのインスタンスが関連付けられる可能性があります。したがって、子モデルが1対多の関係における “多の関係” となり、親モデル側が “1の関係” となります。

1対多の関係を示す図

そして、select_related が利用可能なのは、相手側のモデルクラスが “1の関係” である場合ですので、1対多の関係の場合は子モデル側に関してのみ select_related が利用可能となります。

ここまでの解説では主に UserComment を使って説明してきましたが、これらは下記のように定義されているため、Comment が子モデルとなります。したがって select_related が利用可能なのは Comment 側のみとなります。

UserとComment
from django.db import models

class User(models.Model):
    name = models.CharField(max_length=256)

class Comment(models.Model):
    text = models.CharField(max_length=256)
    user = models.ForeignKey(User, on_delete=models.CASCADE)

あくまでも select_related が利用可能なのは相手側が “1の関係” であるモデルクラスであり、自分自身が “1の関係” であるかどうかは関係ないので気をつけてください。select_related が利用可能かどうかを考えるためには相手側のモデルクラスの立場を考える必要があるため若干ややこしいです…。どう覚えるのが簡単かは人によるかもしれませんが、個人的には単純に ForeignKey を持つモデルクラスの方が select_related が利用可能であると考えるのがシンプルだと思っています。

そして、前述の通り select_related が利用可能なのであれば、N + 1 問題を解決するためには select_related を利用するのが良いと思います。

prefetch_related を利用するケース

そして、select_related が利用できない場合は、もう選択肢は1つしか残っていないので prefetch_related を利用するしかないです。

select_related が利用可能なのが「相手側のモデルクラスが “1の関係” であるモデルクラス」であるのに対し、select_related が利用不可なのは「相手側のモデルクラスが “多の関係” であるモデルクラス」となります。具体的には、多対多の関係における両方のモデルクラス、および、1対多の関係における親モデルが該当します。なので、これらの場合は prefetch_related を利用する必要があります。

例えば、ここまでの説明でも何度も紹介した UserComment において、User 側のテーブルからレコードを取得してから for ループを回すような場合で、さらにそれによって N + 1 問題が発生するのであれば、N + 1 問題を解決するためには prefetch_related を利用する必要があります。

ただし、親モデルから prefetch_related を利用する場合、引数に指定する文字列は下記のいずれかである必要があるので注意してください。

  • related_name を指定した場合:related_name に指定した文字列
  • related_name を指定しない場合:子モデル名_set (全て小文字)

要は、リレーションを設定することで各インスタンスに追加されるデータ属性の名称を prefetch_related の引数に指定すれば良いです。related_name を指定した場合、親モデル側には related_name に指定した文字列を名前とするデータ属性が追加されます。それ以外の場合、親モデル側には 子モデル名_set という名前のデータ属性が追加されます。この辺りは下記ページで解説していますので、詳しくは下記ページをご参照ください。

Django のリレーションについての解説ページアイキャッチ【Django入門7】リレーションの基本

例えば、ここまでも紹介してきたモデルクラス User から prefetch_related を利用する場合は下記のように処理を記述する必要があります。

親モデルからのprefetch_relatedの利用例1
users = User.objects.prefetch_related('comment_set')

ですが、Commentuserフィールドの定義を下記のように変更すれば、

related_nameの指定
user = models.ForeignKey(User, related_name='comments', 略)

今度は prefetch_related の引数に指定すべき文字列は  related_name に指定した文字列 'comments' となります。

親モデルからのprefetch_relatedの利用例2
users = User.objects.prefetch_related('comments')

このように、相手側が “多の関係” である親モデルから prefetch_related を利用するのは結構ややこしいので注意してください。

相手側が “多の関係” の場合の N + 1 問題の解決

この章の最後として、相手側が “多の関係” である親モデルから prefetch_related を利用することで N + 1 問題を解決する例を示しておきます。

下記は User のテーブルから全レコードを取得し、さらに、各 User のレコードに関連付けられている Comment のインスタンスを全て表示する例となります。この場合、for user in users: で1回クエリが発行され、for comment in comments:User のレコードの数だけクエリが発行されることになります。レコードの件数に応じてクエリの発行回数が増えるので、N + 1 問題が発生していると言えます。

N+1問題が発生する例
users = User.objects.all()

for user in users:
    comments = user.comment_set.all()
    for comment in comments:
        print(user.name + ':' + comment.text)

これに関しては、前述の通り prefetch_related を利用することで N + 1 問題を解決することができます。この場合のクエリの発行回数は 2 回となります。

N+1問題を解消する例
users = User.objects.prefetch_related('comment_set')

for user in users:
    comments = user.comment_set.all()
    for comment in comments:
        print(user.name + ':' + comment.text)

ちなみに、下記のように select_related を利用しようとすると例外が発生することになります。

例外が発生する例
users = User.objects.select_related('comment_set')

for user in users:
    comments = user.comment_set.all()
    for comment in comments:
        print(user.name + ':' + comment.text)

発生する例外は下記のようなものになります。

Invalid field name(s) given in select_related: 'comment_set'. Choices are: (none)

例外が発生するのは、これも前述の通り、User が1対多の関係における親モデルであるためです。すなわち、相手側のモデルクラスが “多の関係” にあり、この場合は select_related の利用が不可となるためです。

ここまで説明してきたように、select_relatedprefetch_related の両方で N + 1 問題を解決することは可能ですが、状況によって適切にこの2つは使い分ける必要があります。

掲示板アプリの N + 1 問題を解決する

最後に、いつも通りの流れで、この Django 入門の連載の中で開発してきている掲示板アプリに対し、N + 1 問題の解決を行なっていきたいと思います。

この Django 入門に関しては連載形式となっており、ここでは以前に下記ページの 掲示板アプリでページネーションを利用してみる で作成したウェブアプリに対し、N + 1 問題が発生している箇所の修正を行なっていきたいと思います。

Djangoにおけるページネーションの解説ページアイキャッチ【Django入門12】ページネーションの基本

現状、このアプリでは、「ユーザー一覧」「コメント一覧」の2つのページで表示時に N + 1 問題が発生するようになってしまっています。

「ユーザー一覧」では、User のレコードのみを取得した状態で、その User のレコードに関連付けられている Comment のレコードの情報を表示しようとするため N + 1 問題が発生してしまっています。

掲示板アプリでN+1問題が発生している原因1

「コメント一覧」では、Comment のレコードのみを取得した状態で、その Comment のレコードに関連付けられている User のレコードの情報を表示しようとするため N + 1 問題が発生してしまっています。

掲示板アプリでN+1問題が発生している原因2

前回の連載時に、これらのページではページネーションを行うようにしており、各ページで表示されるレコードの件数に上限が設けられるようになっています。なので、全レコードの件数に比例してクエリ発行回数が増加するのではなく、その表示するレコードの上限数に比例してクエリの発行回数が増えるようになっています。

したがって、ページネーションを行う前に比べると、1つのページを表示するために必要となるクエリの発行回数は減っています。ですが、それでもページに表示するレコードの件数に応じてクエリの発行回数は増えることになっていますので、その点の解決を行なっていきます。

スポンサーリンク

views.py の変更

現状の掲示板アプリで発生している N + 1 問題は、結論としては views.py を下記のように変更することで解決することができます。前回の連載時の views.py からの変更点は users_viewscomments_views の中で太字で示した2行のみとなります。

変更後のviews.py
from django.shortcuts import redirect, render, get_object_or_404
from .forms import RegisterForm, PostForm, LoginForm
from .models import Comment
from django.contrib.auth.decorators import login_required
from django.contrib.auth import login, logout
from django.contrib.auth import get_user_model
from django.core.paginator import Paginator

User = get_user_model()


def login_view(request):
    if request.method == 'POST':
        form = LoginForm(data=request.POST)

        if form.is_valid():
            user = form.get_user()

            if user:
                login(request, user)
                return redirect('user', user.id)

    else:
        form = LoginForm()

    context = {
        'form': form,
    }

    return render(request, 'forum/login.html', context)


def logout_view(request):
    logout(request)
    return redirect('login')


def index_view(request):
    return redirect(to='comments')


@login_required
def users_view(request):
    users = User.objects.prefetch_related('comments').all()

    paginator = Paginator(users, 3)
    number = int(request.GET.get('p', 1))
    page_obj = paginator.page(number)

    context = {
        'page_obj': page_obj
    }

    return render(request, 'forum/users.html', context)


@login_required
def user_view(request, user_id):
    user = get_object_or_404(User, id=user_id)
    comments = Comment.objects.filter(user=user)

    paginator = Paginator(comments, 3)
    number = int(request.GET.get('p', 1))
    page_obj = paginator.page(number)

    context = {
        'user': user,
        'page_obj': page_obj
    }

    return render(request, 'forum/user.html', context)


@login_required
def comments_view(request):
    comments = Comment.objects.select_related('user').all()

    paginator = Paginator(comments, 3)
    number = int(request.GET.get('p', 1))
    page_obj = paginator.page(number)

    context = {
        'page_obj': page_obj
    }

    return render(request, 'forum/comments.html', context)


@login_required
def comment_view(request, comment_id):
    comment = get_object_or_404(Comment, id=comment_id)

    context = {
        'comment': comment
    }

    return render(request, 'forum/comment.html', context)


def register_view(request):

    if request.method == 'POST':

        form = RegisterForm(request.POST)
        if form.is_valid():
            user = form.save()
            login(request, user)

            return redirect('user', user.id)

    else:
        form = RegisterForm()

    context = {
        'form': form
    }

    return render(request, 'forum/register.html', context)


@login_required
def post_view(request):
    if request.method == 'POST':
        form = PostForm(request.POST)

        if form.is_valid():
            comment = form.instance
            comment.user = request.user
            comment.save()

            return redirect('comments')
    else:
        form = PostForm()

    context = {
        'form': form,
    }

    return render(request, 'forum/post.html', context)

この掲示板アプリでは、UserComment との間に1対多のリレーションを設定しており、User が親モデルで Comment が子モデルになっています(User は、より正確にいうと CustomUser となります)。

Commentの定義(一部抜粋)
class Comment(models.Model):
    user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, null=True, related_name='comments')

そのため、Comment からは select_related が利用可能で、これにより N + 1 問題が解決できます。それに対し、User からは select_related が利用不可なので、prefetch_related を利用して解決する必要があります。また、prefetch_related の引数には、上記で related_name に指定している 'comments' を指定する必要があります。

今回の掲示板アプリの変更は以上になります。いつもは動作確認を実施するのですが、今回はパフォーマンスの改善で見た目として動作確認結果が分かりにくいため、動作確認に関しては省略させていただきます。

前述でも触れた、下記ページで紹介している django-debug-toolbar を利用すれば、変更前に比べてクエリの発行回数が削減されていることを確認することができると思います。

クエリの情報の確認ページアイキャッチ【Django】発行されるクエリの確認方法(django-debug-toolbar)

まとめ

このページでは、Django での N + 1 問題と、その解決方法について解説しました!

N + 1 問題とは、レコードの一覧を表示する際のクエリの発行回数が N + 1 回になってしまうことを言います。この N は表示するレコードの件数となります。このようにクエリの発行回数がレコードの件数に比例して増加してしまうと、レコードの件数が増えるたびにアプリの処理速度が低下することになります。

そのため、クエリの発行回数が N に比例しないようにすることが重要であり、これは select_relatedprefetch_related のいずれかを利用することで解決することが可能です。select_related に関しては利用できるケースが限られているため、状況に応じてこれらを使い分けることが必要となります。

N + 1 問題が発生する原因は「所持していないレコードの情報はデータベースから取得してからでないと表示できない」という当たり前の前提に基づいて考えれば自然と理解できると思います。ぜひ、この前提を頭に入れて、自身のウェブアプリで N + 1 問題が発生していないかどうかを確認し、発生している場合は select_related や prefetch_related を利用して問題を解決してみてください。

また、クエリの発行タイミングや発行回数は、下記ページでも紹介している django-debug-toolbar でも確認できますので、こういったツールを利用して自身のウェブアプリが発行するクエリについて調べてみるのも良いと思います!

クエリの情報の確認ページアイキャッチ【Django】発行されるクエリの確認方法(django-debug-toolbar)

おすすめ書籍(PR)

クエリそのものやデータベースに興味が出てきた方には、別途書籍等でデータベース・SQL・クエリなどについて学んでみても良いと思います。特に下記の スッキリわかるSQL入門 第3版 ドリル256問付き! は、初心者の方でもクエリについて理解しやすい内容となっておりオススメです。こういった書籍でクエリについて学べば、Django でどんな処理を行えばどんなクエリが発行されるのかをイメージしながらウェブアプリ開発が行えるようになりますし、特にパフォーマンスの改善等に、この知識を活かすことができると思います。

同じカテゴリのページ一覧を表示

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です