このページでは N + 1
問題について解説していきます。
いきなりですが、みなさん、Django でのウェブアプリ開発を楽しんでいらっしゃいますか?
Django を利用すればウェブアプリの開発を簡単に行うことができますが、それでもウェブアプリ開発に難しさを感じておられる方もいらっしゃるかもしれません。でも、このページを訪れてくださったという方は、きっとウェブアプリの開発を楽しいと感じられていたり、これからウェブアプリの開発を楽しみたいという方が多いのではないかと思います。実際、ウェブアプリに機能を追加したり、ウェブアプリの見た目を自分好みのものに仕立てていく作業は楽しいと思います。
が、今回は少し現実的な話をしていきます。今回は、ページのタイトルにもなっている N + 1
問題を扱っていきます。この N + 1
問題は機能追加や見た目を綺麗にするようなものではなく、ウェブアプリのパフォーマンス(反応速度や処理速度など)に関わるものになります。なので、このページを読むことで、あなたがウェブアプリで実現可能な機能が増えるというわけではありません。
ですが、今回説明する N + 1
問題は、ウェブアプリを公開していく上では必ず解決しておかなければならない問題となります。そして、この N + 1
問題は Django 利用者が陥りやすい問題です。
もしかしたら、つまらない題材だと感じる方もおられるかもしれませんが、できるだけ分かりやすく解説していきますので、是非 N + 1
問題について、そしてこの問題の解決方法について学んでいっていただければと思います。
Contents
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
にモデルクラスを定義し、そのモデルクラスや、そのインスタンスを利用してデータベースのテーブルの操作を行うことになります。下記ページでも解説しているとおり、このモデルクラスがテーブルであり、そのインスタンスが、そのテーブルのレコードに対応します。
例えば、下記のように models.py
に 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
関数で出力することができます。レコードの情報が表示されるということは、データベースからレコードの取得が行われているということになります。つまり、この処理のどこかでレコードを取得するためのクエリが発行されることになります。
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
問題が発生することになります。
実は、正確には、レコードを既に取得している場合でもクエリが発行される場合があります
その点については後述で補足しますので、まずは、レコードを既に取得していれば、そのレコードを取得するためのクエリは発行されないという “当たり前” の前提を頭に入れて記事を読み進めていただければと思います
じゃあ、どんな時に for
ループ内部で “取得済みのレコード以外” のレコードの情報を表示することになるのか、次はその点について考えていきたいと思います。ズバリ言うと、これはリレーションを設定している場合になります。
と言うことで、次の節ではリレーションのおさらいをしていきたいと思います。が、ここで少し、ここまでの説明に補足を加えておきたいと思います。
発行するクエリの情報の更新
ここまでの説明で、下記ではクエリは発行されないと説明しました。では、下記では何が行われるのでしょうか?
comments = Comment.objects.all()
上記で行われるのは、直感的にはインスタンス(レコード)の取得と考えて良いと思います。コード的にはそのように考えて問題ありません。
ですが、実際の動作としては、上記で行われるのはクエリの生成のみとなります。もっと正確に言えば、クエリを生成するための情報の設定のみが行われることになるのですが、簡単に “クエリの生成” と考えて説明していきたいと思います。
具体的には、上記では Comment
の全レコードを取得するためのクエリの生成が行われることになります。ですが、前述の通り、この時点ではクエリは発行されません。クエリが発行されるのはレコードが必要になったタイミングであり、さらにクエリは、必要なレコードのみを取得するように上記で生成されたクエリを後から更新して発行される可能性があります。
例えば、下記の場合であれば、Comment.objects.all()
で生成されたクエリが for comment in comments:
で発行されることになります。Comment.objects.all()
では Comment
のテーブルの全レコードを取得するためのクエリが生成されます。
comments = Comment.objects.all()
for comment in comments:
print(comment.text)
ですが、下記の場合、for
ループを実現するのに必要となる Comment
のレコードは 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-debug-toolbar
を利用すれば、ページ表示後に、そのページを表示するために発行されたクエリの内容や、どの処理を実行した時にクエリが発行されたかを確認することができるようになります。このページの本題である N + 1
問題が発生しているかどうかも確認しやすくなるため、ウェブアプリを公開する前に一度は django-debug-toolbar
を利用して自身のウェブアプリのクエリの情報を確認しておくことをオススメします。
リレーション
さて、少し話が逸れましたが、次はリレーションについておさらいしていきます。このリレーションを利用している場合、N + 1
問題が発生する可能性があります。
Django では、モデルクラスに OneToOneField
・ForeignKey
・ManyToManyField
のフィールドを持たせることで、他のモデルクラスとの間に関係性を持たせることができます。このモデルクラス同士に関係性を持たせることをリレーションの設定と呼んでいます。
例えば、先程紹介した models.py
に下記のように User
を追加したとしましょう。この場合、Comment
と 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)
それに対し、下記のように Comment
に user
フィールドを追加した場合、Comment
と 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
フィールドは、そのコメントを投稿したユーザーを管理するフィールドであると考えると関係性がイメージしやすくなると思います。
テーブルで考えると、下の図のように ForeignKey
を持たせた側のモデルクラス(上記の場合は Comment
)のテーブルに、相手側のモデルクラス(上記の場合は User
)のレコードとの関連性を管理するためのフィールドが追加されます。そして、このフィールドでは、関連性を持つレコードのプライマリーキーが格納されることになります。
今回の例の場合は、Comment
のテーブルに user_id
というフィールドが追加され、このフィールドには User
のレコードのプライマリーキー(id
)が設定されます。そのため、このフィールドから、そのレコードと関連付けられているレコードを特定することができるようになります。
そして、このように関連性を持たせたインスタンスは互いにデータ属性から他方のインスタンスにアクセスすることができるようになります。例えば下記のような処理を行えば、Comment
のインスタンスから投稿者の名前を表示するようなことが可能です。
# commentはCommentのインスタンス
print(comment.user.name)
ここで重要な点は2つあって、1つ目はリレーションの設定を行うことで特定のインスタンスから、そのインスタンスと関連性を持つ “他のモデルクラスの” インスタンスにアクセスすることができるようになるという点になります。リレーションの設定が行われていなければ、そのモデルクラスのインスタンスからは自身のフィールドにしかアクセスすることができません。ですが、リレーションを設定することで、自身のインスタンスのフィールドだけでなく、他のモデルクラスのインスタンスにアクセスできるようになります。
2つ目は、リレーションの設定によって複数のモデルクラスの間に関連性を持たせることができるものの、結局これらのテーブルは別のものという点になります。
スポンサーリンク
N + 1
問題
さて、上記のようにリレーションの設定を行なった場合、N + 1
が発生することがあります。
まずは、下記の処理について考えてみましょう。この処理では、クエリは何回発行されることになるでしょうか?
comments = Comment.objects.all()
for comment in comments:
print(comment.text)
答えは 1
回ですね!
これは クエリの発行 でも確認した例で、2行目の for comment in comments:
が実行されるタイミングで Comment
のテーブルのレコードを全て取得するためのクエリが 1
回発行されます。そして、for
ループ内の処理は既に取得済みの Comment
のレコードのみで実現することができるため、for
ループ内の処理ではクエリは発行されません。そのため、クエリの発行回数は合計 1
回のみとなります。
それに対し、下記の例ではクエリは何回発行されるでしょうか?
comments = Comment.objects.all()
for comment in comments:
print(comment.user.name)
この場合も、先ほどと同様に comment
のデータ属性を print
関数で出力しているだけです。先ほどと同様の感覚でこの処理も記述することが可能ですが、先程の例とは決定的な違いがあります。もうここまで解説を読んでくださった方であればお気づきだと思いますが、それは、comment.user.name
の出力を行うためには2つのレコードが必要になるという点になります。
まず、comment
は Comment
のテーブルのレコードとなるため、Comment
のテーブルのレコードが必要となります。さらに、comment.user
は comment
と関連性を持つ User
のテーブルのレコードとなりますので、User
のテーブルのレコードも必要になります。comment.user.name
は User
のテーブルのレコードの name
フィールドですので、User
のテーブルのレコードを取得すれば、この name
フィールドは出力可能となります。
クエリの発行 (1
回)
そして、上記の処理では、これまで通り for comment in comments:
の実行時にクエリが発行され、Comment
のテーブルの全レコードが取得されることになります。つまり、ここでクエリが 1
回発行されることになります。
ここで 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
であれば、id
が 7
である 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
回クエリが発行されることになり、
{% for comment in comments %}
さらにループ内部の下記で N
回クエリが発行されることになります。
<p>{{ comment.user.name }}</p>
当たり前と言えば当たり前なのですが、テンプレートを利用したとしても、結局ウェブアプリが所持していないレコードの情報は利用することができません。そのため、ウェブアプリが所持していないレコードを利用しようとする段階で、そのレコードを取得するためのクエリが発行されることになります。
そして、上記のように、タグ for
を利用してレコードを複数表示するようなテンプレートファイルは多くの方が当たり前のように実装されている形式のコードになると思います。例えばユーザー一覧・コメント一覧などの一覧リストを表示するときは上記のような形式に自然となると思います。前述の通り、そのレコードが登録されているテーブルが独立したものであれば問題ないですが、他のテーブルとリレーションが設定されている場合、N + 1
問題が発生している可能性があります。ですが、Django フレームワークが裏側で自動的にクエリを発行してくれているので、N + 1
問題が発生していたとしても気づきにくいことになります。
もし、あなたがウェブアプリを開発したことがあるのであれば、是非 N + 1
問題が発生していないかどうかを確認してみていただければと思います。特にリレーションを利用している場合、自然と for
ループ内部で “for
ループに入る前に取得したレコード以外” のレコードを利用している可能性があり、その場合は N + 1
問題が発生しているはずです。
N + 1
問題については大体理解していただけたでしょうか?
次は、N + 1
問題の解決方法について説明していきます。
N + 1
問題が発生する原因は、結局は for
ループ内部で “for
ループに入る前に取得したレコード以外” のレコードを利用しようとすることになります。先程の例でいえば、for
ループに入る前に Comment
のテーブルのレコードは取得したものの、for
ループ内部で Comment
のテーブルのレコードだけでなく User
のテーブルのレコードを利用しようとしているため N + 1
問題が発生します。そして、このような N + 1
問題は、リレーションの設定によって互いに関連付けられたテーブルのレコードから他方のレコードに for
ループ内部でアクセスするような場合に発生します。
であれば、for
ループに入る前にリレーションの設定によって関連付けられた一方のテーブルのレコードを取得するだけでなく、それらのレコードと関連付けられている他方のテーブルのレコードを取得しておけば、この N + 1
問題は解決可能となります。
ただし、実はそれらのレコードを別々のクエリを発行して取得しておくだけではダメで、Django から提供されるメソッドを利用して取得する必要があります。これらのメソッドが select_related
と prefetch_related
となります。
関連しているレコード同士を結合した状態でレコードを取得してしまおう!という考え方で N + 1
問題を解決するのが前者の select_related
になります。
レコードは結合はしないものの、関連性の持つレコードも一緒に取得しておき、一方のレコードから他方のレコードを参照できるように関連性も管理しておこう!という考え方で N + 1
問題を解決するのが後者の prefetch_related
になります。
つまり、ループに入る前にレコードを取得する際に上記の select_related
や prefetch_related
を利用すれば、ループ内部で必要となる情報を全て含むレコードが事前に取得されることになるため、ループ内部でのクエリの発行を防ぐことができます。したがって、これらを利用した場合に必要となるクエリは N
に比例して増えるのではなく、定数回で済むことになります。つまり N + 1
問題が解消されます。
先程少し説明しましたが、select_related
はリレーションが設定された2つのテーブルを結合するメソッドとなります。
select_related
の基本的な使い方
select_related
は下記のような形式で使用することになります。
モデルクラス.objects.select_related('フィールド名')
フィールド名
には、他のモデルクラスとのリレーションを設定しているフィールドの名前を指定します。
例えば、モデルクラス
に下記の Comment
を指定した場合、フィールド名
には ForeignKey
となる 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)
select_related
の効果
select_related
を利用することで、モデルクラス
で指定したモデルクラスのテーブルの全レコードと フィールド名
で指定したフィールドで関連付けられるレコードが “結合されたレコード” を取得するためのクエリが生成されることになります。あくまでもクエリは生成されるだけで、クエリの発行はレコードが必要になったタイミングで実行されます。また、後からクエリを上書きして取得するレコードの条件等を加えることも可能です。
例えば、Comment
のテーブルと User
のテーブルがそれぞれ下の図のような状態である場合、
下記を実行した場合は、単に Comment
のテーブルの全レコードを取得するクエリが生成されるだけになります。
comments = Comment.objects.all()
具体的には、これによって生成されるクエリが発行されると下の図のようなレコードが取得されることになります。
それに対し、下記のように select_related
を利用した場合、単に Comment
のテーブルの全レコードを取得するのではなく、各レコードと、そのレコードの user_id
フィールドと一致するプライマリーキーが設定された User
のテーブルのレコードとを結合することを要求するクエリが発行されることになります。
comments = Comment.objects.select_related('user')
そして、クエリが発行された際には、それらが結合された状態のレコード取得されることになります。具体的には、下の図のようなレコードが取得されることになります。
select_related
により N + 1
問題が解決される理由
で、ここで N + 1
問題が発生していた例で考えると、下記のように select_related
を利用するようにすれば、上の図のような Comment
のテーブルと User
のテーブルとが結合された状態のレコードが取得されることになり、ループ内部での処理でクエリの発行が必要なくなります。なぜなら、ループに入る前に取得するレコードには User
の name
フィールドが含まれているからです。ループ内部で必要になる情報が全て取得済みのレコードに含まれているため、別途クエリを発行する必要はありません。
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
について説明していきます。
prefetch_related
の基本的な使い方
prefetch_related
はリレーションが設定された2つのテーブルを取得するメソッドとなります。prefetch_related
は下記のような形式で使用することになります。
モデルクラス.objects.prefetch_related('フィールド名')
フィールド名
には、他のモデルクラスとのリレーションを設定しているフィールドの名前を指定します。
例えば、モデルクラス
に下記の Comment
を指定した場合、フィールド名
には ForeignKey
となる 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)
prefetch_related
の効果
prefetch_related
を利用することで、モデルクラス
で指定したモデルクラスのテーブルの全レコードを取得するためのクエリと、フィールド名
で指定したフィールドで関連付けられるレコードを取得するためのクエリが生成されることになります。
例えば、Comment
のテーブルと User
のテーブルがそれぞれ下の図のような状態である場合、
下記のように prefetch_related
を実行すると2つのクエリが生成されることになります。1つ目が Comment
のテーブルの全レコードを取得するクエリで、2つ目が、そのクエリによって取得される Comment
のテーブルの “各レコードと関連づけられている User
のテーブルのレコード” を取得するクエリとなります。
comments = Comment.objects.prefetch_related('user')
したがって、上の図のようなテーブルの場合、Comment.objects.prefetch_related('user')
によって生成されるクエリが発行されると下の図のような2つのテーブルのレコードが取得されることになります。
User
のテーブルの id : 3
のレコードが取得されないのは、そのレコードと関連づけられている Comment
のテーブルのレコードが存在しないから、すなわち、user_id
が 3
であるレコードが存在しないからになります。
また、prefetch_related
を利用した場合、2つのテーブルに対するクエリが別々に生成されることになりますが、これらのクエリの発行によって取得されたレコードはそれぞれ独立した状態ではなく、各レコードの関連性を保ったまま Django フレームワークによって管理されるようになります。後述でも例を示しますが、prefetch_related
を利用せずに2つのテーブルに対して別々にクエリを発行すると、それぞれ独立したレコードとして扱われてしまうため、N + 1
問題を解決することができません。
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 内部で各レコードの関連性が管理されるようになります。
comments = Comment.objects.prefetch_related('user')
for comment in comments:
print(comment.user.name)
したがって、ループ内部で Comment
のレコードから User
のレコードを参照しようとした際には、クエリを発行してレコードを取得するのではなく、既に取得したレコードの中から、その Comment
のレコードと関連付けられている User
のレコードを参照するようになります。したがって、別途クエリを発行することが不要となり、発行されるクエリは prefetch_related
で生成される2つのクエリのみで済むことになります。
そのため、上記のループ処理を行なったとしても、クエリの発行回数は 2
回のみとなり、N + 1
問題を解消することができることになります。
関連性が管理されていない場合のクエリの発行
で、ここで重要になるのが prefetch_related
を利用した場合は各レコードの関連性が Django フレームワークによって管理された状態になるという点になります。
例えば、少し無理矢理な例になりますが、下記のような処理を行なった場合、レコードの件数を求めるために全レコードが必要になるため、len(comments)
で Comment
の全レコードを取得するためのクエリが、len(users)
で User
の全レコードを取得するためのクエリがそれぞれ発行されることになります。したがって、for
ループの処理は Comment
とUser
の全レコードが取得された状態で実行されることになります。
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
単体で実行した場合と同じクエリが生成されることになります。
comments = Comment.objects.prefetch_related('user').all()
さらに、下記のように filter
と併用して取得する Comment
のレコードの条件を指定することも可能です。
comments = Comment.objects.prefetch_related('user').filter(条件)
さて、ここまで 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の関係” にある場合のみとなります。
Django ではモデルクラス間に設定できるリレーションは下記の3種類となります。
- 1対1(
OneToOneField
) - 1対多(
ForeignKey
) - 多対多(
ManyToManyField
)
分かりやすいのが1対1と多対多の関係で、1対1の場合、2つのモデルクラスにおいて、相手側のモデルクラスは両方とも “1の関係” となります。つまり、1対1のリレーションが設定されている場合は何も考えずに select_related
が利用してしまえばよいです。
逆に多対多の場合は、どちらのモデルクラスも必ず相手のモデルクラスに対して “多の関係” にあることになります。したがって、この場合は 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の関係” となります。
そして、select_related
が利用可能なのは、相手側のモデルクラスが “1の関係” である場合ですので、1対多の関係の場合は子モデル側に関してのみ select_related
が利用可能となります。
ここまでの解説では主に User
と Comment
を使って説明してきましたが、これらは下記のように定義されているため、Comment
が子モデルとなります。したがって select_related
が利用可能なのは 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
を利用する必要があります。
例えば、ここまでの説明でも何度も紹介した User
と Comment
において、User
側のテーブルからレコードを取得してから for
ループを回すような場合で、さらにそれによって N + 1
問題が発生するのであれば、N + 1
問題を解決するためには prefetch_related
を利用する必要があります。
ただし、親モデルから prefetch_related
を利用する場合、引数に指定する文字列は下記のいずれかである必要があるので注意してください。
related_name
を指定した場合:related_name
に指定した文字列related_name
を指定しない場合:子モデル名_set
(全て小文字)
要は、リレーションを設定することで各インスタンスに追加されるデータ属性の名称を prefetch_related
の引数に指定すれば良いです。related_name
を指定した場合、親モデル側には related_name
に指定した文字列を名前とするデータ属性が追加されます。それ以外の場合、親モデル側には 子モデル名_set
という名前のデータ属性が追加されます。この辺りは下記ページで解説していますので、詳しくは下記ページをご参照ください。
例えば、ここまでも紹介してきたモデルクラス User
から prefetch_related
を利用する場合は下記のように処理を記述する必要があります。
users = User.objects.prefetch_related('comment_set')
ですが、Comment
の user
フィールドの定義を下記のように変更すれば、
user = models.ForeignKey(User, related_name='comments', 略)
今度は prefetch_related
の引数に指定すべき文字列は related_name
に指定した文字列 'comments'
となります。
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
問題が発生していると言えます。
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
回となります。
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_related
と prefetch_related
の両方で N + 1
問題を解決することは可能ですが、状況によって適切にこの2つは使い分ける必要があります。
掲示板アプリの N + 1
問題を解決する
最後に、いつも通りの流れで、この Django 入門の連載の中で開発してきている掲示板アプリに対し、N + 1
問題の解決を行なっていきたいと思います。
この Django 入門に関しては連載形式となっており、ここでは以前に下記ページの 掲示板アプリでページネーションを利用してみる で作成したウェブアプリに対し、N + 1
問題が発生している箇所の修正を行なっていきたいと思います。
現状、このアプリでは、「ユーザー一覧」「コメント一覧」の2つのページで表示時に N + 1
問題が発生するようになってしまっています。
「ユーザー一覧」では、User
のレコードのみを取得した状態で、その User
のレコードに関連付けられている Comment
のレコードの情報を表示しようとするため N + 1
問題が発生してしまっています。
「コメント一覧」では、Comment
のレコードのみを取得した状態で、その Comment
のレコードに関連付けられている User
のレコードの情報を表示しようとするため N + 1
問題が発生してしまっています。
前回の連載時に、これらのページではページネーションを行うようにしており、各ページで表示されるレコードの件数に上限が設けられるようになっています。なので、全レコードの件数に比例してクエリ発行回数が増加するのではなく、その表示するレコードの上限数に比例してクエリの発行回数が増えるようになっています。
したがって、ページネーションを行う前に比べると、1つのページを表示するために必要となるクエリの発行回数は減っています。ですが、それでもページに表示するレコードの件数に応じてクエリの発行回数は増えることになっていますので、その点の解決を行なっていきます。
スポンサーリンク
views.py
の変更
現状の掲示板アプリで発生している N + 1
問題は、結論としては views.py
を下記のように変更することで解決することができます。前回の連載時の views.py
からの変更点は users_views
と comments_views
の中で太字で示した2行のみとなります。
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)
この掲示板アプリでは、User
と Comment
との間に1対多のリレーションを設定しており、User
が親モデルで Comment
が子モデルになっています(User
は、より正確にいうと CustomUser
となります)。
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 での N + 1
問題と、その解決方法について解説しました!
N + 1
問題とは、レコードの一覧を表示する際のクエリの発行回数が N + 1
回になってしまうことを言います。この N
は表示するレコードの件数となります。このようにクエリの発行回数がレコードの件数に比例して増加してしまうと、レコードの件数が増えるたびにアプリの処理速度が低下することになります。
そのため、クエリの発行回数が N
に比例しないようにすることが重要であり、これは select_related
と prefetch_related
のいずれかを利用することで解決することが可能です。select_related
に関しては利用できるケースが限られているため、状況に応じてこれらを使い分けることが必要となります。
N + 1
問題が発生する原因は「所持していないレコードの情報はデータベースから取得してからでないと表示できない」という当たり前の前提に基づいて考えれば自然と理解できると思います。ぜひ、この前提を頭に入れて、自身のウェブアプリで N + 1
問題が発生していないかどうかを確認し、発生している場合は select_related
や prefetch_related
を利用して問題を解決してみてください。
また、クエリの発行タイミングや発行回数は、下記ページでも紹介している django-debug-toolbar
でも確認できますので、こういったツールを利用して自身のウェブアプリが発行するクエリについて調べてみるのも良いと思います!
おすすめ書籍(PR)
クエリそのものやデータベースに興味が出てきた方には、別途書籍等でデータベース・SQL・クエリなどについて学んでみても良いと思います。特に下記の スッキリわかるSQL入門 第3版 ドリル256問付き! は、初心者の方でもクエリについて理解しやすい内容となっておりオススメです。こういった書籍でクエリについて学べば、Django でどんな処理を行えばどんなクエリが発行されるのかをイメージしながらウェブアプリ開発が行えるようになりますし、特にパフォーマンスの改善等に、この知識を活かすことができると思います。