SQL パフォーマンス最適化 — INDEX設計・LATERALの応用

応用複合INDEX設計LATERAL JOINグループ別Top-NFILTER句PostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

日付の半開区間 — 関数とBETWEENの罠を範囲条件で断つ

半開区間date_truncBETWEENRange Scan
前提知識

「2026年5月の注文」のような期間抽出で date_trunc('month', ordered_at) = '2026-05-01' と書くと、列が関数で包まれて index が使えません(Sargable 違反)。さらに BETWEEN '2026-05-01' AND '2026-05-31' もタイムスタンプ列では危険で、5月31日の 00:00:00 より後の行が全て漏れます。正解は半開区間(開始以上・終了「未満」)です。

-- ✗ 列を関数で包む → 全行で関数評価 = Seq Scan
WHERE date_trunc('month', ordered_at) = '2026-05-01'

-- ✗ BETWEEN は「両端を含む」→ 5/31 00:00:00 を超える行が漏れる
WHERE ordered_at BETWEEN '2026-05-01' AND '2026-05-31'

-- ✓ 半開区間:index が効き、月末の端まで正確
WHERE ordered_at >= '2026-05-01' AND ordered_at < '2026-06-01'
半開区間が正解な理由:「終了側を < 翌期間の先頭」にすると、秒・ミリ秒の端数を一切考えなくてよいうえ、月末日数(28/30/31日)の場合分けも不要になります。日付でも月でも年でも同じ形で書ける、期間条件の万能フォームです。
問題

orders テーブル(実体300万行)の ordered_at(timestamp)にはインデックス idx_orders_ordered_at があります。2026年5月の注文を、index が効き、かつ月末の端数秒まで漏れなく ordered_at 昇順で取得してください。出力列は order_id, ordered_at, amount

使用テーブル
- orders(ordered_at は timestamp / index あり)
order_idordered_atamount
12026-04-30 23:59:59700
22026-05-01 00:00:003400
32026-05-14 12:30:00800
42026-05-20 18:05:00950
52026-05-31 23:59:59.92600
62026-06-01 00:00:001500
期待出力
order_idordered_atamount
22026-05-01 00:00:003400
32026-05-14 12:30:00800
42026-05-20 18:05:00950
52026-05-31 23:59:59.92600
QUESTION 2

複合インデックスの列順 — 「等値 → 範囲 → 並び」で設計する

複合index列順等値→範囲ソート省略
前提知識

複合インデックス (A, B) は「A で並べ、A が同じ中で B で並べる」電話帳(姓 → 名)構造です。この構造を最大限使える条件の形は「等値 → 範囲 → 並び替え」:先頭列を等値(=)で固定すると、その内側で次の列がきれいに整列した連続区間になり、範囲条件と ORDER BY までまとめて index に乗ります。

-- index (status, shipped_at) に対して:
WHERE status = 'shipped'              -- 1. 等値で先頭列を固定(区間を1点に)
  AND shipped_at >= '2026-06-01'      -- 2. 固定した区間内の範囲走査
ORDER BY shipped_at                    -- 3. 区間内は整列済み → ソート不要
逆順 (shipped_at, status) だと?範囲条件の列が先頭だと、範囲内のあらゆる status が混在した広い区間を読みながら status を1行ずつ判定するハメになります。「等値で絞れる列を左、範囲・ソートの列を右」——複合 index 設計の最重要原則です。
問題

shipments テーブル(実体200万行)には複合インデックス idx_ship_status_date (status, shipped_at) があります。ステータスが 'shipped' で、2026年6月1日以降に出荷されたレコードを、この index だけで絞り込みとソートが完結する形shipped_at 昇順に取得してください。出力列は ship_id, status, shipped_at, dest

使用テーブル
- shipments((status, shipped_at) に複合 index)
ship_idstatusshipped_atdest
1pending2026-06-02Tokyo
2shipped2026-05-28Osaka
3shipped2026-06-01Tokyo
4delivered2026-06-03Nagoya
5shipped2026-06-04Sapporo
6pending2026-05-30Fukuoka
7shipped2026-06-09Tokyo
8delivered2026-06-05Kobe
期待出力
ship_idstatusshipped_atdest
3shipped2026-06-01Tokyo
5shipped2026-06-04Sapporo
7shipped2026-06-09Tokyo
QUESTION 3

LATERAL JOIN — 「顧客ごとの最新2件」を index で取り切る

LATERALグループ別Top-NLIMITIndex Scan×N
前提知識

「顧客ごとの最新N件」は実務最頻出の要件ですが、素朴に書くと難物です。ウィンドウ関数(ROW_NUMBER())でも書けますが、対象顧客が少数なら全行スキャン + 全行ソートは過剰。LATERAL を使うと、左側の各行を引数のように受け取るサブクエリを書け、顧客1人ずつ「index で最新2件だけ拾って即終了」という最小走査が実現します。

FROM customers c
CROSS JOIN LATERAL (              -- c の各行ごとに実行されるサブクエリ
  SELECT ...
  FROM   orders
  WHERE  customer_id = c.customer_id  -- 外側の列を参照できる(LATERAL の特権)
  ORDER BY ordered_at DESC
  LIMIT  2                       -- 顧客ごとに2件で打ち切り
) o
速さの源泉:複合 index (customer_id, ordered_at DESC) があれば、各顧客のサブクエリは「等値で顧客区間に固定 → 整列済みの先頭から2行読んで終了」。注文が100万件あっても、走査は 顧客数 × 2行 で済みます。
問題

customers(3行)と orders(実体100万行、複合インデックス idx_orders_cust_date (customer_id, ordered_at DESC) あり)から、各顧客の最新2件の注文を LATERAL JOIN で取得してください。出力列は customer_id, name, order_id, ordered_at, amount、並びは customer_id 昇順・ordered_at 降順。

使用テーブル
- customers
customer_idname
101Sato
102Suzuki
103Tanaka
- orders((customer_id, ordered_at DESC) に複合 index)
order_idcustomer_idordered_atamount
11012026-06-011200
21022026-06-02800
31012026-06-033000
41032026-06-04500
51012026-06-05900
61022026-06-061500
71032026-06-07700
81012026-06-082200
91022026-06-09600
期待出力
customer_idnameorder_idordered_atamount
101Sato82026-06-082200
101Sato52026-06-05900
102Suzuki92026-06-09600
102Suzuki62026-06-061500
103Tanaka72026-06-07700
103Tanaka42026-06-04500
QUESTION 4

FILTER 句 — 1回の走査で複数の条件付き集約を畳み込む

FILTER条件付き集約HAVING1パス集計
前提知識

「決済手段ごとに、全体件数成功件数失敗額合計を出す」——条件の違う集約が複数並ぶレポート要件です。テーブルを条件ごとに3回スキャンして JOIN するのは最悪手。PostgreSQL の FILTER 句を使えば、1回の走査の中で集約関数ごとに対象行を選り分けられます。

-- ✗ 条件ごとにスキャンを繰り返して JOIN(3パス)
SELECT ... FROM (成功だけ集計) JOIN (失敗だけ集計) ON ...

-- ✓ FILTER:1パスの中で集約ごとに行を選別
COUNT(*) FILTER (WHERE status = 'ok')   -- この COUNT だけ ok 行を数える
SUM(amount) FILTER (WHERE status = 'ng') -- この SUM だけ ng 行を足す
WHERE / FILTER / HAVING の3層を区別する:WHERE は「集約に入れる行」を全体で絞る(集約前・index 可)。FILTER は「その集約関数1つだけの対象行」を絞る。HAVING は「集約し終わったグループ」を絞る(集約後)。基礎編の WHERE vs HAVING に、中間の第3層が加わった形です。
問題

payments テーブル(実体800万行)から決済手段(method)ごとに、全体件数 cnt成功件数 ok_cnt(status='ok')失敗額合計 ng_amount(status='ng' の amount 合計、該当なしは 0)1回の走査で集計し、件数が2以上の手段だけmethod 昇順で取得してください。

使用テーブル
- payments
pay_idmethodstatusamount
1cardok1200
2bankok5000
3cardng800
4cardok2400
5paypayok600
6bankng3000
7cardng500
8bankok7000
期待出力
methodcntok_cntng_amount
bank323000
card421300
QUESTION 5

部分インデックス — 「未処理の1%」だけを索引して小さく速く

部分indexWHERE付きindexLIMITキュー処理
前提知識

ジョブキューや注文ステータスのようなテーブルでは、検索対象は常に「未処理(pending)」のごく一部なのに、行の99%は処理済み——という偏りが生まれます。全行を索引する通常 index は、この99%のためにサイズと更新コストを払い続けるムダを抱えます。CREATE INDEX ... WHERE 条件 の部分インデックスは、条件に合う行だけを索引する小さな index です。

-- pending 行(全体の1%)だけを索引する部分 index
CREATE INDEX idx_tasks_pending
  ON tasks (created_at)
  WHERE status = 'pending';   -- ← この述語が index の「守備範囲」
使われる条件:部分 index は、クエリの WHERE が index の述語を含意するときだけ採用されます。WHERE status = 'pending' を書けば使われ、WHERE status IN ('pending', 'done') では使われません。「index の述語 ⊇ クエリの絞り込み」が成立するかをプランナが判定します。
問題

tasks テーブル(実体100万行、うち status = 'pending' は約1%)に対し、(1) pending 行だけを created_at 順に索引する部分インデックスを作成し、(2) それを使って最も古い pending タスク3件を取得してください。出力列は task_id, created_at, title

使用テーブル
- tasks(100万行中 pending は約1%)
task_idstatuscreated_attitle
1done2026-05-01請求書作成
2pending2026-05-03在庫棚卸
3done2026-05-04月次レポート
4pending2026-05-06契約書レビュー
5canceled2026-05-08旧サイト改修
6pending2026-05-10監査資料準備
7done2026-05-12採用面接調整
8pending2026-05-13価格改定通知
期待出力
task_idcreated_attitle
22026-05-03在庫棚卸
42026-05-06契約書レビュー
62026-05-10監査資料準備