💿

Djangoのデータベースのパフォーマンを最適化する

2022/01/31に公開

Djangoにおけるデータベースのパフォーマンスを高める方法について、自分がやった方法などについて自分なりにいくつかまとめてみました。
主にDjangoのORMを利用してのパフォーマンスを最適化する方法について解説しています。

※ (他の方法も今後記述予定)

以下のようなDjangoモデルを前提として話を進めていきます。

class Author(models.Model):
    name = models.CharField(max_length=255)

    def __str__(self):
        return self.name

class Post(models.Model):

    title = models.CharField('title', max_length=255)
    text = models.TextField('Text')
    author = models.ForeignKey(
        Author,
        verbose_name='author',
        on_delete=models.PROTECT
    )

存在チェック

レコードが実際に存在するかは、count() > 0よりも.exists()の方が処理時間が短いです。

ModelTestPost.objects.filter(title=first_title).exists()

ModelTestPost.objects.filter(title=first_title).count() > 0

N+1問題について

Djangoのデータベースのボトルネックとなる箇所でよくあるのがN+1問題が挙げられます。

N+1問題とは、DjangoだけでなくフレームワークなどにあるOR Mapperを使用しているときに発生しがちな問題です。
N+1問題とは何か。実例を元に見ていきましょう。

for p in Post.objects.all():
    print(p)

シンプルに Post.objects.all() という記述で、Postの全レコードを取得してみます。
すると、以下のようなSQLが発行されて、実行されているSQLが一行だけなのがよくわかります。
こちらは問題ありません。

SELECT "blog_post"."id", "blog_post"."title", "blog_post"."text", "blog_post"."author_id" FROM "blog_post" ORDER BY "blog_post"."id" ASC

しかし、これを以下のようにして、取得したPostオブジェク毎に Post.Author.name を参照するようなコードを書いてみます。

for p in Post.objects.all():
    print(p.author.name)

実行されたSQLを覗いてみると、なんとPostオブジェクトをループを回して取り出す毎にAuthorを取得するSQLを実行していることがわかります。

SELECT "blog_post"."id", "blog_post"."title", "blog_post"."text", "blog_post"."author_id" FROM "blog_post" ORDER BY "blog_post"."id" ASC

SELECT "blog_post"."id", "blog_post"."name" FROM "blog_post" WHERE "blog_post"."id" = 1 LIMIT 21
SELECT "blog_post"."id", "blog_post"."name" FROM "blog_post" WHERE "blog_post"."id" = 2 LIMIT 21
SELECT "blog_post"."id", "blog_post"."name" FROM "blog_post" WHERE "blog_post"."id" = 3 LIMIT 21
SELECT "blog_post"."id", "blog_post"."name" FROM "blog_post" WHERE "blog_post"."id" = 4 LIMIT 21
....
  • Post の全レコードを取得するSQLを一回
  • Author を取得するSQLを Post のレコード数分

もしPost のレコード数がNだとすると、合計でN+1回分のSQLを実行していることがわかります。
つまりはデータベースアクセス(SELECT) が合計 N+1 回も実行されるというものです。

このように各レコード毎にSQLを実行すれば、パフォーマンスの低下の要因となる場合があります。レコード数が10000も超えれば、結果が返ってくるのに4〜5秒なんていうことにもなるため、無駄なSQLの実行は避けなければいけません。

prefetch_relatedを使用する

DjangoのORMで他レコードを参照する場合は、prefetch_relatedとselect_relatedなどを使用すれば無駄なSQLの発行を軽減することができます。

for p in Post.objects.prefetch_related("author").all():
    print(p.author.name)

上記コードで発行されたSQLは以下になります。先ほどと比べてN回も無駄なSQLは発行されることはなくなりました。

SELECT "blog_post"."id", "blog_post"."title", "blog_post"."text", "blog_post"."author_id" FROM "blog_post" ORDER BY "blog_post"."id" ASC

SELECT "blog_author"."id", "blog_author"."name" FROM "blog_author" WHERE "blog_author"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, ....)

prefetch_relatedとパフォーマンスを比較してみる

prefetch_relatedでどれくらいパフォーマンスが上がるのかを調べるためにも、実際に5000レコードぐらいのサンプルデータを入れて、Post.objects.all()Post.objects.prefetch_related("author").all()を比較してみます。

※データベースはsqlite3を使用

自分の環境での実行は結果は以下のようになりました。桁数が1桁も違うことがわかります。

処理時間 (秒)
N + 1 4.324150730040856
prefetch_related 0.5334562789648771

ORMを使用しないで生SQLを使う

ORMを使用せず生SQLでレコードを取得してみるとどうなるのか。次にDjangoのORMを使用しないで、from django.db import connectionを使用して生SQLを実行してみます。

先程のprefetch_relatedを使用した方とN+1が出る方の2つを、ORMを使用しないで生SQLで実行して、処理時間を比較してみます。

N+1が発生した時と同じSQLを発行するコードです。

# N+1と同じ処理
from django.db import connection

cur = connection.cursor()

db_table_name = Post.objects.model._meta.db_table
sql = "select * from {0}".format(db_table_name)
for c in cur.execute(sql).fetchall():
    author_id = c[4]
    db_table_name = Author.objects.model._meta.db_table

    sql = "select * from {0} where id=?".format(db_table_name)
    cur.execute(sql, (author_id,)).fetchone()

以下はprefetch_relatedを使った時と同じSQLが発行される処理です。

# prefetch_relatedと同じ処理
from django.db import connection

cur = connection.cursor()

db_table_name = Post.objects.model._meta.db_table
sql = "select * from {0}".format(db_table_name)

post_table = cur.execute(sql).fetchall()
db_table_test_author_name = Author.objects.model._meta.db_table
sql = "select * from {0}".format(db_table_test_author_name)
sql += " where id in (" + ",".join([str(c[4]) for c in post_table]) + ")"

cur.execute(sql,).fetchall()

※データベースはsqlite3を使用

先程同様にレコード数は5000くらいです。
自分の環境での実行は結果は以下のようになりました。

処理時間 (秒)
N + 1 0.18250489805359393
prefetch_related 0.04175675299484283

なんと、ORMを使用したprefetch_relatedのコードよりも、N+1が発生する生SQLのコードの方が処理時間が短いことがわかります。
直接SQLを実行をすれば、ORMを利用した場合に比べて1桁も高速であることがわかります。

ここからわかることは、ORMの処理時間のほとんどはRDBのI/Oなどの呼び出し部分ではなく、ORM内のSQL文の組み立てやモデルオブジェクトの構築に費やされています。

たとえば、Django ORMであれば、次のようにして同じデータを取得できます。

db_table_test_author_name = Author.objects.model._meta.db_table
name = '{0}__name'.format("author")
for values in Post.objects.values(name).all():
    values['author__name']

この方法を用いれば、RDBからフェッチした値からモデルオブジェクトを構築するのではなく、必要なカラムの値だけを取得して結果を辞書で受け取ることが可能です。

自分の環境では処理時間は0.014秒と生SQLに匹敵するほどのパフォーマンスとなりました。

チューニング一つとっても、ORMのメソッドであるprefetch_relatedを使用するだけではなく、パフォーマンスが求められるような所は、ORMを使用せずに生SQLを書く、必要な値だけを直接取得する場合は上記のようなコードを書くなどの、状況によって使い分けが必要になります。

キャッシュ機能付きのモデルを作成

トラフィックが高いサービスを作るときはDBアクセスを減らすことが重要であり、何度もselectなどを叩くようなアクセスがある場合は、モデルにキャッシュ機能を加えるのが良いです。

以下のようなサードパーティのライブラリもあります。

Djangoにはdjango.core.cacheという機能が存在しており、それを利用して以下のように自前でキャッシュ機能付きモデルを実装している例もあります。

参考

Discussion