4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

DjangoにおけるN+1問題について

Last updated at Posted at 2022-02-17

はじめに

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に記述して有効化します。

settings.py
INSTALLED_APPS = [
    ...
    ...
    ...
    'django_extensions',
]

これでshell_plusコマンドが使えるようになり、アプリケーションで定義したモデルをimportした状態で対話型シェルを起動することができます。

検証に使うモデル

求人情報に対する応募モデルを定義しています。

models.py
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問題に気付くのが難しそうです...

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?