複合インデックスの列順 — 「等値 → 範囲」で並べる
複合インデックス (a, b, c) は B-tree 上で「a でまず辞書順、a が同じなら b、…」と並びます。WHERE 句で活用されるのは左端から連続した列であり、しかも「最初の範囲条件(<, >, BETWEEN, LIKE)」より右の列はインデックスのソート順で絞り込めません。設計の鉄則は 「等値 → 範囲 → ソート」の順で列を並べること。
-- ✓ (customer_id, created_at) ← 等値→範囲 の順 WHERE customer_id = 101 AND created_at >= '2024-06-01' ORDER BY created_at DESC; -- ✗ (created_at, customer_id) ← 範囲が先頭:customer_id 単独で絞れない
orders テーブル(100万行)に対し、よく流れるクエリは「特定顧客の最近の注文(日付降順 上位3件)」です。最適な複合インデックスを設計し、それを活かす SELECT 文を書いてください。
| order_id | customer_id | created_at | amount |
|---|---|---|---|
| 1 | 100 | 2024-05-01 | 1200 |
| 2 | 101 | 2024-06-10 | 800 |
| 3 | 101 | 2024-07-22 | 2000 |
| 4 | 101 | 2024-08-30 | 3000 |
| 5 | 102 | 2024-04-15 | 500 |
| 6 | 101 | 2024-09-05 | 1500 |
| 7 | 103 | 2024-07-01 | 700 |
| 8 | 101 | 2024-05-20 | 900 |
| order_id | customer_id | created_at | amount |
|---|---|---|---|
| 6 | 101 | 2024-09-05 | 1500 |
| 4 | 101 | 2024-08-30 | 3000 |
| 3 | 101 | 2024-07-22 | 2000 |
CREATE INDEX idx_orders_cust_date ON orders (customer_id, created_at DESC); -- 【インデックス設計】等値(customer_id)→ 範囲(created_at)の順で並べる -- 【クエリ】そのインデックスをフル活用する SELECT SELECT order_id, customer_id, created_at, amount FROM orders WHERE customer_id = 101 -- 等値:B-treeで該当ブロックに直接ジャンプ AND created_at >= '2024-06-01' -- 範囲:絞ったブロック内で範囲スキャン ORDER BY created_at DESC -- index と同じ並び → 追加ソート不要 LIMIT 3; -- 3行読んだ瞬間に走査終了(Top-N) /* 実行順序(プランナの動き): 1. idx の B-tree で customer_id に直接到達 → インデックスで対象に到達 2. created_at の開始位置を二分探索 → 範囲の開始を特定 3. 最新側から必要行を取り出して終了 → 必要件数で打ち切り 4. テーブル本体から SELECT 列を取得 → 列を取得 */
LEGEND
1. 対象テーブル — orders(customer_id, created_at の複合index予定)
FROM ordersorders は customer_id(多数顧客)と created_at(日付)の組み合わせ検索が頻発します。customer_id=101 の行は order_id=2,3,4,6,8 の5行、うち 2024-06-01 以降は4行です。| order_id | customer_id | created_at | amount |
|---|---|---|---|
| 1 | 100 | 2024-05-01 | 1200 |
| 2 | 101 | 2024-06-10 | 800 |
| 3 | 101 | 2024-07-22 | 2000 |
| 4 | 101 | 2024-08-30 | 3000 |
| 5 | 102 | 2024-04-15 | 500 |
| 6 | 101 | 2024-09-05 | 1500 |
| 7 | 103 | 2024-07-01 | 700 |
| 8 | 101 | 2024-05-20 | 900 |
CREATE INDEX ... (col DESC) または ORDER BY col DESC を index の昇順から Backward Scan させることで、ソート工程そのものを省けます。EXPLAIN に Sort ノードが出ているかどうかが目印です。(a, b, c) は WHERE が a 単独 / a+b / a+b+c のいずれでもインデックスが効きますが、b 単独 / c 単独 / b+c では効きません。「左から連続して使われているか」だけが判定基準です。これを意識すると、似たような単一インデックスを乱立させずに済みます。(created_at, customer_id) のように範囲列を先頭に置くと、後続の customer_id は index で絞れず、結果として「該当期間の全顧客分」を読むハメになります。等値で絞れる列ほど左に持ってくるのが鉄則です。pg_stat_statements や slow query log で実行頻度の高い WHERE / ORDER BY パターンを抽出し、その列順・並び順に合わせて複合 index を作る。「等値 → 範囲 → ソート」を満たす index は、しばしば1本で複数の頻出クエリを高速化します。一方、書込み側はインデックス数だけコストが増えるため、「1本で多くのクエリをカバーする」のがプロの設計です。Q7 では、複数テーブルを跨ぐ JOIN でもこの index 設計がそのまま効いてくることを見ていきます。JOIN の基礎 — Nested Loop と Hash Join、駆動表の選び方
JOIN は2つのテーブルを関連付ける操作で、プランナは主に2つのアルゴリズムから選びます。Nested Loop は駆動表(外側)の各行に対して内部表(内側)を都度検索する方式で、外側が小さく内側にインデックスがあるときに最速。Hash Join は片方からハッシュテーブルを構築してもう一方を1回スキャンする方式で、大規模・等値結合で有利です。
-- プランナが Nested Loop を選ぶ典型例(customersが小、ordersが大+Indexあり) SELECT * FROM customers c -- ← 駆動表(外側:ここで絞り込まれた行数分だけループが回る) JOIN orders o -- ← 内部表(内側:外側の1行ごとに Index Scan される) ON c.customer_id = o.customer_id; -- 大規模テーブル同士で Index がない場合は Hash Join が選ばれやすい SELECT * FROM huge_table_A a -- ← 小さい方でハッシュ表をメモリに構築 JOIN huge_table_B b -- ← 大きい方を1パススキャンしてハッシュ表と突合 ON a.id = b.a_id;
customers と orders を customer_id で内部結合し、各注文に顧客名を付けた一覧を取得してください。orders.customer_id にはインデックスがあります。出力列は order_id, customer_name, amount、order_id 昇順で。
| customer_id | name |
|---|---|
| 101 | Alice |
| 102 | Bob |
| 103 | Carol |
| order_id | customer_id | amount |
|---|---|---|
| 1 | 101 | 1200 |
| 2 | 103 | 800 |
| 3 | 101 | 2000 |
| 4 | 102 | 3000 |
| 5 | 103 | 1500 |
| order_id | customer_name | amount |
|---|---|---|
| 1 | Alice | 1200 |
| 2 | Carol | 800 |
| 3 | Alice | 2000 |
| 4 | Bob | 3000 |
| 5 | Carol | 1500 |
SELECT o.order_id, c.name AS customer_name, o.amount FROM customers c -- 小さい側を駆動表として明示的に先頭に INNER JOIN orders o -- 内側:customer_id に index あり → 1ループ1ジャンプ ON o.customer_id = c.customer_id -- 結合条件(等値) ORDER BY o.order_id; -- 出力ソート /* 実行順序(Nested Loop プランの場合): 1. customers を1行ずつ走査 → 外側=駆動表 2. 各 c に orders を Index Scan → インデックスで結合 3. 結合結果を ORDER BY order_id → 並べ替え */
LEGEND
1. 駆動表の走査 — customers (外側)
FROM customers cプランナは行数が少ない customers を駆動表(外側テーブル)として選び、全3行を1行ずつ走査します。| customer_id | name |
|---|---|
| 101 | Alice |
| 102 | Bob |
| 103 | Carol |
WHERE で外側を絞れば絞るほどコストが直線的に減るのが特徴。「絞ってから結合」を意識すると、自然と良いプランに乗ります。外側×内側のループを回すより圧倒的に軽くなる場面です。work_mem が小さいとディスクに溢れて遅くなるので、メモリ設定も性能の一部です。ON c.customer_id = o.customer_id_text のように型がずれた結合は、片側に暗黙のキャストが入って index が無効化されます。「両側 BIGINT」「両側 VARCHAR」のように同型・同サイズで揃えるのが、性能だけでなくデータ整合性のためにも重要です。LEFT JOIN ではなく INNER JOIN を使います。OUTER JOIN はNULL 行を許容する分、プランナの選択肢が狭まることがあり、INNER に置き換えるだけで Hash Join が選ばれるケースもあります。「INNER で十分か?」を毎回問うのは良い習慣です。A JOIN B と B JOIN A は意味的に同じで、プランナがコストを見て駆動表を自動的に入れ替えます(join_collapse_limit の範囲内で)。「FROM の左に書いた方が外側になる」のは古い迷信です。重要なのは (1) 結合キーに index があるか、(2) どちらが先に絞り込めるか、(3) 統計が最新かの3点だけ。プランナが想定と違う駆動表を選んでいると感じたら、まず ANALYZE で統計を更新し、それでもダメなら結合キーや WHERE の選択率を見直しましょう。次の Q8 では「サブクエリで隠れた N+1 が起きる」典型例を扱います。スカラサブクエリの罠 — 集約は事前に1回だけ計算する
SELECT 句に書くスカラサブクエリ(1行1列を返すサブクエリ)は便利ですが、外側1行ごとに再評価されることがあり、外側 N 行に対しN 回サブクエリが走ることになります(いわゆる SQL レベルの N+1)。事前に GROUP BY で集約してから LEFT JOIN エンジンに任せれば、集約は1回で済みます。
-- ✗ スカラサブクエリ:customers 4 行 → SUM が 4 回実行される SELECT c.customer_id, c.name, (SELECT SUM(amount) FROM orders WHERE customer_id = c.customer_id) AS total FROM customers c; -- ✓ 事前集約:SUM は1回(orders を1パス)、その後 LEFT JOIN で結合
customers 全件に、その顧客の注文合計額(注文が無ければ 0)を付与した一覧を取得してください。事前集約 + LEFT JOIN の形で、スカラサブクエリを使わずに書いてください。出力列は customer_id, name, total、customer_id 昇順で。
| customer_id | name |
|---|---|
| 101 | Alice |
| 102 | Bob |
| 103 | Carol |
| 104 | Dan |
| order_id | customer_id | amount |
|---|---|---|
| 1 | 101 | 1200 |
| 2 | 101 | 800 |
| 3 | 103 | 2000 |
| 4 | 103 | 1500 |
| 5 | 101 | 500 |
| customer_id | name | total |
|---|---|---|
| 101 | Alice | 2500 |
| 102 | Bob | 0 |
| 103 | Carol | 3500 |
| 104 | Dan | 0 |
WITH agg AS ( -- 【事前集約】customer_id 単位で SUM を1回だけ計算 SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id -- orders を1パスで集計 ) SELECT c.customer_id, c.name, COALESCE(a.total, 0) AS total -- 【NULL → 0】注文なし顧客を 0 で埋める FROM customers c LEFT JOIN agg a -- 【LEFT JOIN】注文なし顧客も残す ON a.customer_id = c.customer_id ORDER BY c.customer_id; /* 実行順序: 1. CTE agg: orders を1パスでスキャンし、customer_id 単位で SUM 2. customers 4 行を読み込み(駆動表) 3. 各 c に対し agg を customer_id で LEFT JOIN 4. COALESCE で NULL を 0 に置換 5. customer_id 昇順で並べ替え */
LEGEND
1. 2テーブル読込 — customers と orders
FROM customers, orderscustomers 4 行に対し、orders は 5 行。最終的に「全顧客 × 合計額(無ければ0)」が欲しいので、orders をどう走査するかが性能の鍵です。| src | customer_id | val1 | val2 |
|---|---|---|---|
| customers | 101 | Alice | — |
| customers | 102 | Bob | — |
| customers | 103 | Carol | — |
| customers | 104 | Dan | — |
| orders | 101 | o.order_id=1 | 1200 |
| orders | 101 | o.order_id=2 | 800 |
| orders | 103 | o.order_id=3 | 2000 |
| orders | 103 | o.order_id=4 | 1500 |
| orders | 101 | o.order_id=5 | 500 |
WITH や派生表で事前に1回だけ集約し、その結果を結合する形に直すと、orders などの大規模テーブルへのアクセスが激減します。LEFT JOIN で全顧客を残しつつ COALESCE(集約結果, 0) で NULL を埋めるのが最も読みやすく速い書き方です。INNER JOIN にすると「注文なし顧客」が消えるバグを生むので、必ず LEFT JOIN を選びます。WITH agg AS (... GROUP BY customer_id) で一度に複数列を作れば、orders へのアクセスは1回で済みます。(SELECT ... FROM ...) を3〜4本並べると、外側1行ごとにその本数だけサブクエリが実行されます。プランナが結合に書き換えてくれることもありますが、全DB・全構文で保証されるわけではなく、最初から CTE / 派生表で書く方が安全です。FROM c LEFT JOIN agg a ON ... WHERE a.total > 0 と書くと、LEFT JOIN が実質 INNER JOIN に降格します(NULL は > 0 で FALSE のため)。NULL を残したい時は WHERE a.total IS NULL OR a.total > 0 か、COALESCE(a.total,0) > 0 のように明示的に NULL を扱える条件に書き換えます。MATERIALIZED / NOT MATERIALIZED で制御できます。実務では「集約は CTE で、結合は LEFT JOIN で、NULL は COALESCE で」を機械的に適用すれば、ほとんどのレポートクエリは綺麗に書けます。Q9 ではこの集約を「グループ別の上位 K」に拡張するウィンドウ関数を扱います。ウィンドウ関数で「グループ別 Top-N」 — ROW_NUMBER + PARTITION BY
通常の ORDER BY ... LIMIT は「全体の上位 K」しか取れません。「グループごとの上位 K」を取るにはウィンドウ関数 ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) を使い、各グループ内で番号を振ってからWHERE rn <= K で絞ります。集約と違って行が畳まれないのがウィンドウ関数の最大の特徴です。
-- グループ毎に独立に番号付け(=畳まずに列を追加) ROW_NUMBER() OVER ( PARTITION BY category -- カテゴリ毎にリセット ORDER BY amount DESC -- 同一カテゴリ内の並び順 ) AS rn
ROW_NUMBER=同値でも別番号(1,2,3,4)、(2) RANK=同値は同順位+次が飛ぶ(1,2,2,4)、(3) DENSE_RANK=同値同順位+飛ばない(1,2,2,3)。「Top-N で正確に K 件」欲しい時は ROW_NUMBER が定石です。orders からカテゴリ別の売上トップ2注文を取得してください。出力列は 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 | 1500 |
| 7 | Food | 700 |
| 8 | Food | 500 |
| category | order_id | amount | rn |
|---|---|---|---|
| Books | 3 | 2000 | 1 |
| Books | 1 | 1200 | 2 |
| Food | 7 | 700 | 1 |
| Food | 8 | 500 | 2 |
| Toys | 4 | 5000 | 1 |
| Toys | 5 | 3000 | 2 |
WITH ranked AS ( -- 【番号付け】各カテゴリ内で売上降順の順位を列として追加 SELECT category, order_id, amount, ROW_NUMBER() OVER ( PARTITION BY category -- カテゴリ毎に番号をリセット ORDER BY amount DESC -- 同一カテゴリ内は売上降順 ) AS rn FROM orders ) SELECT category, order_id, amount, rn FROM ranked WHERE rn <= 2 -- 各カテゴリの上位 K=2 だけを残す ORDER BY category, rn; -- カテゴリ昇順、同カテ内は順位昇順 /* 実行順序(SQLの論理的な評価順): 1. FROM orders → スキャン 2. ウィンドウ評価 → PARTITION+ORDER+ROW_NUMBER で番号付与 3. WHERE rn で上位を抽出 → 各カテゴリ上位2行を残す 4. ORDER BY category, rn → 表示用に並べ替え */
LEGEND
1. FROM orders — 8行読込
FROM orders3カテゴリ × 計8行のデータを読み込みます。「カテゴリ別のトップ2」を最終的に欲しいので、カテゴリ毎に独立した順位付けが必要です。| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Books | 2000 |
| 4 | Toys | 5000 |
| 5 | Toys | 3000 |
| 6 | Toys | 1500 |
| 7 | Food | 700 |
| 8 | Food | 500 |
WITH ranked AS (...) SELECT ... WHERE rn <= K のようにCTE か派生表で一旦包むのが定型パターンです。PARTITION BY は「どこで番号をリセットするか」、ORDER BY は「窓の中での並び順」。両方省略すると「全行を1つの窓として」扱われ、ORDER BY だけだと「全体での累計順位」になります。「窓 = グループ + 並び順」と覚えると関数族全体が一気に身に付きます。WHERE (SELECT COUNT(*) FROM orders o2 WHERE o2.category = o.category AND o2.amount > o.amount) < 2 のような書き方は、外側 N 行 × 内側 N 行 = N² の計算量になりがちです。ROW_NUMBER で1パスにする方が圧倒的に速く、読みやすくもなります。amount に同値があり、「同順位は両方残したい」のに ROW_NUMBER を使うと同値の片方が任意に切られます。同順位を尊重したいなら RANK か DENSE_RANK。「同値の扱いをどうしたいか」を仕様レベルで決めてから関数を選ぶのが本筋です。SUM(...) OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN ...) のように「窓の中で累計・移動平均・前後行参照」まで行えます。累計売上(partition なし + ORDER BY date)、顧客毎の累計(PARTITION BY customer_id + ORDER BY date)、3ヶ月移動平均(ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)など、レポート系の頻出指標はほぼ全部ウィンドウ関数1本で書けます。性能面ではpartition と order_by の組み合わせに合うインデックスを貼ると、ウィンドウのソート工程が省略されます。最終問題の Q10 では、ページング処理を「OFFSET の罠 → Keyset Pagination」で本質的に解決する方法を扱います。Keyset Pagination — OFFSET の罠を seek method で解決する
ページングの定番 LIMIT ... OFFSET ... は浅いページなら問題ありませんが、OFFSET が大きくなるほど線形に遅くなる致命的な性質があります。OFFSET 10000 なら「先頭から10000行を読んで捨ててから次の20行を返す」動作で、ページが深くなるほどコストが膨らみます。Keyset Pagination(seek method)は「前ページ最後の値より大きい行から LIMIT 件」を取る方式で、ページ位置に依らず一定時間です。
-- ✗ OFFSET 方式:深いページで線形に遅くなる SELECT ... FROM orders ORDER BY order_id LIMIT 20 OFFSET 10000; -- ✓ Keyset 方式:前ページ最後の order_id より後ろを直接取得 SELECT ... FROM orders WHERE order_id > 10000 -- 前ページ最後の order_id ORDER BY order_id LIMIT 20;
orders(100万行)の order_id 昇順で 20 件ずつページングします。「直前ページの最終 order_id = 10000」が分かっている前提で、次の20件を Keyset Pagination で取得してください。出力列は order_id, customer_id, amount。
| order_id | customer_id | amount |
|---|---|---|
| 9998 | 301 | 1100 |
| 9999 | 302 | 900 |
| 10000 ←前ページ末尾 | 303 | 1300 |
| 10001 | 304 | 800 |
| 10002 | 305 | 2200 |
| ... | ... | ... |
| 10020 | 323 | 1700 |
| 10021 | 324 | 950 |
| order_id | customer_id | amount |
|---|---|---|
| 10001 | 304 | 800 |
| 10002 | 305 | 2200 |
| 10003 | 306 | 1400 |
| ... | ... | ... |
| 10019 | 322 | 1600 |
| 10020 | 323 | 1700 |
-- 【Keyset Pagination】前ページの最終 order_id より後ろを index で直接取得 SELECT order_id, customer_id, amount FROM orders WHERE order_id > 10000 -- 前ページ末尾の order_id(クライアントが保持) ORDER BY order_id -- index と同じ並び → 追加ソート不要 LIMIT 20; -- 20件で打ち切り /* 実行順序(プランナの動き): 1. orders_pkey で order_id 直後のリーフに到達 → 二分探索で到達 2. リーフを右に辿り必要行を取得 → 必要件数で打ち切り 3. テーブル本体から SELECT 列を取得 → 列を取得 */
LEGEND
1. 対象テーブル — orders(100万行・order_id に PK 索引)
FROM ordersorders は order_id を主キーに持つ100万行のテーブル。order_id=10000 付近を表示しています。「20件ずつページング」したい時、深いページほど OFFSET 方式は線形に遅くなります。| order_id | customer_id | amount |
|---|---|---|
| 9998 | 301 | 1100 |
| 9999 | 302 | 900 |
| 10000 (前ページ末尾) | 303 | 1300 |
| 10001 | 304 | 800 |
| 10002 | 305 | 2200 |
| … | … | … |
| 10020 | 323 | 1700 |
| 10021 | 324 | 950 |
LIMIT K OFFSET N は内部的に「N 行を index でたどって全部捨て、その後 K 行を返す」動作です。OFFSET が小さい間は問題ありませんが、N が数万を超えるあたりから体感できる遅延が出始め、N が数百万になると実質クエリが死にます。深いページネーションが想定される画面では最初から Keyset 設計が原則です。WHERE 並び順列 > 前ページ最後の値 ORDER BY 並び順列 LIMIT K。これが Index Range Scan に乗るためには並び順列に index が必須です。複合キーで並べる場合は同じ順序の複合 indexを貼り、(created_at, order_id) > (...) のタプル比較や OR 展開で正しく境界を作ります。OFFSET 大値 を生成する設計は、データ増加で突然死します。最終ページが本当に必要なら、ORDER BY を逆順にして OFFSET 0 から取るのが代替手段です(並び順を反転させると「最後の K 件」は「逆順の最初の K 件」になる)。ORDER BY created_at LIMIT 20 で created_at に重複があると、境界で行が欠ける/二重表示される事故が起きます。必ずtie-breaker 列(多くは主キー)を ORDER BY に足し、WHERE もタプル比較するのが原則です。「ORDER BY は一意であるべし」と覚えておくとバグが激減します。SELECT COUNT(*) です。数百万件のテーブルでは、検索条件に一致する全件を数えるだけで秒単位の時間がかかります。Keyset Pagination を導入して「もっと見る」「次へ」型の UI に変更した場合、もはや総ページ数は不要になります。実務でのベストプラクティスは、「1ページの表示件数が 20 件なら、DB からは LIMIT 21 で取得する」ことです。21件目が存在すれば「次のページがある」と判定して「次へ」ボタンを表示し、実際の画面には20件だけを表示します。この LIMIT K+1 手法 と Keyset を組み合わせることで、DB への負荷を最小限に抑えた超高速な一覧画面が完成します。