SQL パフォーマンス最適化 — LAG/LEAD・キーセットの応用

応用式インデックスLAG / LEADDESC/ASC 混在キーセットLATERAL JOINPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

式インデックス — 「列を関数で包む」要件を Sargable に戻す

式インデックスFunctional IndexSargable大文字小文字無視
前提知識

基礎編では「リテラル側を列の型に揃えろ」と学びました。しかし実務には列を関数で包まないと表現できない要件があります。代表例が大文字小文字無視の検索——アプリ側から Alice@Example.COMalice@example.com のどちらが来ても同じ顧客にマッチさせたい。
このとき通常の idx(email)LOWER(email) = ? に対して無力です(列が関数で包まれた瞬間に Sargable 違反)。解決策は式そのものを索引化する「式インデックス(functional index)」。LOWER(email) という式の値をあらかじめ並べた index を作れば、同じ式での検索は等価検索に戻ります。

-- ✗ 通常 index は LOWER で死ぬ — 全行スキャン
CREATE INDEX idx_users_email ON users (email);
WHERE LOWER(email) = 'alice@example.com'

-- ✓ 式そのものを索引化 → 同じ式での検索が Sargable に戻る
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
WHERE LOWER(email) = 'alice@example.com'   -- 式が index と完全一致
核心:「列を関数で包んだら index は使えない」は通常 index に対して真。包んだ式そのものを index にすると原則は維持されたまま、関数包みのコードを書ける——「Sargable は等価か範囲で使える形」であり、式 index はその「形」の対象を式の値まで広げる仕組みです。
問題

users テーブル(実体100万行)から、メールアドレスを大文字小文字を区別せず 'Alice@Example.COM' に一致する会員を取得してください。アプリ側からの入力は表記揺れがあり、テーブル側のデータも歴史的経緯で大文字小文字が混在しています。関数包みのまま、しかも index 等価検索で読む形にすること(必要な index も同時に提示)。出力列は user_id, email(user_id 昇順)。

使用テーブル
- users(email は varchar・通常 index あり・表記揺れ混在)
user_idemail
1alice@example.com
2bob@example.com
3ALICE@example.com
4charlie@example.com
5aLiCe@Example.Com
期待出力
user_idemail
1alice@example.com
3ALICE@example.com
5aLiCe@Example.Com
QUESTION 7

LAG / LEAD と差分計算 — 自己 JOIN ではなくウィンドウで1パス

LAG / LEADウィンドウ前日比1パス
前提知識

基礎編では ROW_NUMBER で「グループ内の順位」を取りました。応用ではLAG / LEAD——「同じ区画の前後の行の値」をその場で参照できる関数です。前日比・直前との差分・連続イベント間隔など、行どうしを並べて隣を見たい要件はすべてこの形に落ちます。

同じ要件を自己 JOIN や相関サブクエリで書くと、行ごとに「前の行」を再探索することになり N+1 とファンアウトの温床。ウィンドウ関数ならテーブルを1回走査するだけで「前の行」を引けます——区画キーで整列した順に1行ずつ流れていく中で、内部的に1つ前の値を覚えているだけだからです。

-- ✗ 自己 LEFT JOIN + 相関 MAX で前日を探す → 行ごとに探索 + ファンアウトリスク
SELECT p1.price - p2.price AS diff
FROM price_history p1
LEFT JOIN price_history p2
  ON p2.product_id = p1.product_id
 AND p2.recorded_at = (SELECT MAX(recorded_at) FROM price_history x
                          WHERE x.product_id = p1.product_id AND x.recorded_at < p1.recorded_at)

-- ✓ LAG で1パス・行は畳まれず差分が各行に付く
price - LAG(price) OVER (PARTITION BY product_id ORDER BY recorded_at) AS diff
核心:LAG(col)同じ区画内ORDER BY 順に1つ手前の行の col を返します(区画先頭は NULL、または LAG(col, 1, 0) で既定値指定)。区画の境界をまたいだ参照はしません——これが「自己 JOIN だと書きにくい正しさ」を構文レベルで担保している部分です。
問題

price_history から、各商品の日次価格を「前日との差分(前日比)」付きで取得してください。各商品の初日は差分 NULL でよい。要件は次の通り:

  • 自己 JOIN や相関サブクエリは使わない(行数ぶんの探索を発生させない)
  • テーブルの走査は1回のみ(ウィンドウ関数1パス)
  • 商品をまたいで差分を取らない(product_id=1 の最終日から product_id=2 の初日を引かない)

出力列は product_id, recorded_at, price, diff(product_id, recorded_at 昇順)。

使用テーブル
- price_history((product_id, recorded_at) に index あり)
product_idrecorded_atprice
12026-06-011000
12026-06-021100
12026-06-031050
22026-06-01500
22026-06-02520
22026-06-03510
期待出力
product_idrecorded_atpricediff
12026-06-011000NULL
12026-06-021100100
12026-06-031050-50
22026-06-01500NULL
22026-06-0252020
22026-06-03510-10
QUESTION 8

DESC/ASC 混在のキーセット — 行値比較が使えない並びを OR 展開で

複合キーセットソート方向混在OR 展開複合 index
前提知識

基礎編では ORDER BY published_at DESC, article_id DESC のようにすべて同じ向きのソートに対して、行値(タプル)比較 (a, b) < (x, y) でカーソルを表現しました。
実務のソートは「優先度 DESC、同点なら期限 ASC、それも同じなら id DESC」のように向きが混在することが多く、このとき単純な行値比較は使えません(タプル比較の辞書式順序は全列同じ向きを前提とするため、ASC が混ざると ORDER BY と意味が一致しなくなる)。
解決策は OR 展開——カーソル位置の「次の行」になる条件を桁上がりの考え方で列ごとに分解します。index は (priority DESC, due_date ASC, task_id DESC) と向きまで含めて並びを揃えると、ソート工程ゼロ + シーク1回のキーセットが成立します。

-- ORDER BY priority DESC, due_date ASC, task_id DESC
-- 前ページ最終: (priority=2, due_date='2026-06-07', task_id=4)

-- ✓ OR 展開: 桁上がりの順に列ごとの条件を並べる
WHERE priority < 2                                              -- ① 優先度が下がる
   OR (priority = 2 AND due_date > DATE '2026-06-07')              -- ② 同優先度・期限が後ろ(ASC なので >)
   OR (priority = 2 AND due_date = DATE '2026-06-07' AND task_id < 4)   -- ③ 全同じなら id 小(DESC なので <)
核心:OR 展開の各分岐は「ORDER BY の何列目で順位がついたか」を表します。DESC の列は「カーソル値より小さい」、ASC の列は「カーソル値より大きい」を不等号として選ぶ——ORDER BY の各列の向きと、不等号の向きが対応します。複合 index も同じ向きで並べると、index の「次のキー」が「次のページの先頭」とそのまま一致します。
問題

タスク一覧を ORDER BY priority DESC, due_date ASC, task_id DESC の順で2件/ページ表示します。前ページ最終行のキーは (priority, due_date, task_id) = (2, '2026-06-07', 4)OFFSET を使わず単純な行値比較も使えない並びで、次のページの2件を取得してください。index 定義も含めて記述すること。出力列は task_id, title, priority, due_date

使用テーブル
- tasks(実体100万行)
task_idtitleprioritydue_date
1緊急対応A32026-06-10
2緊急対応B32026-06-15
3通常対応A22026-06-05
4通常対応B22026-06-07
5通常対応C22026-06-07
6通常対応D22026-06-09
7通常対応E22026-06-12
8低優先A12026-06-20
期待出力
task_idtitleprioritydue_date
6通常対応D22026-06-09
7通常対応E22026-06-12
QUESTION 9

LATERAL JOIN — 各グループの上位N件を index に直接シーク

LATERAL各グループの上位N件Semi Join 拡張index シーク
前提知識

基礎編では EXISTS で「ある/ない」の存在判定を学びました。応用は LATERAL JOIN——「左の各行に対して、右のサブクエリをその行の値を使って実行する」結合です。相関サブクエリを JOIN 形式に書き直したもの、と見ると本質が掴めます。
本領を発揮するのは「各グループの上位N件」(top-N per group)。基礎編の ROW_NUMBER + WHERE rn <= N は全行に番号を付けてから絞るので、グループ内の行数が膨大だと不利。LATERAL なら各グループにつき index を N 件シークするだけで済みます——グループ数 ≪ グループ内行数 のとき威力が桁違いです。

-- △ 全行に番号を付けてから上位3件: 1000万行に rn を付ける必要
WITH ranked AS (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sold_at DESC) AS rn
  FROM products
) SELECT * FROM ranked WHERE rn <= 3;

-- ✓ カテゴリ数 × 3 回の index シークだけで済む
SELECT c.*, p.*
FROM   categories c
JOIN LATERAL (
  SELECT ... FROM products WHERE category_id = c.cat_id    -- c の値を参照
  ORDER BY sold_at DESC LIMIT 3
) p ON TRUE;
核心:LATERAL は「左の行の値が右のサブクエリで使える」JOIN。各行に対して右辺の LIMIT 3 が独立に評価され、index がグループキー+ソートキーで張られていれば、シーク + 3件読みで終わる——基礎編の EXISTS(1件で打ち切り=Semi Join)を「N件まで読む」に拡張した道具と理解できます。
問題

EC サイトの管理画面で「各カテゴリの最新販売3商品」を一覧表示します。categories は10カテゴリ、products は1000万行で (category_id, sold_at DESC, product_id DESC) に index があります。ROW_NUMBER で全行に番号を付ける書き方は不可——各カテゴリにつきindex を3件シークするだけの形で書いてください。出力列は cat_id, category, product_id, product_name, sold_at(cat_id 昇順、その中で sold_at 降順)。

使用テーブル
- categories(10行・本問では3行に抜粋)
cat_idname
1Electronics
2Books
3Apparel
- products(実体1000万行・cat 1, 2 に4件、cat 3 に2件を抜粋)
product_idcategory_idnamesold_at
1011イヤホン2026-06-10
1021スピーカー2026-06-09
1031ケーブル2026-06-08
1041充電器2026-06-01
2012SQL本2026-06-12
2022小説A2026-06-11
2032図鑑2026-06-05
2042雑誌2026-06-02
3013Tシャツ2026-06-07
3023帽子2026-06-03
期待出力
cat_idcategoryproduct_idproduct_namesold_at
1Electronics101イヤホン2026-06-10
1Electronics102スピーカー2026-06-09
1Electronics103ケーブル2026-06-08
2Books201SQL本2026-06-12
2Books202小説A2026-06-11
2Books203図鑑2026-06-05
3Apparel301Tシャツ2026-06-07
3Apparel302帽子2026-06-03
QUESTION 10

総合問題 — サポート管理ダッシュボードに応用4技を1本のクエリで

総合式 indexLATERAL複合キーセット部分 index
前提知識

応用編の総まとめとして、実務頻出のサポートチケット管理ダッシュボードを組み立てます。「特定顧客の未対応チケットを優先度順に、各チケットの最新応答付きで、カーソル方式で取得する」——応用編の4技 + 基礎編の部分インデックスがきれいに役割分担します。

① 部分インデックスで status='open' の少数派だけを索引化、② 式インデックスでメール検索を大文字小文字無視 + Sargable、③ DESC/ASC 混在キーセットで OR 展開のページング、④ LATERAL JOIN で各チケットの最新応答1件を index に直接シーク。精神(「並べて隣を見る」処理は ETL に追い出さずクエリで完結)も、この LATERAL 構造に直結しています。

-- 全体像(疑似コード)
SELECT ..., lr.*
FROM   tickets t
JOIN LATERAL (...) lr ON TRUE                          -- ④ 各チケットの最新応答1件
WHERE  t.status = 'open'                                    -- ① 部分 index 含意
  AND  LOWER(t.customer_email) = LOWER(?)                  -- ② 式 index で大文字小文字無視
  AND  (priority < ? OR ...)                                -- ③ DESC/ASC 混在のカーソル OR 展開
ORDER BY priority DESC, due_date ASC, ticket_id DESC LIMIT ?;
狙い:応用4技は単独でも強力ですが、実務クエリでは複数の論点が重なる場所でこそ真価が出ます。同じテーブルに対し絞り込み(式 index)と並び替え(複合キーセット)が同時にかかり、関連テーブルからサマリ(LATERAL)も取る——この3層の役割分担を1本に組み込む設計感覚を身につけるのが本問のゴールです。
問題

SaaS のカスタマーサポート画面「特定顧客の未対応チケット一覧(優先度順 + 最新応答)」を作ります。要件:

  • 対象は customer_email'Alice@Example.com'(大文字小文字無視)に一致する顧客の status = 'open' のチケットだけ
  • 並びは priority DESC, due_date ASC, ticket_id DESC(応用同形・方向混在
  • 各チケットに対し最新の応答1件replied_at DESC, reply_id DESC)を結合表示
  • 前ページ最終のカーソル (priority, due_date, ticket_id) = (2, '2026-06-09', 104) から2件/ページで次ページを取得
  • OFFSET 不使用、必要な index も合わせて提示(部分 index + 式 index + 並び用 index)
使用テーブル
- tickets(実体100万行・'open' は約3%)
ticket_idcustomer_emailsubjectprioritydue_datestatus
101alice@example.com注文未着32026-06-05open
102Alice@Example.com返金希望22026-06-08open
103alice@example.comパスワード再設定22026-06-08open
104alice@example.comキャンセル依頼22026-06-09open
105alice@example.com配送遅延12026-06-15open
106bob@example.com商品違い32026-06-06open
107ALICE@example.com商品破損22026-06-11open
108alice@example.com(完了済み)22026-06-04closed
- replies
reply_idticket_idreplied_atbody
11012026-06-06 09:00物流から回答待ち
21022026-06-08 14:00返金処理を開始します
31032026-06-09 10:00再設定リンク送付済
41042026-06-10 11:00キャンセル受付
51052026-06-15 09:00配達予定: 06-16
61072026-06-11 16:00交換手続き開始
期待出力
ticket_idsubjectprioritydue_datelast_replied_atlast_message
107商品破損22026-06-112026-06-11 16:00交換手続き開始
105配送遅延12026-06-152026-06-15 09:00配達予定: 06-16