SQL パフォーマンス最適化 — 複合INDEX・JOIN前集約の応用

応用複合インデックスKeyset PaginationJOIN前集約再帰CTETop-N per GroupPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

複合インデックスの列順 — 「等価が先、範囲は後」と左端プレフィックスの原則

複合INDEX左端プレフィックス等価→範囲Index Range Scan
前提知識

複数列の条件で絞るクエリには複合インデックスが効きますが、性能は列の並び順で決まります。B-tree は左の列から順にソートされた構造なので、等価条件(=)の列を先頭、範囲条件(>= / <)の列を後ろに置くと、検索範囲が連続した1本のレンジに収まります。逆順だと、範囲列で広く読んでから等価条件をフィルタする非効率な走査になります。

-- クエリ: status は等価、created_at は範囲
WHERE status = 'completed'
  AND created_at >= '2024-04-01' AND created_at < '2024-07-01'

-- ✗ INDEX (created_at, status): 範囲が先 → status で絞れず広く走査
-- ✓ INDEX (status, created_at): 等価が先 → 連続した1レンジのみ走査
CREATE INDEX idx_orders_status_created ON orders (status, created_at);
左端プレフィックスの原則:複合インデックス (a, b, c) が使えるのは、先頭から連続して条件に現れる場合(a / a,b / a,b,c)。bc だけの条件にはほぼ効きません。さらに範囲条件に当たった列より後ろの列は「絞り込み」には使えず、フィルタ扱いになります。だから「等価 → 範囲」の順が鉄則です。
問題

orders テーブルから「completed 状態かつ 2024年第2四半期(4〜6月)に作成された注文」を取得してください。あわせて、このクエリに最適な複合インデックスの CREATE INDEX 文も書いてください。出力列は order_id, created_at, amount、created_at 昇順で。

使用テーブル
- orders(8行)
order_idstatuscreated_atamount
1completed2024-01-101200
2cancelled2024-04-02800
3completed2024-04-153000
4completed2024-05-202500
5pending2024-05-25900
6completed2024-06-304000
7completed2024-08-011100
8cancelled2024-06-10600
期待出力
order_idcreated_atamount
32024-04-153000
42024-05-202500
62024-06-304000
模範解答コード
CREATE INDEX idx_orders_status_created  -- 最適な複合インデックス: 等価列(status)を先頭、範囲列(created_at)を後ろに
ON orders (status, created_at);

SELECT order_id, created_at, amount
FROM   orders
WHERE  status = 'completed'          -- 等価条件: インデックス先頭列でピンポイントに絞る
  AND  created_at >= '2024-04-01'    -- 範囲条件: 2列目で連続レンジを走査(半開区間)
  AND  created_at <  '2024-07-01'
ORDER BY created_at;                 -- インデックス順と一致するためソート不要

/*
  実行順序:
  1. B-tree を (status, created_at) で二分探索  → 複合キーで到達
  2. リーフを右に辿り連続スキャン                       → 範囲の周辺のみ走査
  3. テーブル参照で列を取得                          → amount などを取得
  4. ORDER BY created_at                → Sort ノード不要

  プラン比較(概念):
  ✗ INDEX (created_at, status)
       → 4〜6月の全行(id=2,3,4,5,6,8)を読み、status をフィルタで判定
  ✓ INDEX (status, created_at)
       → completed の中の 4〜6月だけを1本のレンジで読む
*/
解説(テーブル変化・ポイント)
CREATE INDEX idx ON orders (status, created_at); SELECT order_id, created_at, amount FROM orders WHERE status = 'completed' AND created_at >= '2024-04-01' AND created_at < '2024-07-01' ORDER BY created_at;
LEGEND
データ取得・読込対象
1. FROM orders — 8行(複合インデックス設計の対象)
FROM ordersstatus(等価)と created_at(範囲)の2条件で絞ります。同じ3行を返すクエリでも、インデックスの列順によって走査量がまったく変わるのがこの問題のテーマです。
1 / 4
order_idstatuscreated_atamount
1completed2024-01-101200
2cancelled2024-04-02800
3completed2024-04-153000
4completed2024-05-202500
5pending2024-05-25900
6completed2024-06-304000
7completed2024-08-011100
8cancelled2024-06-10600
8行(うち該当は3行)
学習ポイント
「等価が先、範囲は後」が複合インデックスの第一原則:B-tree は左の列から順にソートされるため、等価条件で先にブロックを確定し、その中を範囲条件で連続走査するのが最小コストです。範囲列を先頭にすると、範囲内の全行を読んでから等価条件をフィルタする形になり、選択率次第で読み捨てが膨らみます。
ORDER BY もインデックスで「消せる」:INDEX (status, created_at) は status を固定すれば created_at 順で並んでいます。WHERE status=... ORDER BY created_at はこの並びをそのまま返せるため、EXPLAIN から Sort ノードが消えます。「絞り込み列+並べ替え列」をセットで設計するのが応用編の視点です。
左端プレフィックスを意識して本数を減らす:(status, created_at) があれば、WHERE status = ... 単独のクエリにも同じインデックスが効きます(先頭列だけの利用)。逆に WHERE created_at = ... 単独にはほぼ効きません。既存インデックスの左端で賄えるかを先に確認すると、無駄なインデックス追加を防げます。
アンチパターン
列ごとに単独インデックスを乱立させる:INDEX(status)INDEX(created_at) を別々に張っても、複合条件では片方しか使われないか、BitmapAnd で合成されて複合インデックス1本より遅くなりがちです。さらにインデックスは書き込み(INSERT/UPDATE)のたびに全部更新されるため、本数の乱立は更新性能を直撃します。
カーディナリティの極端に低い列だけのインデックス:INDEX(status) のように値が数種類しかない列単独のインデックスは、1値あたりのヒット率が高すぎてプランナに無視されることが多いです。低カーディナリティ列は単独ではなく、複合インデックスの先頭で「等価の足場」として活かすのが正しい使い方です。
実務コラム:インデックス設計は「クエリから逆算」する
良いインデックスは、テーブルを眺めて決めるものではなく頻出クエリの WHERE / JOIN / ORDER BY から逆算して決めるものです。手順は (1) 遅いクエリを pg_stat_statements 等で特定、(2) 条件を「等価/範囲/並べ替え」に分類、(3) 等価 → 範囲 → (カバーしたい列は INCLUDE)の順で列を並べる、(4) EXPLAIN ANALYZE で前後比較。既存インデックスの左端プレフィックスで賄えるなら追加しない、使われないインデックスは pg_stat_user_indexes で見つけて削る——「増やす設計」と同じくらい「増やさない設計」が重要です。
QUESTION 2

Keyset Pagination — 深いページでも遅くならない「カーソル方式」のページング

KeysetOFFSET回避タプル比較ページング
前提知識

基礎編で触れたとおり、LIMIT 20 OFFSET 1000000 は100万行を読み捨ててから20行返す処理で、ページが深くなるほど線形に遅くなります。応用編の解は Keyset Pagination(カーソル方式)。「前ページの最後の行のキー」を覚えておき、そこより後ろの行を WHERE で直接指定します。どのページでも Index Scan 一発で、コストは一定です。

-- ✗ OFFSET: ページが深いほど読み捨てが増える O(offset)
SELECT ... ORDER BY created_at DESC, order_id DESC
LIMIT 3 OFFSET 3;

-- ✓ Keyset: 前ページ末尾のキーから直接続きを読む O(limit)
WHERE (created_at, order_id) < ('2024-06-05', 8)
ORDER BY created_at DESC, order_id DESC LIMIT 3;
タプル比較とタイブレーク:created_at が同値の行があると「どこから続きか」が曖昧になるため、一意なキー(order_id)をソートと比較の両方に必ず加えます(created_at, order_id) < (値1, 値2) は「created_at が小さい、または created_at が同じで order_id が小さい」を1式で表す行値(タプル)比較で、複合インデックス (created_at DESC, order_id DESC) がそのまま効きます。
問題

新着順フィード(created_at DESC, order_id DESC・1ページ3件)の2ページ目を取得してください。1ページ目の最後の行は (created_at, order_id) = ('2024-06-05', 8) です。OFFSET を使わず、タプル比較による Keyset Pagination で書いてください。出力列は order_id, created_at, amount

使用テーブル
- orders(10行・INDEX (created_at DESC, order_id DESC))
order_idcreated_atamount
102024-06-302200
92024-06-211800
82024-06-053500
72024-06-051500
62024-05-18900
52024-05-024100
42024-04-11700
32024-03-222600
22024-02-141300
12024-01-05500
期待出力
order_idcreated_atamount
72024-06-051500
62024-05-18900
52024-05-024100
模範解答コード
SELECT order_id, created_at, amount
FROM   orders
WHERE  (created_at, order_id) < ('2024-06-05', 8)  -- 【カーソル】前ページ末尾キーよりソート順で後ろ(タプル比較)
ORDER BY created_at DESC, order_id DESC            -- 一意キーまで含めて順序を完全に固定
LIMIT  3;                                          -- 1ページ分だけ取得して早期終了

/*
  実行順序:
  1. INDEX を直後の位置まで二分探索   → OFFSET の読み捨てなし
  2. インデックス順に3行だけ読んで打ち切り  → LIMIT 3
  3. SELECT 列を返却          → 列を返す

  タプル比較の展開(等価な条件):
  (created_at, order_id) < ('2024-06-05', 8)
  ⇔  created_at <  '2024-06-05'
   OR (created_at = '2024-06-05' AND order_id < 8)

  プラン比較(概念):
  ✗ LIMIT 3 OFFSET 3 … 先頭から6行読んで3行捨てる(深いページほど悪化)
  ✓ Keyset          … カーソル位置から3行読むだけ(常に O(limit))
*/
解説(テーブル変化・ポイント)
SELECT order_id, created_at, amount FROM orders WHERE (created_at, order_id) < ('2024-06-05', 8) ORDER BY created_at DESC, order_id DESC LIMIT 3;
LEGEND
データ取得・読込対象
1. FROM orders — フィード全体(ソート済みインデックス)
FROM ordersINDEX (created_at DESC, order_id DESC) 上では、行はこの表の順で物理的に並んでいます。1ページ目は先頭3行(id=10, 9, 8)。id=8 と 7 の created_at 同値に注目してください。
1 / 5
order_idcreated_atamount
102024-06-302200
92024-06-211800
82024-06-053500
72024-06-051500
62024-05-18900
52024-05-024100
42024-04-11700
32024-03-222600
22024-02-141300
12024-01-05500
10行(インデックス順=表示順)
学習ポイント
OFFSET は「読んでから捨てる」、Keyset は「最初から読まない」:OFFSET N はどんなにインデックスが効いていても先頭から N 行を必ず走査します。Keyset は B-tree の二分探索でカーソル位置に直接ジャンプするため、1ページ目も100万ページ目も同じコスト。「ページ深度に対して O(1)」がこの方式の本質的な価値です。
一意キーをソートと比較の両方に必ず含める:created_at だけでカーソルを切ると、同時刻の行が重複表示・欠落します(id=7 と 8 のケース)。ORDER BY created_at DESC, order_id DESC(created_at, order_id) < (...) をペアで書き、順序を完全に一意化するのが鉄則です。
タプル比較は「展開形」よりインデックスに優しい:(a, b) < (x, y)a < x OR (a = x AND b < y) と等価ですが、タプル形のほうがプランナが複合インデックスの1レンジとして扱いやすいです(PostgreSQL は行値比較を直接サポート)。OR 展開形だと計画が劣化する DB もあるため、対応していればタプル形を選びます。
アンチパターン
「総ページ数」表示のために毎回 COUNT(*) を流す:ページングUIのために SELECT COUNT(*) FROM huge_table を毎リクエスト実行すると、本体クエリより集計のほうが重い本末転倒が起きます。無限スクロールなら件数は不要、必要でも概算(pg_class.reltuples)やキャッシュで代替するのが実務解です。
カーソルに一意でない列だけを使う:WHERE created_at < '前ページ末尾の時刻' だけのカーソルは、同時刻の行をまとめて飛ばす(または2重に出す)バグの温床です。バッチ取り込みで created_at が秒単位で揃うテーブルでは特に頻発します。主キーまで含めたタプルでカーソルを構成してください。
実務コラム:OFFSET を完全には捨てられない場面と折衷案
Keyset は高速ですが、「任意のページ番号へジャンプ」ができない制約があります(前ページのカーソルが必要なため)。そのため実務では、無限スクロール・API のページング・バッチの分割読込は Keyset管理画面の「○ページ目へ」UI は浅いページに限り OFFSET 許容、という使い分けが定番です。さらに大規模では「OFFSET は最大100ページまで、それ以遠は検索条件で絞らせる」というプロダクト側の仕様判断も立派な性能対策になります。SQL の書き換えだけでなく、UI 仕様ごと設計し直すのが応用編の視座です。
QUESTION 3

ウィンドウ関数で Top-N per Group — ROW_NUMBER で「カテゴリ別 上位2件」を1パスで取る

ROW_NUMBERPARTITION BYCTETop-N per Group
前提知識

基礎編の Top-N は「全体の上位K件」でしたが、実務で本当に頻出なのは「グループごとの上位K件」(Top-N per Group)です。相関サブクエリで書くとグループ数×走査の繰り返しになりがちですが、ウィンドウ関数 ROW_NUMBER() ならテーブル1パス+パーティション内ソートで済みます。ウィンドウ関数は WHERE で直接使えないため、CTE(またはサブクエリ)で順位を付けてから外側で絞るのが定石です。

-- 定石: CTEで順位付け → 外側で rn <= K に絞る
WITH ranked AS (
  SELECT *,
         ROW_NUMBER() OVER (
           PARTITION BY category
           ORDER BY amount DESC) AS rn
  FROM orders)
SELECT ... WHERE rn <= 2;
ROW_NUMBER / RANK / DENSE_RANK の違い:同点(タイ)の扱いが異なります。ROW_NUMBER必ず連番(同点でも 1,2,3...)、RANK は同点同順位で次が飛ぶ(1,1,3)、DENSE_RANK は同点同順位で詰める(1,1,2)。「ちょうどK件欲しい」なら ROW_NUMBER + 一意なタイブレーク列、「同点は全部欲しい」なら RANK を選びます。
問題

orders テーブルから「カテゴリごとの売上金額 上位2件」を取得してください。同額の場合は order_id の小さい方を優先します。出力列は category, order_id, amount, rn、category 昇順 → rn 昇順で。

使用テーブル
- orders(9行・3カテゴリ)
order_idcategoryamount
1Books1200
2Books800
3Books2000
4Toys5000
5Toys3000
6Toys4500
7Games2500
8Games2500
9Games900
期待出力
categoryorder_idamountrn
Books320001
Books112002
Games725001
Games825002
Toys450001
Toys645002
模範解答コード
WITH ranked AS (
  SELECT category, order_id, amount,
         ROW_NUMBER() OVER (
           PARTITION BY category           -- カテゴリごとに独立した採番空間を作る
           ORDER BY amount DESC, order_id  -- 金額降順 + 一意キーで順位を決定的に
         ) AS rn
  FROM orders
)
SELECT category, order_id, amount, rn
FROM   ranked
WHERE  rn <= 2                             -- ウィンドウ関数はWHEREで直接使えないため外側で絞る
ORDER BY category, rn;

/*
  実行順序:
  1. FROM orders                          → 9行を1パスで読込
  2. PARTITION BY category                → カテゴリごとに区画化
  3. 各区画内 ORDER BY amount DESC, order_id  → 区画内ソート
  4. ROW_NUMBER() で連番付与                   → rn を付与
  5. 外側 WHERE rn で上位抽出                    → 各区画の上位2行だけ通過
  6. ORDER BY category, rn                → 最終並べ替え

  タイブレークの効果(Games):
    id=7 (2500) と id=8 (2500) は同額
    → ORDER BY amount DESC, order_id により id=7 が rn=1、id=8 が rn=2 に確定
*/
解説(テーブル変化・ポイント)
WITH ranked AS ( SELECT category, order_id, amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC, order_id) AS rn FROM orders ) SELECT category, order_id, amount, rn FROM ranked WHERE rn <= 2 ORDER BY category, rn;
LEGEND
データ取得・読込対象
1. FROM orders — 9行を1パスで読込
FROM orders3カテゴリ×各3行。「各カテゴリの上位2件」を相関サブクエリで書くと外側9行×内側走査の繰り返しになりますが、ウィンドウ関数なら全行を1回読むだけで済みます。
1 / 5
order_idcategoryamount
1Books1200
2Books800
3Books2000
4Toys5000
5Toys3000
6Toys4500
7Games2500
8Games2500
9Games900
9行 × 3列
学習ポイント
GROUP BY は「畳む」、ウィンドウ関数は「畳まない」:GROUP BY は行をグループ単位に集約しますが、ウィンドウ関数は行数を変えずに「グループ視点の計算結果」を各行に付与します。「明細行も残しつつ順位・累計・前行比較が欲しい」場面はウィンドウ関数の独壇場です。
ウィンドウ関数は WHERE で使えない — CTE で一段挟む:SQL の論理評価順では WHERE → SELECT(ウィンドウ評価) の順なので、WHERE rn <= 2 を同じ階層に書くとエラーになります。CTE またはサブクエリで rn を確定させてから外側で絞る2段構成が、Top-N per Group の不動の定石です。
ORDER BY に一意キーを足して「決定的」にする:同額タイがあると、タイブレークなしの ROW_NUMBER は実行のたびに順位が入れ替わる可能性があります(結果の非決定性)。テストが不安定になる・差分検知が誤爆する等の実害につながるため、ORDER BY amount DESC, order_id のように一意キーで必ず順序を固定します。
アンチパターン
相関サブクエリで Top-N per Group を書く:WHERE amount >= (SELECT ... ORDER BY amount DESC LIMIT 1 OFFSET 1) のような書き方は、外側の行数ぶんサブクエリが繰り返し評価されます。グループ数・行数が増えると一気に破綻するため、ウィンドウ関数(または PostgreSQL なら LATERAL + LIMIT)に置き換えるのが正解です。
「ちょうどK件」の場面で RANK を使う:RANK() <= 2 は同点が多いと2件のつもりが3件以上返ることがあります。画面の枠が2件しかないUIにそのまま流すとレイアウト崩れやページング不整合の原因に。件数保証なら ROW_NUMBER、同点全員なら RANK と要件で使い分けてください。
実務コラム:ウィンドウ関数の性能を支えるインデックス
ウィンドウ関数自体は1パスでも、PARTITION BY + ORDER BY のためのソートコストは残ります。ここで 複合インデックスが再登場します。INDEX (category, amount DESC, order_id) があれば、データはすでに「区画 → 区画内順位」の順で並んでおり、ソートをインデックススキャンで肩代わりできます。さらに区画数が少なく各区画が巨大な場合、PostgreSQL ではTop-N を区画単位で打ち切れる LATERAL (SELECT ... LIMIT 2) 方式が勝つこともあります。「ウィンドウ一択」ではなく EXPLAIN で両者を比較するのが応用編の作法です。
QUESTION 4

JOIN前集約 — 「集約してから結合」で行膨張と二重カウントを防ぐ

JOIN前集約LEFT JOINCOALESCE行膨張回避
前提知識

1対多のテーブルを JOIN すると、親の行は子の件数分膨張(fan-out)します。膨張した状態で集約すると計算量が増えるだけでなく、複数の子テーブルを JOIN した瞬間に二重カウントのバグを生みます。応用編の定石は「先に子テーブルを CTE で集約し、小さくなった結果を親に JOIN する」こと。さらに「子が0件の親」も落とさないために LEFT JOIN + COALESCE を組み合わせます。

-- ✗ 結合してから集約: 行が膨張してから畳む(重い・事故りやすい)
SELECT c.name, COUNT(o.order_id), SUM(o.amount)
FROM customers c LEFT JOIN orders o ON ... GROUP BY c.name;

-- ✓ 集約してから結合: 子を先に畳み、小さい結果をJOIN
WITH agg AS (SELECT customer_id, COUNT(*) cnt, SUM(amount) total
            FROM orders GROUP BY customer_id)
SELECT ... FROM customers c LEFT JOIN agg ON ...;
なぜ「先に畳む」と速いのか:JOIN のコストはおおよそ両辺の行数の積(または和)に効きます。100万件の orders を先に customer 単位へ畳めば、JOIN に参加する行数は顧客数まで激減。さらに集約キーが JOIN キーと同じなら、集約も結合もインデックス1本で完結します。「JOIN は小さくしてから」が応用編の合言葉です。
問題

customersorders から、全顧客の注文件数と合計金額を取得してください。orders を先に CTE で集約してから JOIN し、注文が1件もない顧客(Bob)も 0 / 0 で出力してください。出力列は customer_id, name, order_count, total_amount、customer_id 昇順で。

使用テーブル
- customers(3行)
customer_idname
101Alice
102Bob
103Carol
- orders(6行)
order_idcustomer_idamount
11011200
2101800
31035000
41033000
5103500
61012500
期待出力
customer_idnameorder_counttotal_amount
101Alice34500
102Bob00
103Carol38500
模範解答コード
WITH order_agg AS (
  SELECT   customer_id,
           COUNT(*)    AS order_count,   -- JOIN前に子テーブル側で件数を確定
           SUM(amount) AS total_amount   -- 合計も同じ1パスで計算
  FROM     orders
  GROUP BY customer_id                  -- 6行 → 2行に畳んでから結合へ
)
SELECT c.customer_id,
       c.name,
       COALESCE(a.order_count, 0)  AS order_count,  -- 注文0件の顧客は NULL → 0 に補正
       COALESCE(a.total_amount, 0) AS total_amount
FROM   customers c
LEFT JOIN order_agg a                  -- LEFT JOIN で全顧客を保持(Bobを落とさない)
       ON a.customer_id = c.customer_id
ORDER BY c.customer_id;

/*
  実行順序:
  1. CTE order_agg                       → customer_id で集約
  2. FROM customers LEFT JOIN order_agg  → 集約結果を結合(未一致はNULL)
  3. SELECT で COALESCE                   → NULL を 0 へ変換
  4. ORDER BY customer_id                → 整列

  プラン比較(概念):
  ✗ JOINしてから GROUP BY → 結合中間結果が膨張(orders全行が参加)
  ✓ 集約してから JOIN     → 結合に参加するのは「顧客数」行だけ
*/
解説(テーブル変化・ポイント)
WITH order_agg AS ( SELECT customer_id, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY customer_id ) SELECT c.customer_id, c.name, COALESCE(a.order_count, 0), COALESCE(a.total_amount, 0) FROM customers c LEFT JOIN order_agg a ON a.customer_id = c.customer_id ORDER BY c.customer_id;
LEGEND
グループ化キー・集計対象
グループ分類
1. FROM orders — 集約対象の子テーブル(6行)
WITH order_agg AS (... FROM orders ...)先に畳むのは「多」側の orders。customer_id ごとに色分けすると、101 が3行、103 が3行、102(Bob)は0行であることが見えます。
1 / 5
order_idcustomer_idamount
11011200
2101800
61012500
31035000
41033000
5103500
6行(101×3, 103×3, 102は0行)
学習ポイント
「集約してから結合」は速度と正しさの両取り:JOIN 前に子テーブルを畳むと、(1) 結合に参加する行数が激減して速くなり、(2) 複数の子テーブルを JOIN しても集計値が互いに膨張しない(二重カウント防止)という2つの効果が同時に得られます。1対多が2本以上ある集計レポートでは必須の構成です。
「0件の親」を扱う3点セット — LEFT JOIN + 事前集約 + COALESCE:JOIN後に COUNT(*) で数えると、Bob のような0件顧客が「1件」に化けます(NULL行も1行は1行だから)。COUNT(o.order_id) で回避もできますが、事前集約 + COALESCE 構成ならそもそも数え間違いが構造的に起きません
集約キー=結合キーに揃えるとインデックスが二度効く:CTE の GROUP BY customer_id と外側の ON a.customer_id = c.customer_id が同じキーなら、orders(customer_id) のインデックス1本で集約のソート回避と結合の探索の両方に寄与します。キー設計を揃えるだけでプラン全体が引き締まります。
アンチパターン
複数の1対多を結合してから SUM する:customers ⨝ orders ⨝ payments を作ってから SUM(o.amount) すると、orders の各行が payments の件数分だけ重複して合計される古典的バグが起きます。数字が「なぜか倍になる」障害の大半はこれ。子テーブルはそれぞれ単独で集約してから JOIN してください。
SELECT 句の相関サブクエリで件数を取る:SELECT c.name, (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) ... は読みやすい反面、顧客行ごとにサブクエリが繰り返し実行されます。顧客数が少なければ許容できますが、一覧系クエリでは事前集約 + LEFT JOIN へ書き換えるのが定石です。
実務コラム:それでも重いなら「集計の事前計算」へ
JOIN前集約を尽くしてもダッシュボードが重い——そのときの次の一手がマテリアライズドビュー(集計結果の実体化)です。CREATE MATERIALIZED VIEW customer_sales AS WITH order_agg AS (...) SELECT ... として今回のクエリ結果を物理保存し、REFRESH MATERIALIZED VIEW CONCURRENTLY を夜間バッチや数分間隔で回せば、参照側はただの小さいテーブルを読むだけになります。トレードオフは「鮮度」。リアルタイム性が必須なら集計テーブル+トリガ/増分更新、数分遅れで良ければマテビュー、と鮮度要件から逆算して選ぶのが実務の判断軸です。
QUESTION 5

再帰CTEの性能管理 — 階層データを深さ制限つきで安全・高速に辿る

WITH RECURSIVEUNION ALL深さ制限サイクル対策
前提知識

カテゴリ階層・組織図・部品表(BOM)など、親子関係を任意の深さまで辿るには WITH RECURSIVE を使います。動きは「非再帰項(シード)で初期行を作り、再帰項が直前の結果(作業テーブル)に対して繰り返し実行され、新しい行が出なくなったら停止」。性能管理の要点は2つ — 深さ制限(depth ガード)でツリーの爆発を抑えること、サイクル(循環参照)対策で無限ループを防ぐことです。

WITH RECURSIVE tree AS (
  -- 非再帰項(シード): 起点の1行
  SELECT id, name, parent_id, 0 AS depth FROM categories WHERE id = 1
  UNION ALL
  -- 再帰項: 直前ステップの行(t)の子だけを探す
  SELECT c.id, c.name, c.parent_id, t.depth + 1
  FROM categories c JOIN tree t ON c.parent_id = t.id
  WHERE t.depth < 2          -- 深さ制限ガード
)
SELECT * FROM tree;
再帰項が見るのは「累積全体」ではなく「直前に追加された行」:各イテレーションで JOIN 相手になるのは前回追加された行(作業テーブル)だけです。だから処理は「世代ごとの幅優先」で進み、子が見つからなくなった世代で自然停止します。この仕組みを理解すると、parent_id へのインデックスが各世代の JOIN を支える急所だと分かります。
問題

categories テーブルから、id=1(Electronics)を起点に配下のカテゴリすべてを深さ付きで取得してください。安全のため depth < 2 のガード(最大深さ2まで)を入れます。出力列は id, name, depth、depth 昇順 → id 昇順で。

使用テーブル
- categories(6行・parent_id に index)
idnameparent_id
1ElectronicsNULL
2Computers1
3Laptops2
4Audio1
5Headphones4
6BooksNULL
期待出力
idnamedepth
1Electronics0
2Computers1
4Audio1
3Laptops2
5Headphones2
模範解答コード
WITH RECURSIVE tree AS (
  SELECT id, name, parent_id, 0 AS depth  -- 【シード】起点 Electronics(depth=0)
  FROM   categories
  WHERE  id = 1

  UNION ALL  -- 重複排除不要のため UNION ALL(UNIONより軽い)

  SELECT c.id, c.name, c.parent_id, t.depth + 1
  FROM   categories c
  JOIN   tree t ON c.parent_id = t.id      -- 直前世代の行の「子」だけを探索(要 parent_id index)
  WHERE  t.depth < 2                         -- 【深さ制限】暴走・データ異常時の安全弁
)
SELECT   id, name, depth
FROM     tree
ORDER BY depth, id;

/*
  実行順序(再帰の世代ごと):
  1. シード実行      → 起点を作業テーブルに置く
  2. 再帰1回目      → parent_id の子を探索し追加
  3. 再帰2回目      → さらに子を探索し追加
  4. 再帰3回目      → ガードで停止(新規0件)
  5. 外側 SELECT  → 累積結果を depth, id で整列
  */
解説(テーブル変化・ポイント)
WITH RECURSIVE tree AS ( SELECT id, name, parent_id, 0 AS depth FROM categories WHERE id = 1 UNION ALL SELECT c.id, c.name, c.parent_id, t.depth + 1 FROM categories c JOIN tree t ON c.parent_id = t.id WHERE t.depth < 2 ) SELECT id, name, depth FROM tree ORDER BY depth, id;
LEGEND
1. シード — 非再帰項で起点を置く(depth=0)
SELECT ... WHERE id = 1(非再帰項)再帰の出発点。Electronics の1行だけが「累積結果」かつ「次の探索の親(作業テーブル)」になります。次のステップでは parent_id = 1 の行が生成される予定です(点線でプレビュー)。
1 / 5
idnamedepth状態
1Electronics0今回追加(シード)
2Computers1(予定)次に生成される行
4Audio1(予定)次に生成される行
累積: 1行 / 次の親候補: id=1
学習ポイント
再帰CTEは「世代ごとの幅優先」で動く:各イテレーションの JOIN 相手は直前世代の行だけで、累積結果全体ではありません。「シード → 子 → 孫」と1世代ずつ広がり、新規行が0件になった世代で自動停止します。この構造を頭に入れておくと、深さ・分岐数からコストを見積もれるようになります。
depth ガードは「正しく動く前提でも」入れる:本来3階層しかないデータでも、運用ミスで親子が循環した瞬間に無限ループします(A→B→A)。WHERE t.depth < N の1行は、データ異常時にクエリ暴走から DB を守る保険です。PostgreSQL 14+ なら CYCLE id SET is_cycle USING path でサイクル検出を宣言的に書くこともできます。
UNION ALL と parent_id インデックスが性能の急所:再帰CTEでは UNION(重複排除あり)より UNION ALL が基本 — 木構造なら重複は出ず、毎世代のハッシュ重複排除コストを丸ごと省けます。さらに再帰項の c.parent_id = t.id は世代ごとに実行されるため、categories(parent_id) のインデックスがないと世代数 × Seq Scan という最悪パターンになります。
アンチパターン
ガードなしの再帰を本番に出す:「データは綺麗だから大丈夫」は通用しません。1件の循環参照データが入っただけで、クエリは停止せず CPU を食い続けます。深さ制限・サイクル検出・statement_timeout のいずれか(できれば複数)を必ず仕込んでから本番投入してください。
アプリ側のループで1階層ずつ SELECT する:「親を取って、その子を取って…」とアプリケーションでループすると、階層の深さ × ノード数ぶんのラウンドトリップが発生します(いわゆる N+1 の階層版)。1本の再帰CTEに畳めば、DB 内で完結しネットワーク往復は1回です。
実務コラム:応用編の総まとめ — 「計画を設計する」5原則
基礎編の5原則が「悪い書き方を避ける」だったのに対し、応用編は実行計画そのものを設計する段階です。1. 複合インデックスは等価→範囲+並び順まで設計する(Q1)2. ページングはカーソルで深さ非依存にする(Q2)3. グループ内ランキングはウィンドウ関数で1パス化(Q3)4. JOINは畳んでから — 集約を結合の前へ(Q4)5. 再帰は世代モデルで理解し、ガードで守る(Q5)。共通する考え方は「DBに渡す仕事の総量を、クエリの構造で減らす」こと。インデックスを足す前に構造を変える、構造を変えたら EXPLAIN ANALYZE で測る——このループを回せることが、応用レベルの実務力です。