0
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?

Django ORMで育ったエンジニアが生SQLを学び直す 【パフォーマンスチューニング編】

0
Posted at

はじめに

本記事は、Django ORMで育った私が生SQLを学び直すシリーズの4本目、最終回のパフォーマンスチューニング編です。シリーズは次の4本立てでした。

  • モデル定義編: models.pyの定義がどんなテーブルになるのか
  • モデル変更編: makemigrationsとmigrateの裏でどんなALTER TABLEが走るのか
  • クエリ編: filterやupdateの裏でどんなクエリが走っているのか
  • パフォーマンスチューニング編(本記事): N+1問題とselect_related・prefetch_related、集計の裏側

前回のクエリ編までで、テーブルを作り、変え、1本1本のクエリを読み書きできるようになりました。最後は速さの話をします。ORMの便利さの代償として名高いN+1問題は、SQLで見ると何が起きているのか。その対策のselect_relatedとprefetch_relatedは、それぞれどんなSQLを発行してN+1を潰すのか。そして集計のannotateやaggregate、F・Q・Case・Subqueryといった式オブジェクトが、SQLのどの構文に化けるのか。「遅いORMコード」の裏で走っているSQLを覗いて、原因のほうから理解するのがこの回のねらいです。

引き続き、モデルは前回までと同じサンプルモデル(User・Profile・Post・Tag・Comment)を使います。定義の全文とER図はモデル定義編を参照してください。手元では、ユーザー3人(alice・bob・carol)、記事6本、それにタグとコメントを紐付けた状態で確認しています。

今回使うのは、クエリ編の最後で予告したconnection.queriesです。実行されたSQLが1本ずつ入っているので、その本数を数えれば「このORMコードが何本のSQLを投げたか」がそのまま分かります1。N+1問題は、まさにこの本数の問題でした。以降、各実験の頭に「クエリ本数」を添えて進めます。

独自に調べた内容のため、誤りを含むかもしれません。気づいた点があれば指摘してもらえると大変助かります。

N+1問題をSQLで観察する

記事一覧で、各記事の著者名を出したいとします。何気なく書くと、こうなります。

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

一見ふつうのループですが、connection.queriesを数えると、記事6本に対してSQLが7本飛んでいました。

-- クエリ本数: 7本
SELECT ... FROM "blog_post"
SELECT ... FROM "blog_user" WHERE "blog_user"."id" = 1 LIMIT 21
SELECT ... FROM "blog_user" WHERE "blog_user"."id" = 1 LIMIT 21
SELECT ... FROM "blog_user" WHERE "blog_user"."id" = 1 LIMIT 21
SELECT ... FROM "blog_user" WHERE "blog_user"."id" = 2 LIMIT 21
SELECT ... FROM "blog_user" WHERE "blog_user"."id" = 2 LIMIT 21
SELECT ... FROM "blog_user" WHERE "blog_user"."id" = 3 LIMIT 21

1本目が記事の一覧を取るSELECTで、残り6本がp.authorへアクセスするたびに飛ぶ著者のSELECTです。Post.objects.all()の時点ではblog_userには触れていないので、著者が要るたび、記事1本ごとにWHERE id = ?のSELECTが1本ずつ発行されています。末尾のLIMIT 21は、クエリ編で見たgetのあれです。関連を1件辿るのも内部的にはgetなので、同じ形が出ています。

注目したいのは、id=1のaliceを引くSELECTが3回、そっくりそのまま繰り返されていることです。aliceの記事は3本あるので、記事ごとに毎回aliceを引き直しています。「さっき取った著者」を使い回してはくれません。これが、一覧の件数に比例してクエリが増えていく仕組みです。

一覧の1本を「1」、ループで増える分を「N」と数えて、この現象はN+1問題と呼ばれます。今回はN=6で7本でしたが、記事が100本並ぶ一覧なら101本、1000本なら1001本のSQLが飛びます。アプリとDBの往復を図にすると、問題の形がはっきりします。

1本のSQLがどれだけ速くても、この往復そのものに固定のコスト(ネットワークの遅延やクエリの解析)がかかります。N+1問題が効いてくるのは、個々のクエリが遅いからではなく、軽いクエリを何度も往復してしまうからです。実際、手元の合計時間は数ミリ秒で、6本のSELECT自体は一瞬でした。それでもDBが別サーバーにいて1往復に数ミリ秒かかる本番環境では、この本数がそのまま遅延に化けます。

同じことは、件数を数えるときにも起きます。各記事のコメント数を出そうとしてp.comments.count()をループで呼ぶと、やはり7本でした。

-- クエリ本数: 7本
SELECT ... FROM "blog_post"
SELECT COUNT(*) AS "__count" FROM "blog_comment" WHERE "blog_comment"."post_id" = 1
SELECT COUNT(*) AS "__count" FROM "blog_comment" WHERE "blog_comment"."post_id" = 2
SELECT COUNT(*) AS "__count" FROM "blog_comment" WHERE "blog_comment"."post_id" = 3
...

こちらは記事ごとにCOUNTが1本ずつ飛んでいます。関連オブジェクトそのものを取るのも、関連の件数を数えるのも、ループの中で関連に触れれば同じN+1になる、というわけです。ではどう1本にまとめるのか。ここからが対策です。DjangoにはJOINで潰すselect_relatedと、別クエリで潰すprefetch_relatedの2つが用意されています。順に見ます。

select_related: JOINで1本にまとめる

さきほどのループに、select_related("author")を1つ足します。

>>> for p in Post.objects.select_related("author"):
...     print(p.author.name)
-- クエリ本数: 1本
SELECT "blog_post"."id", ..., "blog_post"."updated_at",
       "blog_user"."id", "blog_user"."name", "blog_user"."email"
FROM "blog_post" INNER JOIN "blog_user" ON ("blog_post"."author_id" = "blog_user"."id")

7本が1本になりました。出てきたのはJOINです。JOINそのものはクエリ編で「リレーションをまたぐfilter」として見ましたが、あのときと役割が違います。filterのJOINは、blog_user側の列で絞り込むための道でした。今回のJOINで肝心なのはSELECT句のほうです。よく見ると、blog_postの全列に続けてblog_userのid・name・emailまで並んでいます。記事とその著者を1つの行にくっつけて、まとめて1回で取ってきているのです。

JOINが2つのテーブルをどう1つの行にするか、結果の形を表で見ておきます。ON ("blog_post"."author_id" = "blog_user"."id")は「記事のauthor_idと一致するユーザーの行を横に繋げる」という指定で、結果はこうなります。

blog_post.id blog_post.title blog_post.author_id blog_user.id blog_user.name
1 DjangoのORM入門 1 1 alice
2 SQLを学び直す 1 1 alice
4 Python 3.14の新機能 2 2 bob

記事1行につき、その著者の列がぴったり横に付いた1行ができます。Djangoはこの1本のSELECT結果から、Postインスタンスと一緒にauthorのインスタンスまで組み立ててキャッシュしておきます。だからループでp.authorに触れても、もうDBには行きません。N+1のNが丸ごと消えた、という理屈です。

INNER JOINとLEFT OUTER JOIN

上のSQLはINNER JOINでした。INNER JOINは「両側で相手が見つかった行だけ」を残します。記事のauthorは必須(NOT NULL)なので、著者のいない記事は存在せず、INNERで取りこぼしはありません。

一方、相手がいないかもしれないリレーションだと、JOINの種類が変わります。ユーザーとプロフィールの関係で試します。プロフィールはcarolだけ持っていません。

>>> for u in User.objects.select_related("profile"):
...     ...
-- クエリ本数: 1本
SELECT "blog_user"."id", "blog_user"."name", "blog_user"."email",
       "blog_profile"."id", "blog_profile"."user_id", "blog_profile"."bio", "blog_profile"."links"
FROM "blog_user" LEFT OUTER JOIN "blog_profile" ON ("blog_user"."id" = "blog_profile"."user_id")

LEFT OUTER JOINになりました。これもクエリ編の__isnullの節で顔を出した部品で、左側(blog_user)の行は全部残し、相手のいないcarolの行はプロフィール側の列をNULLで埋めて繋ぎます。もしここでINNER JOINを使ってしまうと、プロフィールを持たないcarolがJOINの時点で消えてしまい、一覧から1人抜け落ちます。Djangoは、繋ぐ相手がNULLになりうるか(=リレーションのnull許容)を見て、INNERとLEFT OUTERを選び分けていました。

select_relatedが使えるのはFKとOneToOneまで

ここまでの例で、select_relatedの相手はどちらもForeignKeyかOneToOneでした。これは制約です。select_relatedは「JOINで横に繋いで1行にまとめる」戦略なので、繋いだ結果、記事の行数が増えてしまう関連には使えません。

思い出したいのが、クエリ編で見た「JOINすると行が増える」現象です。1人のユーザーが公開記事を2本持っていると、ユーザーとその記事をJOINした結果はユーザーの行が2行に増えました。ManyToManyや逆参照(1対多の「多」側)を横に繋ぐと、まさにこれが起きて、元のテーブルの行が水増しされてしまいます。だからそちら向きには、行を増やさない別の戦略が要ります。それがprefetch_relatedです。

prefetch_related: IN句で2本目を投げる

各記事のコメント一覧を出す、というN+1が起きやすい形を、prefetch_relatedで書きます。

>>> for p in Post.objects.prefetch_related("comments"):
...     list(p.comments.all())
-- クエリ本数: 2本
SELECT ... FROM "blog_post"
SELECT ... FROM "blog_comment" WHERE "blog_comment"."post_id" IN (1, 2, 3, 4, 5, 6)

select_relatedと違い、SQLが1本ではなく2本でした。ただしN+1の7本ではなく、きっちり2本で止まっています。1本目でまず記事を全部取り、そこに登場した記事のid(1〜6)を集めて、2本目でWHERE post_id IN (...)とまとめて問い合わせる。取れたコメントを、DjangoがPython側で記事ごとに振り分けてキャッシュします。JOINで1つの表にするのではなく、関連を別クエリで一気に取ってきてアプリ側で結合する、という戦略です。

なぜJOINにしないかは、前の節のとおりです。記事とコメントをJOINすると、コメント3件を持つ記事は3行に増え、本文(content)のような大きな列が3回ぶん重複して転送されます。記事が多くコメントも多いと、この水増しが効いてきます。IN句で分けて取れば、記事とコメントをそれぞれ1回ずつしか運びません。

ManyToManyも同じ形です。タグで試すと、中間テーブルを経由する2本目になりました。

-- クエリ本数: 2本
SELECT ... FROM "blog_post"
SELECT ("blog_post_tags"."post_id") AS "_prefetch_related_val_post_id", "blog_tag"."id", "blog_tag"."name"
FROM "blog_tag" INNER JOIN "blog_post_tags" ON ("blog_tag"."id" = "blog_post_tags"."tag_id")
WHERE "blog_post_tags"."post_id" IN (1, 2, 3, 4, 5, 6)

2本目の中でJOINが使われていますが、これはタグ本体と中間テーブルを繋ぐためのもので、記事側とはIN句で分けたままです。モデル定義編で見た中間テーブルblog_post_tagsが、ここでも経由地として働いています。先頭の_prefetch_related_val_post_idは、取れたタグをどの記事に振り分けるかをDjangoが判断するために、post_idを一緒に持ち帰っている列です。

2つの対策を、SQLの形で対比しておきます。

select_related prefetch_related
SQLの本数 1本(JOIN) 2本(本体 + IN)
結合する場所 DB(JOIN) Python側
使える関連 ForeignKey / OneToOne ManyToMany / 逆参照(1対多)も可
向いていない場面 行が増える関連 1対1で十分なとき(1本で済むのに2本になる)

選び方は「相手が1件に定まるならselect_related(JOINで1本)、相手が複数になりうるならprefetch_related(INで2本)」と押さえておけば、まず外しません。どちらも、クエリの本数をNから定数に畳むための道具でした。

集計はGROUP BYで理解する

N+1の次は集計です。件数や合計、平均といった集計は、SQLではCOUNTやSUMといった集計関数と、GROUP BYという構文で表現されます。Django側のaggregateとannotateが、それぞれどちらに対応するのかを見ていきます。

aggregateとCount・Sum・Avg・Min・Max

まずaggregateです。全記事の件数と平均閲覧数を、一度に求めてみます。

>>> Post.objects.aggregate(Count("id"), Avg("view_count"))
{'id__count': 6, 'view_count__avg': 128.33...}
-- クエリ本数: 1本
SELECT COUNT("blog_post"."id") AS "id__count", AVG("blog_post"."view_count") AS "view_count__avg"
FROM "blog_post"

CountAvgが、そのままSQLの集計関数COUNT・AVGになりました。集計関数は、たくさんの行を受け取って1つの値に畳み込む関数です。COUNTは行数、SUMは合計、AVGは平均、MINとMAXは最小・最大。このSELECTにはWHEREやGROUP BYが付かないので、テーブル全体という1つのかたまりに対して集計関数が効き、結果は1行(dict1つ)だけ返ります。絞り込んでから集計したいなら、いつものfilterを前に挟むだけです。

>>> Post.objects.filter(status="published").aggregate(Sum("view_count"), Min("view_count"), Max("view_count"))
SELECT SUM("blog_post"."view_count") AS "view_count__sum", MIN(...) AS "view_count__min", MAX(...) AS "view_count__max"
FROM "blog_post" WHERE "blog_post"."status" = 'published'

filterが組み立てるWHERE句は、クエリ編で見たものと同じ部品が、そのまま集計のSELECTにも刺さっています。「公開記事だけを対象に合計・最小・最大」が1本のSQLで求まりました。

ちなみに、件数だけならおなじみのcount()があります。

>>> Post.objects.count()
SELECT COUNT(*) AS "__count" FROM "blog_post"

aggregate(Count("id"))とほぼ同じで、COUNT(*)になります。件数を知りたいだけならこちらが素直です。

annotateとGROUP BY

aggregateがテーブル全体を1つの値に畳むのに対して、annotateは「行ごとに集計列を足す」操作です。各ユーザーの投稿数を求めてみます。

>>> User.objects.annotate(post_count=Count("posts"))
-- クエリ本数: 1本
SELECT "blog_user"."id", "blog_user"."name", "blog_user"."email", COUNT("blog_post"."id") AS "post_count"
FROM "blog_user" LEFT OUTER JOIN "blog_post" ON ("blog_user"."id" = "blog_post"."author_id")
GROUP BY "blog_user"."id"

新しく出てきたのがGROUP BYです。GROUP BYは、指定した列が同じ値の行を1つのグループにまとめ、集計関数をグループごとに効かせる構文です。ここではGROUP BY "blog_user"."id"なので、ユーザーIDごとに記事をまとめ、そのグループの中でCOUNTを数えています。動きを表で追ってみます。まずJOINでユーザーと記事を繋ぐと、次のようになります。

blog_user.id blog_post.id
1 (alice) 1
1 (alice) 2
1 (alice) 3
2 (bob) 4
2 (bob) 5
3 (carol) 6

これをuser.idでまとめると、alice=3行・bob=2行・carol=1行のグループになり、各グループの行数をCOUNTした値がpost_countになります。集計関数は、テーブル全体ではなくこのグループ単位に効く、というのがaggregateとの違いでした。JOINがLEFT OUTERなのは、記事が0本のユーザーもCOUNT=0として残すためです(INNERだと0本のユーザーが消えます)。

もうひとつ、クエリ編のvaluesで張った伏線をここで回収します。valuesとannotateを組み合わせると、GROUP BYの対象が変わります。ステータスごとの記事数を数えてみます。

>>> Post.objects.values("status").annotate(n=Count("id"))
SELECT "blog_post"."status" AS "status", COUNT("blog_post"."id") AS "n"
FROM "blog_post" GROUP BY 1

今度はGROUP BY 1、つまりSELECTの1番目の列(status)でまとめています。annotateの前に置いたvalues("status")が、そのままGROUP BYの単位を決めているのです。ここがハマりどころで、valuesに何を並べるかでグループの粒度が変わります。さきほどのユーザーの例がGROUP BY user.idだったのに対し、valuesを挟むとその列でグルーピングされる。「行ごとの集計はannotate、その集計の単位はGROUP BY(valuesで指定できる)」と対応づけておくと、狙った粒度で数えられます。

exists()とcount()の使い分け

集計に関連して、実務でよく迷うexists()count()の違いもSQLで見ておきます。「公開記事が1本でもあるか」を知りたいとき、両方書けてしまいます。

>>> Post.objects.filter(status="published").exists()
SELECT 1 AS "a" FROM "blog_post" WHERE "blog_post"."status" = 'published' LIMIT 1
>>> Post.objects.filter(status="published").count()
SELECT COUNT(*) AS "__count" FROM "blog_post" WHERE "blog_post"."status" = 'published'

exists()はSELECT 1 ... LIMIT 1、つまり「条件に合う行が1つ見つかった時点で打ち切り」です。存在確認なら1件見つければ十分なので、DBは最初の1件を見つけしだい仕事をやめられます。対してcount()はCOUNT(*)で、条件に合う行を全部数え切ります。「あるかどうかだけ知りたいのに、全部数える」のが無駄だ、というのがSQLの仕事量として見えます。

同じ理由で、if queryset:という書き方にも注意が要ります。QuerySetを真偽判定すると、Djangoは全件をSELECTしてリストに読み込んでから、空かどうかを見ます。

-- if qs: で走るSQL(全カラム・全件)
SELECT "blog_post"."id", ..., "blog_post"."updated_at" FROM "blog_post" WHERE "blog_post"."status" = 'published'

存在を知りたいだけなのに、全記事の全カラムを運んでいます。if queryset.exists():と書けば、上のLIMIT 1で済みます。「あるか?」にはexists()、と決めておくのが安全でした。

EXPLAINで実行計画を読む

ここまでは「何本SQLが飛ぶか」の話でした。最後に、1本のSQLがDBの中でどう処理されるかを覗きます。使うのはEXPLAINです。SQLの頭にEXPLAINを付けると、DBがそのクエリをどう実行するつもりか(実行計画)を教えてくれます。DjangoではQuerySetのexplain()で見られます。

モデル変更編で、published_atにインデックスを張りました(db_index=True)。あれが本当に使われているのかを、ここで確かめます。ただし、手元の6行のような小さなテーブルだと、DBは「全部読んだほうが速い」と判断してインデックスを使いません。そこで、published_atを散らした記事を1万行ほど投入した状態(実験後にロールバック)で、少数だけがヒットする条件で絞ってみます。

>>> print(Post.objects.filter(published_at__gte=recent).explain())
Index Scan using blog_post_published_at_9524a659 on blog_post  (cost=0.29..9.37 rows=31 width=74)
  Index Cond: (published_at >= '2026-07-08 02:20:55+00'::timestamp with time zone)

Index Scanと出ました。使われているインデックスの名前blog_post_published_at_9524a659は、モデル変更編でmigrateしたときにDjangoが作ったものです。あのときCREATE INDEXで作られた索引が、今こうしてクエリの高速化に効いている、というのが伏線の回収です。行の頭にあるcost=0.29..9.37は、DBが見積もったコストの目安で、小さいほど軽い見込みです。

比べるために、インデックスを張っていないview_countで、同じくらいの件数を絞ってみます。

>>> print(Post.objects.filter(view_count__lt=30).explain())
Seq Scan on blog_post  (cost=0.00..268.07 rows=32 width=74)
  Filter: (view_count < 30)

今度はSeq Scanです。Seq Scanは「テーブルを先頭から全行読んで、条件に合う行だけ拾う」という総当たりの読み方です。costの右の数字を見比べると、Index Scanが9.37だったのに対しSeq Scanは268.07。インデックスのある列で絞ると索引をたどって目的の行だけ読み、ない列で絞ると全行を読んで振るい落とす、という差がコストにそのまま出ています。この2つを見分けられると、「遅いクエリにインデックスが効いていない」状況を実行計画から読み取れます。

さらにexplain(analyze=True)を付けると、見積もりだけでなく実際に実行して実測値まで見せてくれます。

Index Scan using blog_post_published_at_9524a659 on blog_post  (cost=0.29..9.37 rows=31 width=74) (actual time=0.003..0.007 rows=32 loops=1)
  Index Cond: (published_at >= '...')
Planning Time: 0.022 ms
Execution Time: 0.011 ms

actual timeExecution Timeが、実際にかかった時間です。本番で「なぜかこのクエリだけ遅い」というとき、まずEXPLAIN ANALYZEにかけて、Seq Scanになっていないか・想定した件数で済んでいるかを見るのが調査の入口になります。ORMのexplain()から同じことができる、と知っておくと便利です。

式を組み立てるオブジェクトたち

ここからは、より込み入ったSQLを組み立てるための道具です。filterやexcludeだけでは書けなかった条件や更新が、F・Q・Case・Subqueryといったオブジェクトで表現できます。それぞれSQLのどの構文になるのかを確かめます。

F: カラム同士の比較・更新

ふつう、閲覧数を1増やすには「取ってきて、+1して、保存」と書きたくなります。しかしそれだと、取得と保存のあいだに別の処理が同じ行を触ると、片方の更新が消える競合(レースコンディション)を招きます。Fを使うと、この計算をDBの中で完結させられます。

>>> Post.objects.filter(id=1).update(view_count=F("view_count") + 1)
-- クエリ本数: 1本
UPDATE "blog_post" SET "view_count" = ("blog_post"."view_count" + 1) WHERE "blog_post"."id" = 1

F("view_count")が、SQLの中の"blog_post"."view_count"という列参照になりました。値をPython側に持ってこず、SET view_count = view_count + 1とDBに計算させています。現在値を読んでから書くのではなく、DBが1つのUPDATEの中で読み書きするので、さきほどの競合が起きません。在庫や残高、カウンタのような「今の値を元に増減する」更新では、この書き方が安全です。

Fは更新だけでなく、列同士の比較にも使えます。「公開後に更新された記事」を、updated_atとpublished_atの比較で探します。

>>> Post.objects.filter(updated_at__gt=F("published_at"))
SELECT ... FROM "blog_post" WHERE "blog_post"."updated_at" > ("blog_post"."published_at")

WHEREの右辺が、定数ではなく別の列になっています。ふつうのfilterはview_count__gt=100のように列と定数を比べますが、Fを渡すと2つの列同士を比べるWHEREが書けます。

Q: OR条件と複雑なWHERE

クエリ編で見たとおり、filterに条件を並べるとANDで繋がれます。ORを書きたいときに使うのがQです。

>>> Post.objects.filter(Q(status="published") | Q(view_count__gte=200))
SELECT ... FROM "blog_post" WHERE ("blog_post"."status" = 'published' OR "blog_post"."view_count" >= 200)

|がSQLのORになりました。Qを使わないfilterではANDしか書けなかったのが、Qを|で繋ぐと初めてWHEREにORが現れます。同じように~Q(...)はNOTです。

>>> Post.objects.filter(~Q(status="draft"))
SELECT ... FROM "blog_post" WHERE NOT ("blog_post"."status" = 'draft')

これはクエリ編のexcludeと同じ形のWHEREで、実際exclude(status="draft")filter(~Q(status="draft"))は同じSQLになります。Qとふつうのキーワード引数は混ぜられて、そのときQ側がOR、キーワード側がANDで結ばれます。

>>> Post.objects.filter(Q(view_count__lt=50) | Q(view_count__gte=300), status="published")
SELECT ... FROM "blog_post"
WHERE (("blog_post"."view_count" < 50 OR "blog_post"."view_count" >= 300) AND "blog_post"."status" = 'published')

(A OR B) AND Cという括弧付きのWHEREが組み上がりました。「閲覧数が極端(50未満か300以上)で、かつ公開済み」という条件です。Qは、ANDだけでは表せない入り組んだWHEREを、括弧の構造ごと組み立てるための道具でした。

Case・When: SQLのCASE式

行の値によって表示を出し分けたい、というとき使うのがCaseWhenです。閲覧数で記事に人気度ラベルを付けてみます。

>>> Post.objects.annotate(
...     popularity=Case(
...         When(view_count__gte=200, then=Value("人気")),
...         When(view_count__gte=100, then=Value("普通")),
...         default=Value("低調"),
...     )
... )
SELECT "blog_post"."title", "blog_post"."view_count",
       CASE WHEN "blog_post"."view_count" >= 200 THEN '人気'
            WHEN "blog_post"."view_count" >= 100 THEN '普通'
            ELSE '低調' END AS "popularity"
FROM "blog_post"

Case/Whenが、SQLのCASE式になりました。CASE WHEN 条件 THEN 値 ... ELSE 値 ENDは、行ごとに条件を上から順に見て、最初に当てはまった値を返すSQLの条件分岐です。クエリ編のbulk_updateでちらっと出てきたCASEを、今度は自分で組み立てた形です。上から評価されるので、>= 200の枝を>= 100より前に書く順序が効いてきます。

この条件分岐は、集計と組み合わせると強力です。「各ユーザーの公開記事だけを数える」条件付きの集計を書いてみます。

>>> User.objects.annotate(published_count=Count("posts", filter=Q(posts__status="published")))
SELECT "blog_user"."id", "blog_user"."name", "blog_user"."email",
       COUNT("blog_post"."id") FILTER (WHERE "blog_post"."status" = 'published') AS "published_count"
FROM "blog_user" LEFT OUTER JOIN "blog_post" ON ("blog_user"."id" = "blog_post"."author_id")
GROUP BY "blog_user"."id"

Countfilter=を渡すと、COUNT(...) FILTER (WHERE ...)という構文になりました。これは「グループの中で、条件に合う行だけを数える」というSQL標準の書き方です。annotateのGROUP BYで全記事をユーザーごとにまとめつつ、そのうち公開済みだけをCOUNTしています。ステータス別に何本ずつ、といった集計をユーザー単位で一度に出せます。

SubqueryとOuterRef: 相関サブクエリ

最後は、一覧に「各ユーザーの最新記事タイトル」を添えたい、という題材です。ユーザーごとに記事を1本選ぶので、これまでの集計関数では表せません。使うのがSubqueryOuterRefです。

>>> latest = Post.objects.filter(author=OuterRef("pk")).order_by("-published_at")
>>> User.objects.annotate(latest_title=Subquery(latest.values("title")[:1]))
SELECT "blog_user"."name",
       (SELECT U0."title" FROM "blog_post" U0
        WHERE U0."author_id" = ("blog_user"."id")
        ORDER BY U0."published_at" DESC LIMIT 1) AS "latest_title"
FROM "blog_user"

SELECT句の中に、括弧でくくられた別のSELECTが入りました。これがサブクエリ(クエリの中のクエリ)です。注目はWHERE U0."author_id" = ("blog_user"."id")の部分で、内側のSELECTが外側のblog_userの列を参照しています。OuterRef("pk")が、この「外側の行のid」への参照になっていました。

外側の1行ごとに、その行の値(ユーザーid)を使って内側のSELECTが動く、こういうサブクエリを相関サブクエリと呼びます。ユーザーaliceの行を作るときは内側がauthor_id = 1で最新記事を1本引き、bobの行ではauthor_id = 2で引く。行ごとに内側のクエリが連動する、という形です。.values("title")[:1]が内側のSELECT title ... LIMIT 1に対応しています。

annotateのGROUP BYでも似たことはできますが、相関サブクエリが向くのは「グループごとに1件を選んで、その行の別の列(ここではtitle)がほしい」ような、単純な集計関数では表しにくい場面です。SELECTの中にSELECTを埋め込める、と引き出しに入れておくと、複雑な一覧を1本のSQLで組めます。

まとめ

最終回は、速さの視点でORMとSQLを並べてきました。

N+1問題への対処は、突き詰めると2択でした。JOINで1本にまとめる(select_related)か、IN句でまとめて取って別クエリにする(prefetch_related)か。どちらを選ぶかは、辿る相手が1件に定まるか複数になりうるかで決まります。N本に膨らんでいたクエリを、定数本に畳むのが目的でした。

集計とその周辺は、SQLの構文にきれいに対応していました。今回の対応を一枚にまとめます。

ORM側 SQL側
select_related JOIN(1本にまとめる)
prefetch_related 本体SELECT + WHERE ... IN(2本)
aggregate(Count・Sum・Avg・Min・Max) 集計関数(GROUP BYなし・1行返す)
annotate 集計列 + GROUP BY
values().annotate() GROUP BYの単位をvaluesで指定
exists() / count() SELECT 1 ... LIMIT 1 / COUNT(*)
explain() EXPLAIN(Index Scan / Seq Scan)
F 列参照(列同士の計算・比較)
Q(| / ~) WHEREのOR / NOT
Case・When CASE WHEN ... END
Subquery・OuterRef 相関サブクエリ

最後に、シリーズ全体を振り返ります。4本を通してやってきたのは、ORMのコードを1枚めくってSQLを見るという同じ作業でした。モデル定義のCREATE TABLE、マイグレーションのALTER TABLE、filterのSELECT、N+1の裏で走るSQL。どれも、めくればSQLが待っていました。そこで分かったのは、ORMが素朴な翻訳機ではなく、安全側・効率側の工夫を随所に埋めていることです。JOINで行が増える。OFFSETは読み飛ばす行も読む。N+1は往復の数で効いてくる。こうしたSQL側の性質は、ORMを使っていても消えませんでした。

冒頭のモデル定義編で、「SELECTやWHERE、JOINを知ってはいるが正確に説明できない」と書きました。4本を経て、その一つひとつをORMのコードと対応づけて読めるようになったと思います。ここで身についたSQLの見方は、Django以外でも効くはずです。PrismaやTypeORMのドキュメントを読むときも、結局は「このメソッドはどんなSELECTやJOINになるのか」を追うことになります。ORMの向こうにSQLが見えるようになったことがいちばんの収穫でした。

参考文献

  1. https://docs.djangoproject.com/en/6.0/faq/models/#how-can-i-see-the-raw-sql-queries-django-is-running

0
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
0
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?