DjangoBrothers BLOG ✍️

2020/11/05

このエントリーをはてなブックマークに追加
Django クエリセット SQL データベース django-debug-toolbar パフォーマンス向上

【Django】クエリセットの遅延評価とキャッシュ

Djangoでデータベース周りのパフォーマンスを悪化させないためには、クエリセットの評価タイミングやキャッシュについて理解しておくと良いと思います。

適当にクエリセットを使っているといつの間にか重い処理を埋め込むことになってしまいます。

この記事は、公式ドキュメントのunderstand-querysets付近を参考に書いています。

動作確認

  • Python 3.8.4
  • Django==3.1.2

クエリセットは遅延評価される

クエリセットは遅延評価されます。つまり、実際にその値が必要になるまではデータベースにアクセスしないということです。

以下は、django-debug-toolbarを導入してdebugsqlshellを起動し、クエリセットを使っている例です。debugsqlshellは普通のshellと同じように扱えますが、DBにアクセスしたタイミングでSQL文を表示してくれるので便利です。

クエリセットは遅延評価される

>>> articles = Article.objects.all()
>>> articles = articles.filter(text="text")
>>> articles = articles.filter(title="title")
>>> articles = articles[:5]

# ↑ ここまではDBアクセスされない(クエリセットを構築してるだけ)

>>> len(articles)  # ← ここで初めてクエリセットが評価されてDBアクセスされる
SELECT "app_article"."id",
       "app_article"."title",
       "app_article"."text"
FROM "app_article"
WHERE ("app_article"."text" = 'text'
       AND "app_article"."title" = 'title')
LIMIT 5 [0.54ms]

上記の例でいうと、4行目まではクエリセットを構築してarticles変数に代入しているだけです。この段階ではDBに保存されている値は必要とされないので、4行目までは一度もDBにアクセスしていません。

5行目のlen(articles)で初めて実際の値が必要になります。(このタイミングでDBに登録されているデータの個数を調べるため)。よって、ここで初めてDBにアクセスします。(ちなみにですが、クエリセットの個数だけを知りたいのであればcount()メソッドを使えば良いです。)

このように、実際に値が必要になるまではクエリセットは評価されません(DBにアクセスされません)。

クエリセットはいつ評価されるか

先ほどはlen()を使ったタイミングで評価されましたが、具体的にクエリセットがどのタイミングで評価されるかというと、ドキュメントに記載されている通り、以下のような場面で評価されます。

  • イテレーション

例えば、for文を使うとイテレートされるので、このタイミングでDBアクセスされます。

イテレーションによるクエリセット評価

for article in Article.objects.all():
    print(article.title)
  • スライス
  • pickle
  • repr()を使った時
  • len()を使った時
  • list()を使った時
  • bool()を使った時

つまりは、「何かしらの処理をするために、実際にDBから値を取り出さなければいけない場面」でクエリセットが評価されて、DBにアクセスされます。

クエリセットのキャッシュ

ドキュメントにある通り、構築したクエリセットはDBアクセスを効率化するためにキャッシュ情報を保存します。

クエリセットが最初に評価された時(最初にDBアクセスした時)、その実行結果をクエリセットのキャッシュに保存しておくのです。

次に同じクエリセットを評価しようとしたときは、キャッシュされた情報が使われるのでDBアクセスはされません。

例えば、以下のコードは同じ内容のDBアクセスを2回することになり、DBへの負荷も2倍になるので避けた方が良いでしょう。

同じ内容のクエリセットを2回作っている

>>> print([a.title for a in Article.objects.all()])
>>> print([a.text for a in Article.objects.all()])

この問題を解消するためには、クエリセットを変数に代入しておいて、キャッシュを再利用すると良いです。

評価結果を再利用する

>>> articles = Article.objects.all()
>>> [a.title for a in articles]  # クエリセットが評価される(DBアクセスあり)
>>> [a.text for a in articles]  # キャッシュされた評価結果が再利用される(DBアクセスなし)

クエリセットがキャッシュされない場面

クエリセットがキャッシュされない場合もあります。

クエリセットの一部のみを評価する場合、つまりクエリセットに対してスライスやインデックス番号を使った場合はキャッシュされません。

スライスやインデックス番号を使った場合、キャッシュの存在チェック自体はされますが、キャッシュが存在しない場合、後続のクエリ結果はキャッシュとして保存されません。キャッシュが存在する場合は、後続のクエリ結果をキャッシュします。

スライス・インデックスを使った場合はキャッシュされない

>>> articles = Article.objects.all()  # DBアクセスなし(クエリセットを構築するだけ)
>>> len(articles[1:6])  # キャッシュが空のためDBアクセスあり(クエリセットの部分評価 → 過去にキャッシュは設定されていないので、ここでもキャッシュは保存されない)
>>> len(articles[1:6])  # キャッシュが空のためDBアクセスあり(クエリセットの部分評価 → 過去にキャッシュは設定されていないので、ここでもキャッシュは保存されない)
>>> len(articles)  # キャッシュが空のためDBアクセスあり(クエリセットの全部評価 → キャッシュが設定される)
>>> len(articles)  # DBアクセスなし(クエリセットの全部評価 → キャッシュが設定されているので、DBアクセスなし)
>>> len(articles[1:6])  # DBアクセスなし(クエリセットの部分評価 → キャッシュが設定されているので、DBアクセスなし)

言い換えると、過去にキャッシュが一度も保存されていない状態でスライスやインデックス番号を使うと、その結果はキャッシュされないということです。