久しぶりにdjangoのORMのことについて書いてみる 主に `prefetch_related()` の話

[django][python] 久しぶりにdjangoのORMのことについて書いてみる

はじめに

djangoのORMは正直好きじゃない。そもそも挙動が正確には把握しづらくてなんだか覚えにくいと感じる所がある。また、ドキュメントに書かれたとおりの書き方では不足することもあったりして、結局生のSQLを書かなければいけなかったりする場合もある。とは言え、ORMでできることはORMの機能を使ってやったりすると良い。

queryのチューニングについて

select_related()prefetch_related() について

queryのチューニングについて書いてみようと思う。queryのチューニングと聞いて真っ先に思い浮かぶのは「N+1クエリーの除去」かもしれない。関連オブジェクトの取得が対象となるオブジェクトの個数だけ実行されるようなもののこと。これには、 select_related()prefetch_related() がよく使われる。

それぞれについての使い分けを示す。

関係 利用するメソッド
one to one select_related()
many to one select_related()
one to many prefetch_related()
many to many prefetch_related()

どう使い分けるかは上の対応表を覚えるよりも実装がどうなっているかを理解したほうが早い。

具体的には以下の様になっている。

  • select_related() -- SQL側で関連をくっつける JOIN になる。1つのSQLになる。
  • prefetch_related() -- python側で関連を付ける。内部的には各objectのpk(大抵id)を IN に渡して絞り込み。内部でcacheしたものがあれば使われる。

つまり自分自身から始めて JOIN が書けるものは select_related() を使えば良く、それで無理そうなら prefetch_related() を使えば良い。この自分自身からというのが慣れずにわりと苦労したりした。

簡易的な見分け方

あるモデル同士の関係で混乱するのなら何が one to many で何が many to one なのかを確認してみると良い(釈迦に説法なきもするし取り立てて説明することでもないかもしれない)。

以下の様なモデルがあるとする。 UserとUserKarmaが1:1でUserとCommentが1:Nの関係になっている。

class User(models.Model):
    name = models.CharField(max_length=32, default="foo", blank=False)

class UserKarma(models.Model):
    user = models.OneToOneField(User, related_name="karma")
    point = models.IntegerField(null=False, default=0)

class Comment(models.Model):
    user = models.ForeignKey(User, related_name="comments")
    content = models.CharField(max_length=140, null=False, default="")

Comment のモデルに ForeignKey() の指定があるので、このクラスがqueryの基点になった時には many to one ということにしてほしい。

ここでそれぞれの select_related() をしてみた時の関係は以下のようになっている。

関係 自分自身 join対象 query
one to one' User UserKarma LEFT OUTER JOIN
one' to one UserKarma User INNER JOIN
many to one Comment User INNER JOIN
one to many User Comment x

フラットなレコードが返ってくることを前提にしているっぽいところがあるので one to many は無理。 DB定義的には1:1の関係と1:Nの関係など気にしていないので one to one' のところも同様。User にデータはありつつ UserKarma にはデータが無いと言うことは有り得るので Userをキーと考えて LEFT OUTER JOIN になっている。

many to many に関してはこの表には載せていない。

select_related() では無理そうなものについては prefetch_related() を考える。

寄り道

ちなみに UserComment の数で降順にソートしたい時などは以下の様なコードを書く。初見では絶対に分かんない。

User.objects.annotate(c=Count('comment__user_id')).order_by("-c")
# SELECT "user"."id", "user"."name", COUNT("comment"."user_id") AS "c" FROM "user" LEFT OUTER JOIN "comment" ON ("user"."id" = "comment"."user_id") GROUP BY "user"."id", "user"."name" ORDER BY "c" DESC

それぞれの評価タイミング

select_related() はjoinなのでqueryを評価したタイミングで実行されるのは自明だけれど、 prefetch_related() のタイミングはどうだろう?これもあまり変わらずqueryが評価されたタイミング(評価されたタイミングと言うのは、__getstate__(), __len__(), __bool__(), __iter__(), (repr(),list()も間接的に呼び出す) が呼ばれたタイミング)

以下のようなメソッドが呼ばれる。実際のquery自体の評価は self.iterator() 部分で呼ばれている。

    def _fetch_all(self):
        if self._result_cache is None:
            self._result_cache = list(self.iterator())
        if self._prefetch_related_lookups and not self._prefetch_done:
            self._prefetch_related_objects()

一応SQLの結果は取得していて使いまわされるようになっている。更にqueryにfilterなどの条件を追加した時にどうなるかということも知っておくと便利かもしれない。要は _clone() が呼ばれてこの時SQLの結果は保持しない新しいオブジェクトが作られる。

    def do_something(self):  # filterやexcludeなど
        new_query = self._clone()  # この時点で不要なcacheは渡されない
        self._do_something(new_query)  # 何かする
        return new_query

_clone() のタイミングで主となるSQLの結果のcacheが保持されないことで次のqueryの評価のタイミングで新たに(条件を変えた状態で)SQLが実行され適切な中身が変えるという仕組みになっている。

prefetch_related() で取得された結果は別の場所にcacheされる。具体的には取得した各オブジェクトが _prefetched_object_cache というところにdictを保持して(もしくは単にsetattrで属性として保持(後述))おりそこに格納される(ちなみに prefetch_related() ではない関連のcacheは各オブジェクトの _known_related_objects にcacheされている)。

djangoはidentitymapなどを利用したりはしていないので個々のオブジェクトが状態を共有するということはない。なので新たにqueryを実行した場合には、やっぱり、 prefetch_related() の部分のqueryも実行される。

qs[0]._prefetched_object_cache  # => {'comments': [<Comment_Deferred_name: Comment_Deferred_name object>, <Comment_Deferred_name: Comment_Deferred_name object>]}

# 2. この時点で新しいqueryのSQL評価とそれに対応するprefetch_related部分の評価が行われる。
qs.filter(id=1)[0]._prefetched_object_cace # => {'comments': [<Comment_Deferred_name: Comment_Deferred_name object>, <Comment_Deferred_name: Comment_Deferred_name object>]}

only()defer()

その他にもQuery APIonly()defer() が存在する。これはあまり有名ではないかもしれない。端的に言ってあまり改善に繋がるようなチューニングになることが少ないからかもしれない。

以下のようなもの(ただし内部で色々やるのにpk(大抵はid)はselect句に含められる)。

  • only -- onlyで指定したフィールドのみをSQLのselect句に渡す
  • defer -- deferで指定したフィールド以外をSQLのselect句に渡す

そして隠されたフィールドにアクセスするとどうなるかと言うと、エラーになるではなく、再度完全なオブジェクトのqueryが走って再取得した結果を利用する。

これ自体にはそこまで悪影響があるわけではない。必要になれば使えば良いという程度。

prefetch_related() 何らかのfilterを組み合わせた時の罠

複数の機能を混ぜて使ってみると意図しない動きになるというようなことはしばしばある。それが defer(), only()prefetch_related() にも起きてしまう。

例えば以下の様なコードがあるとする。前者は発行されるqueryの数が2、後者は発行されるqueryの数が4になる。

# defer() 無し
for u in User.objects.prefetch_related("comments"):
    print(u.name, [c.content for c in u.comments.all()])

# defer() あり
for u in User.objects.prefetch_related("comments").defer("id"):
    print(u.name, [c.content for c in u.comments.all().defer("id")])

実際どのようなqueryが発行されているかと言うと以下のようなもの(今回はidでdeferしているため、通常のものと同じ意味のqueryになってしまっている)。

-- defer() 無し
(0.000) SELECT "user"."id", "user"."name" FROM "user"; args=()
(0.000) SELECT "comment"."id", "comment"."user_id", "comment"."content" FROM "comment" WHERE "comment"."user_id" IN (1, 2); args=(1, 2)

-- defer() あり
(0.000) SELECT "user"."id", "user"."name" FROM "user"; args=()
(0.000) SELECT "comment"."id", "comment"."user_id", "comment"."content" FROM "comment" WHERE "comment"."user_id" IN (1, 2); args=(1, 2)
(0.000) SELECT "comment"."id", "comment"."user_id", "comment"."content" FROM "comment" WHERE "comment"."user_id" = 1; args=(1,)
(0.000) SELECT "comment"."id", "comment"."user_id", "comment"."content" FROM "comment" WHERE "comment"."user_id" = 2; args=(2,)

これは至極単純なことで、まず、prefetch_related()のqueryが呼ばれるのは Userの.defer("id") のqueryがloopした時点。ここで各User_Deferオブジェクトの .comments にprefetchされた結果が格納される。ここで、u.comments.all() にさらに defer("id") を呼んでいるので新しいqueryが生成されてしまう。このqueryはprefetchのcacheを持っていないので新たにcommentの取得が走ってしまう。というわけ。

これは、何も defer() に限ったことではなく .filter(id=1) などテキトウな条件を加えた時にも同様に発生する。

寄り道 all() が無駄なqueryの原因になっている場合があるかも?

また、querysetの all() の定義は実質以下の通り新しいqueryを作リ直す処理に他ならないので。安易に qs.all() とやってしまった結果、prefetchされたobjectも含めて再度取り直しということになる。

    def all(self):
        """
        Returns a new QuerySet that is a copy of the current one. This allows a
        QuerySet to proxy for a model manager in some cases.
        """
        return self._clone()

条件をつけたquerysetをprefetchするにはどうするかという話

prefetchされたobjectに対してさらに条件を付けようとした所、新たなqueryが生成される事になりprefetchされたobjectのcacheを使うことができなくなったという問題についてはどうするべきかと言うと Prefetch object を prefetch_related() に渡せば良い。

from django.db.models import Prefetch

for u in User.objects.prefetch_related(Prefetch("comments", queryset=Comment.objects.all().defer("id"))).defer("id"):
    print(u.name, [c.content for c in u.comments.all()])

さらにネストしたprefetchを行いたい時には、渡す引数の順番は重要で foofoo__bar が使いたかった場合には foo,foo__bar の順序で書かないとだめ。

ちなみに途中の所で、「prefetchされたオブジェクトは _prefetched_objects_cache に格納される」 と書いていたが、Prefetch object の生成時に to_attr を指定していた場合には、指定した属性名で単に setattr される。

Prefetch("commments", queryset=Comment.objects.filter(id__gt=0), to_attr="cs")
# <User>.cs としてアクセスできる

参考