Djangoのデータベースのパフォーマンを最適化する
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