目的
Djangoでデータベースとやり取りする際に、ORMを使うことが多いかと思います。
ORMは非常に強力ですが、場合によってはレスポンスタイムの悪化を引き起こすことがあります。
今回はその原因となる「N+1」問題についてと、その解決方法を示します。
N+1問題とは
データベース上のデータを取得する際に発生する性能の問題です。
一般的に、1つの主データに対して、それに紐づく複数のデータを取得する場合、1回のクエリで主データと全ての紐づくデータを取得することが望ましいとされます。
しかし、ORMを使用した際に、1つの主データに対して、それに紐づく複数のデータを取得する際、1つの主データに対して1回のクエリを発行し、その紐づくデータを1つずつ取得することがあります。
結果 「主データ(1) + 紐づくデータ数(N)」 の数だけクエリが発行され、性能劣化を引き起こします。
N+1問題とは
主データに紐づくデータ(N個)を取得する際
「主データ(1) + 紐づくデータ数(N)」 の数だけクエリが発行され、性能劣化を引き起すこと
解決方法の結論
「N+1」問題を解消するためには、「プリロード」や「インジェクション」と言われる「select_related」や「prefetch_related」を使用することで、最低限のクエリ数で主データと全ての紐づくデータを取得することができるようになります。
N+1問題の解決方法
「select_related」や「prefetch_related」を使用する
N+1問題を実際に起こしてみる
では、「N+1」問題を実際に再現してみましょう。
その後、クエリの最適化を行っていきます。
モデル定義
今回使用するモデルは以下のようになります。
イメージとしては複数人で記事を投稿できるブログサイトになります。
それぞれの記事には著者(Author)が存在し、コメント(Comment)を残せるものとします。
以下にER図とmodels.pyを示します。
+-------------+ 1 * +-------------+ 1 * +-------------+
| Author |---------| Blog |---------| Comment |
+-------------+ +-------------+ +-------------+
Authorテーブル
+----+---------+
| id | name |
+----+---------+
| 1 | 著者1 |
| 2 | 著者2 |
+----+---------+
Blogテーブル
+----+------------------+-----------+
| id | title | author_id |
+----+------------------+-----------+
| 1 | 著者1ブログ | 1 |
| 2 | 著者2ブログ | 2 |
+----+------------------+-----------+
Commentテーブル
+----+---------------------+---------+
| id | text | blog_id |
+----+---------------------+---------+
| 1 | 著者1コメント | 1 |
| 2 | 著者2コメント | 2 |
+----+---------------------+---------+
from django.db import models
# Create your models here.
class Author(models.Model):
name = models.CharField(max_length=255)
class Meta:
db_table = "author"
def __str__(self):
return self.name
class Blog(models.Model):
title = models.CharField(max_length=255)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
class Meta:
db_table = "blog"
def __str__(self):
return self.title
class Comment(models.Model):
blog = models.ForeignKey(Blog, on_delete=models.CASCADE)
text = models.TextField()
class Meta:
db_table = "comment"
def __str__(self):
return self.text
- 上記のER図は、AuthorがBlogを持ち、BlogがCommentを持つ一対多の関係を表しています。
- Blogモデルは、titleカラムとAuthorモデルへの外部キーであるauthorカラムを持っています。
- Commentモデルは、textカラムとBlogモデルへの外部キーであるbookカラムを持っています。
N+1の再現
では、実際にN+1問題を見ていきましょう。
まず、単純にBlogモデルからAuthorモデルに紐づくデータとして、著者の一覧を取得してみましょう。
Blogモデルからobjects.all()ですべてのデータを取得し、それぞれに紐づくデータ(著者)をAuthorモデルから取得します。
blogs = Blog.objects.all()
for blog in blogs:
author = blog.author
print(author.name)
# (0.001) SELECT `blog`.`id`, `blog`.`title`, `blog`.`author_id` FROM `blog`; args=()
# (0.000) SELECT `author`.`id`, `author`.`name` FROM `author` WHERE `author`.`id` = 1 LIMIT 21; args=(1,)
# 著者1
# (0.000) SELECT `author`.`id`, `author`.`name` FROM `author` WHERE `author`.`id` = 2 LIMIT 21; args=(2,)
# 著者2
ここでは著者は全部で2名になります。しかし、発行されたクエリを見ると3つあることがわかります。
1つ目のクエリは主データとなるBlogモデルに対してSELECT文を投げています。
2つ、3つ目でAuthorモデルに対して著者名をSELECT文を投げています。
N+1問題の再現
主データ(Blog)に紐づくデータ(Author)を取得する際
「主データ(1) + 紐づくデータ数(2)」 の数だけクエリが発行され、性能劣化が引き起こされた。
select_relatedを用いたクエリの最適化
blogs = Blog.objects.select_related("author").all()
for blog in blogs:
author = blog.author
print(author.name)
# (0.001) SELECT `blog`.`id`, `blog`.`title`, `blog`.`author_id`, `author`.`id`, `author`.`name` FROM `blog` INNER JOIN `author` ON (`blog`.`author_id` = `author`.`id`); args=()
# 著者1
# 著者2
select_related("author") を使うことで発行されるクエリは1つになりました。
クエリ内ではBlogテーブルとAuthorテーブルを結合し、必要なデータを1回のクエリで取得できるようにしてくれています。
select_relatedを使うことで
一度に一つのSQLクエリを発行し、親子関係のデータを取得するため、SQLクエリの発行数は少なくなる。
ただし、実行時間時間は長くなる可能性があるため、必要であればprefetch_relatedの利用も考慮したほうが良い。
prefetch_relatedを用いたクエリの最適化
blogs = Blog.objects.prefetch_related("author").all()
for blog in blogs:
author = blog.author
print(author.name)
# (0.001) SELECT `blog`.`id`, `blog`.`title`, `blog`.`author_id` FROM `blog`; args=()
# (0.000) SELECT `author`.`id`, `author`.`name` FROM `author` WHERE `author`.`id` IN (1, 2); args=(1, 2)
# 著者1
# 著者2
prefetch_related("author") を使うことで発行されるクエリは2つになりました。
クエリ内ではWHERE ... IN (...) 句を用いて複数のリレーションを一度に取得するため、実行時間自体が短くなります。
prefetch_relatedを使うことで
select_relatedと異なり、複数のSQLクエリを発行するが、実行時間自体は短くなる。
また、prefetch_relatedは多対一の関係に対して、多側のモデルから1側のモデルにアクセスするためのマネージャーである「_set」を使う際にも有効です。
今回の場合、CommentモデルをもとにBlogモデルにアクセスする場合を考えます。
blogs = Blog.objects.prefetch_related("comment_set").all()
for blog in blogs:
for comment in blog.comment_set.all():
print(f"タイトル: {blog.title}, コメント: {comment.text}")
# (0.001) SELECT `blog`.`id`, `blog`.`title`, `blog`.`author_id` FROM `blog`; args=()
# (0.001) SELECT `commentttt`.`id`, `commentttt`.`blog_id`, `commentttt`.`text` FROM `commentttt` WHERE `commentttt`.`blog_id` IN (1, 2); args=(1, 2)
# タイトル: 著者1ブログ, コメント: 著者1コメント
# タイトル: 著者2ブログ, コメント: 著者2コメント
無事必要最低限のクエリ回数で値を取得できました。
select_relatedとprefetch_relatedどっちを使うべきか
最適化するのに、どちらの解決策を使うべきか迷うことがあるかと思います。
ここではそれぞれの特徴を示します。
-
prefetch_related
- 外部キーまたは一対多の親子関係を持つモデルに使用されます。
- 一度に複数のオブジェクトのリレーションを取得するため、複数のSQLクエリを発行します。
- 複数のリレーションを取得するため、複数のクエリを発行しますが、実行時間はselect_relatedよりも少なくなります。
-
select_related
- 外部キーまたは一対多の親子関係を持つモデルに使用されます。
- 一度に一つのSQLクエリを発行し、親子関係のデータを取得します。
- 一つのクエリでデータを取得するため、実行時間はprefetch_relatedよりも長くなりますが、SQLクエリの発行数は少なくなります。
-
prefetch_related
- 複数のオブジェクトに対して多数のリレーションを取得する場合、
-
select_related
- 一つのオブジェクトに対して親子関係を取得する場合に使用されます。
まとめ
以上、「N+1問題」を実際に動かしながら見てきました。
今回の例ではデータが数個ずつしかなかったため、「N+1問題」がどれほどの性能劣化を引き起こすかまでは見れませんでしたが、
数千、数万個のデータが対象となった場合、何も考えずに実装するとアプリケーションがまともに動作しない。。。なんてことが起こりうるかと思います。
そんな時はぜひクエリの最適化を検討してみましょう。
クエリ解析には以下の記事も参考にしてみてください。
https://qiita.com/eiji-noguchi/items/7188da49be9372a2ee10
https://qiita.com/eiji-noguchi/items/6513ef0f84615576c283