はじめに
Djangoを使ってWebアプリを開発しているときに、レスポンスタイムの悪化を引き起こすというN+1問題について気になってしまったので、自分の備忘録として残しておきます。
環境
% python -V
Python 3.9.6
% python -m django --version
4.0
% sw_vers
ProductName: Mac OS X
ProductVersion: 10.15.7
BuildVersion: 19H1715
検証に使う便利なライブラリをインストールしておきます。
% pip install django-extensions ipython
settings.pyに記述して有効化します。
INSTALLED_APPS = [
...
...
...
'django_extensions',
]
これでshell_plusコマンドが使えるようになり、アプリケーションで定義したモデルをimportした状態で対話型シェルを起動することができます。
検証に使うモデル
求人情報に対する応募モデルを定義しています。
from django.db import models
class Application(models.Model):
class Meta:
db_table = 'applications'
user = models.ForeignKey(settings.AUTH_USER_MODEL,
verbose_name='求職者',
on_delete=models.CASCADE)
work = models.ForeignKey(Work,
verbose_name='仕事',
on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"apply to {self.work} by {self.user}"
検証
shell_plusでは、--print-sql
オプションを付けることで、実行されるSQLを出力することができます。
(出力されるSQLの各カラム名をそのまま書くと冗長なため、*
で置き換えています。)
% python manage.py shell_plus --print-sql
In [1]: apps = Application.objects.all()
In [2]: for app in apps[:100]:
...: print(app.user.name)
SELECT * FROM "applications" LIMIT 100
SELECT * FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21
username1
SELECT * FROM "accounts_user" WHERE "accounts_user"."id" = 2 LIMIT 21
username2
SELECT * FROM "accounts_user" WHERE "accounts_user"."id" = 3 LIMIT 21
username3
SELECT * FROM "accounts_user" WHERE "accounts_user"."id" = 4 LIMIT 21
username4
SELECT * FROM "accounts_user" WHERE "accounts_user"."id" = 5 LIMIT 21
username5
...
...
...
SELECT * FROM "accounts_user" WHERE "accounts_user"."id" = 100 LIMIT 21
username100
この場合、applicationsテーブルから100件のデータを1回のSQL文で取り出しましたが、
そのあとでaccounts_usersテーブルからnameフィールドを取得するため、100回もSQL文が発行されています。
つまり、100件のデータを取得するのに 1+100 = 101回のSQL文を発行しています(このことから、1+N問題とも呼ばれるみたいです)。
これはクエリの呼び出しオーバーヘッドが大きく、深刻なパフォーマンスの低下を引き起こすため、Djangoではselect_related
を使ってテーブル結合を行います。
In [3]: apps = Application.objects.select_related("user").all()[:100]
In [4]: for app in apps:
...: print(app.user.name)
SELECT * FROM "applications"
INNER JOIN "accounts_user"
ON ("applications"."user_id" = "accounts_user"."id")
LIMIT 100
username1
username2
username3
username4
username5
...
...
...
username100
select_related
を使ったことで、発行されたのはたった1つのSQL文です! 発行されたSQLから分かるように、applicationsテーブルとaccounts_usersテーブルを事前にJOINしておくことで、100件のデータを1度に取得できています。
まとめ
N件のデータを取り出すためにN+1件のSQLを発行してしまう、というところからN+1問題と呼ばれているのってなんか面白いですね。
対多の外部キーでクエリの最適化を行うにはprefetch_related
を使うそうです。以下の記事が参考になりました。
小規模なアプリケーションだとあまり気にならないかもしれませんが、発行されるSQLを意識せずにORMを使っていると、N+1問題に気付くのが難しそうです...