複合インデックスの列順 — 「等価が先、範囲は後」と左端プレフィックスの原則
複数列の条件で絞るクエリには複合インデックスが効きますが、性能は列の並び順で決まります。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)。b や c だけの条件にはほぼ効きません。さらに範囲条件に当たった列より後ろの列は「絞り込み」には使えず、フィルタ扱いになります。だから「等価 → 範囲」の順が鉄則です。orders テーブルから「completed 状態かつ 2024年第2四半期(4〜6月)に作成された注文」を取得してください。あわせて、このクエリに最適な複合インデックスの CREATE INDEX 文も書いてください。出力列は order_id, created_at, amount、created_at 昇順で。
| order_id | status | created_at | amount |
|---|---|---|---|
| 1 | completed | 2024-01-10 | 1200 |
| 2 | cancelled | 2024-04-02 | 800 |
| 3 | completed | 2024-04-15 | 3000 |
| 4 | completed | 2024-05-20 | 2500 |
| 5 | pending | 2024-05-25 | 900 |
| 6 | completed | 2024-06-30 | 4000 |
| 7 | completed | 2024-08-01 | 1100 |
| 8 | cancelled | 2024-06-10 | 600 |
| order_id | created_at | amount |
|---|---|---|
| 3 | 2024-04-15 | 3000 |
| 4 | 2024-05-20 | 2500 |
| 6 | 2024-06-30 | 4000 |
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本のレンジで読む */
LEGEND
1. FROM orders — 8行(複合インデックス設計の対象)
FROM ordersstatus(等価)と created_at(範囲)の2条件で絞ります。同じ3行を返すクエリでも、インデックスの列順によって走査量がまったく変わるのがこの問題のテーマです。| order_id | status | created_at | amount |
|---|---|---|---|
| 1 | completed | 2024-01-10 | 1200 |
| 2 | cancelled | 2024-04-02 | 800 |
| 3 | completed | 2024-04-15 | 3000 |
| 4 | completed | 2024-05-20 | 2500 |
| 5 | pending | 2024-05-25 | 900 |
| 6 | completed | 2024-06-30 | 4000 |
| 7 | completed | 2024-08-01 | 1100 |
| 8 | cancelled | 2024-06-10 | 600 |
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値あたりのヒット率が高すぎてプランナに無視されることが多いです。低カーディナリティ列は単独ではなく、複合インデックスの先頭で「等価の足場」として活かすのが正しい使い方です。pg_stat_statements 等で特定、(2) 条件を「等価/範囲/並べ替え」に分類、(3) 等価 → 範囲 → (カバーしたい列は INCLUDE)の順で列を並べる、(4) EXPLAIN ANALYZE で前後比較。既存インデックスの左端プレフィックスで賄えるなら追加しない、使われないインデックスは pg_stat_user_indexes で見つけて削る——「増やす設計」と同じくらい「増やさない設計」が重要です。Keyset Pagination — 深いページでも遅くならない「カーソル方式」のページング
基礎編で触れたとおり、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) < (値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。
| order_id | created_at | amount |
|---|---|---|
| 10 | 2024-06-30 | 2200 |
| 9 | 2024-06-21 | 1800 |
| 8 | 2024-06-05 | 3500 |
| 7 | 2024-06-05 | 1500 |
| 6 | 2024-05-18 | 900 |
| 5 | 2024-05-02 | 4100 |
| 4 | 2024-04-11 | 700 |
| 3 | 2024-03-22 | 2600 |
| 2 | 2024-02-14 | 1300 |
| 1 | 2024-01-05 | 500 |
| order_id | created_at | amount |
|---|---|---|
| 7 | 2024-06-05 | 1500 |
| 6 | 2024-05-18 | 900 |
| 5 | 2024-05-02 | 4100 |
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)) */
LEGEND
1. FROM orders — フィード全体(ソート済みインデックス)
FROM ordersINDEX (created_at DESC, order_id DESC) 上では、行はこの表の順で物理的に並んでいます。1ページ目は先頭3行(id=10, 9, 8)。id=8 と 7 の created_at 同値に注目してください。| order_id | created_at | amount |
|---|---|---|
| 10 | 2024-06-30 | 2200 |
| 9 | 2024-06-21 | 1800 |
| 8 | 2024-06-05 | 3500 |
| 7 | 2024-06-05 | 1500 |
| 6 | 2024-05-18 | 900 |
| 5 | 2024-05-02 | 4100 |
| 4 | 2024-04-11 | 700 |
| 3 | 2024-03-22 | 2600 |
| 2 | 2024-02-14 | 1300 |
| 1 | 2024-01-05 | 500 |
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 もあるため、対応していればタプル形を選びます。SELECT COUNT(*) FROM huge_table を毎リクエスト実行すると、本体クエリより集計のほうが重い本末転倒が起きます。無限スクロールなら件数は不要、必要でも概算(pg_class.reltuples)やキャッシュで代替するのが実務解です。WHERE created_at < '前ページ末尾の時刻' だけのカーソルは、同時刻の行をまとめて飛ばす(または2重に出す)バグの温床です。バッチ取り込みで created_at が秒単位で揃うテーブルでは特に頻発します。主キーまで含めたタプルでカーソルを構成してください。ウィンドウ関数で Top-N per Group — ROW_NUMBER で「カテゴリ別 上位2件」を1パスで取る
基礎編の 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 は必ず連番(同点でも 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 昇順で。
| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Books | 2000 |
| 4 | Toys | 5000 |
| 5 | Toys | 3000 |
| 6 | Toys | 4500 |
| 7 | Games | 2500 |
| 8 | Games | 2500 |
| 9 | Games | 900 |
| category | order_id | amount | rn |
|---|---|---|---|
| Books | 3 | 2000 | 1 |
| Books | 1 | 1200 | 2 |
| Games | 7 | 2500 | 1 |
| Games | 8 | 2500 | 2 |
| Toys | 4 | 5000 | 1 |
| Toys | 6 | 4500 | 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 -- ウィンドウ関数は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 に確定 */
LEGEND
1. FROM orders — 9行を1パスで読込
FROM orders3カテゴリ×各3行。「各カテゴリの上位2件」を相関サブクエリで書くと外側9行×内側走査の繰り返しになりますが、ウィンドウ関数なら全行を1回読むだけで済みます。| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Books | 2000 |
| 4 | Toys | 5000 |
| 5 | Toys | 3000 |
| 6 | Toys | 4500 |
| 7 | Games | 2500 |
| 8 | Games | 2500 |
| 9 | Games | 900 |
WHERE → SELECT(ウィンドウ評価) の順なので、WHERE rn <= 2 を同じ階層に書くとエラーになります。CTE またはサブクエリで rn を確定させてから外側で絞る2段構成が、Top-N per Group の不動の定石です。ORDER BY amount DESC, order_id のように一意キーで必ず順序を固定します。WHERE amount >= (SELECT ... ORDER BY amount DESC LIMIT 1 OFFSET 1) のような書き方は、外側の行数ぶんサブクエリが繰り返し評価されます。グループ数・行数が増えると一気に破綻するため、ウィンドウ関数(または PostgreSQL なら LATERAL + LIMIT)に置き換えるのが正解です。RANK() <= 2 は同点が多いと2件のつもりが3件以上返ることがあります。画面の枠が2件しかないUIにそのまま流すとレイアウト崩れやページング不整合の原因に。件数保証なら ROW_NUMBER、同点全員なら RANK と要件で使い分けてください。INDEX (category, amount DESC, order_id) があれば、データはすでに「区画 → 区画内順位」の順で並んでおり、ソートをインデックススキャンで肩代わりできます。さらに区画数が少なく各区画が巨大な場合、PostgreSQL ではTop-N を区画単位で打ち切れる LATERAL (SELECT ... LIMIT 2) 方式が勝つこともあります。「ウィンドウ一択」ではなく EXPLAIN で両者を比較するのが応用編の作法です。JOIN前集約 — 「集約してから結合」で行膨張と二重カウントを防ぐ
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 ...;
customers と orders から、全顧客の注文件数と合計金額を取得してください。orders を先に CTE で集約してから JOIN し、注文が1件もない顧客(Bob)も 0 / 0 で出力してください。出力列は customer_id, name, order_count, total_amount、customer_id 昇順で。
| customer_id | name |
|---|---|
| 101 | Alice |
| 102 | Bob |
| 103 | Carol |
| order_id | customer_id | amount |
|---|---|---|
| 1 | 101 | 1200 |
| 2 | 101 | 800 |
| 3 | 103 | 5000 |
| 4 | 103 | 3000 |
| 5 | 103 | 500 |
| 6 | 101 | 2500 |
| customer_id | name | order_count | total_amount |
|---|---|---|---|
| 101 | Alice | 3 | 4500 |
| 102 | Bob | 0 | 0 |
| 103 | Carol | 3 | 8500 |
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 → 結合に参加するのは「顧客数」行だけ */
LEGEND
1. FROM orders — 集約対象の子テーブル(6行)
WITH order_agg AS (... FROM orders ...)先に畳むのは「多」側の orders。customer_id ごとに色分けすると、101 が3行、103 が3行、102(Bob)は0行であることが見えます。| order_id | customer_id | amount |
|---|---|---|
| 1 | 101 | 1200 |
| 2 | 101 | 800 |
| 6 | 101 | 2500 |
| 3 | 103 | 5000 |
| 4 | 103 | 3000 |
| 5 | 103 | 500 |
COUNT(*) で数えると、Bob のような0件顧客が「1件」に化けます(NULL行も1行は1行だから)。COUNT(o.order_id) で回避もできますが、事前集約 + COALESCE 構成ならそもそも数え間違いが構造的に起きません。GROUP BY customer_id と外側の ON a.customer_id = c.customer_id が同じキーなら、orders(customer_id) のインデックス1本で集約のソート回避と結合の探索の両方に寄与します。キー設計を揃えるだけでプラン全体が引き締まります。customers ⨝ orders ⨝ payments を作ってから SUM(o.amount) すると、orders の各行が payments の件数分だけ重複して合計される古典的バグが起きます。数字が「なぜか倍になる」障害の大半はこれ。子テーブルはそれぞれ単独で集約してから JOIN してください。SELECT c.name, (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) ... は読みやすい反面、顧客行ごとにサブクエリが繰り返し実行されます。顧客数が少なければ許容できますが、一覧系クエリでは事前集約 + LEFT JOIN へ書き換えるのが定石です。CREATE MATERIALIZED VIEW customer_sales AS WITH order_agg AS (...) SELECT ... として今回のクエリ結果を物理保存し、REFRESH MATERIALIZED VIEW CONCURRENTLY を夜間バッチや数分間隔で回せば、参照側はただの小さいテーブルを読むだけになります。トレードオフは「鮮度」。リアルタイム性が必須なら集計テーブル+トリガ/増分更新、数分遅れで良ければマテビュー、と鮮度要件から逆算して選ぶのが実務の判断軸です。再帰CTEの性能管理 — 階層データを深さ制限つきで安全・高速に辿る
カテゴリ階層・組織図・部品表(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;
parent_id へのインデックスが各世代の JOIN を支える急所だと分かります。categories テーブルから、id=1(Electronics)を起点に配下のカテゴリすべてを深さ付きで取得してください。安全のため depth < 2 のガード(最大深さ2まで)を入れます。出力列は id, name, depth、depth 昇順 → id 昇順で。
| id | name | parent_id |
|---|---|---|
| 1 | Electronics | NULL |
| 2 | Computers | 1 |
| 3 | Laptops | 2 |
| 4 | Audio | 1 |
| 5 | Headphones | 4 |
| 6 | Books | NULL |
| id | name | depth |
|---|---|---|
| 1 | Electronics | 0 |
| 2 | Computers | 1 |
| 4 | Audio | 1 |
| 3 | Laptops | 2 |
| 5 | Headphones | 2 |
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 で整列 */
LEGEND
1. シード — 非再帰項で起点を置く(depth=0)
SELECT ... WHERE id = 1(非再帰項)再帰の出発点。Electronics の1行だけが「累積結果」かつ「次の探索の親(作業テーブル)」になります。次のステップでは parent_id = 1 の行が生成される予定です(点線でプレビュー)。| id | name | depth | 状態 |
|---|---|---|---|
| 1 | Electronics | 0 | 今回追加(シード) |
| 2 | Computers | 1(予定) | 次に生成される行 |
| 4 | Audio | 1(予定) | 次に生成される行 |
A→B→A)。WHERE t.depth < N の1行は、データ異常時にクエリ暴走から DB を守る保険です。PostgreSQL 14+ なら CYCLE id SET is_cycle USING path でサイクル検出を宣言的に書くこともできます。UNION(重複排除あり)より UNION ALL が基本 — 木構造なら重複は出ず、毎世代のハッシュ重複排除コストを丸ごと省けます。さらに再帰項の c.parent_id = t.id は世代ごとに実行されるため、categories(parent_id) のインデックスがないと世代数 × Seq Scan という最悪パターンになります。statement_timeout のいずれか(できれば複数)を必ず仕込んでから本番投入してください。