式インデックス — 「列を関数で包む」要件を Sargable に戻す
基礎編では「リテラル側を列の型に揃えろ」と学びました。しかし実務には列を関数で包まないと表現できない要件があります。代表例が大文字小文字無視の検索——アプリ側から Alice@Example.COM と alice@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 と完全一致
users テーブル(実体100万行)から、メールアドレスを大文字小文字を区別せず 'Alice@Example.COM' に一致する会員を取得してください。アプリ側からの入力は表記揺れがあり、テーブル側のデータも歴史的経緯で大文字小文字が混在しています。関数包みのまま、しかも index 等価検索で読む形にすること(必要な index も同時に提示)。出力列は user_id, email(user_id 昇順)。
| user_id | |
|---|---|
| 1 | alice@example.com |
| 2 | bob@example.com |
| 3 | ALICE@example.com |
| 4 | charlie@example.com |
| 5 | aLiCe@Example.Com |
| user_id | |
|---|---|
| 1 | alice@example.com |
| 3 | ALICE@example.com |
| 5 | aLiCe@Example.Com |
LAG / LEAD と差分計算 — 自己 JOIN ではなくウィンドウで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 昇順)。
| product_id | recorded_at | price |
|---|---|---|
| 1 | 2026-06-01 | 1000 |
| 1 | 2026-06-02 | 1100 |
| 1 | 2026-06-03 | 1050 |
| 2 | 2026-06-01 | 500 |
| 2 | 2026-06-02 | 520 |
| 2 | 2026-06-03 | 510 |
| product_id | recorded_at | price | diff |
|---|---|---|---|
| 1 | 2026-06-01 | 1000 | NULL |
| 1 | 2026-06-02 | 1100 | 100 |
| 1 | 2026-06-03 | 1050 | -50 |
| 2 | 2026-06-01 | 500 | NULL |
| 2 | 2026-06-02 | 520 | 20 |
| 2 | 2026-06-03 | 510 | -10 |
DESC/ASC 混在のキーセット — 行値比較が使えない並びを OR 展開で
基礎編では 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 なので <)
タスク一覧を 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。
| task_id | title | priority | due_date |
|---|---|---|---|
| 1 | 緊急対応A | 3 | 2026-06-10 |
| 2 | 緊急対応B | 3 | 2026-06-15 |
| 3 | 通常対応A | 2 | 2026-06-05 |
| 4 | 通常対応B | 2 | 2026-06-07 |
| 5 | 通常対応C | 2 | 2026-06-07 |
| 6 | 通常対応D | 2 | 2026-06-09 |
| 7 | 通常対応E | 2 | 2026-06-12 |
| 8 | 低優先A | 1 | 2026-06-20 |
| task_id | title | priority | due_date |
|---|---|---|---|
| 6 | 通常対応D | 2 | 2026-06-09 |
| 7 | 通常対応E | 2 | 2026-06-12 |
LATERAL JOIN — 各グループの上位N件を 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;
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 降順)。
| cat_id | name |
|---|---|
| 1 | Electronics |
| 2 | Books |
| 3 | Apparel |
| product_id | category_id | name | sold_at |
|---|---|---|---|
| 101 | 1 | イヤホン | 2026-06-10 |
| 102 | 1 | スピーカー | 2026-06-09 |
| 103 | 1 | ケーブル | 2026-06-08 |
| 104 | 1 | 充電器 | 2026-06-01 |
| 201 | 2 | SQL本 | 2026-06-12 |
| 202 | 2 | 小説A | 2026-06-11 |
| 203 | 2 | 図鑑 | 2026-06-05 |
| 204 | 2 | 雑誌 | 2026-06-02 |
| 301 | 3 | Tシャツ | 2026-06-07 |
| 302 | 3 | 帽子 | 2026-06-03 |
| cat_id | category | product_id | product_name | sold_at |
|---|---|---|---|---|
| 1 | Electronics | 101 | イヤホン | 2026-06-10 |
| 1 | Electronics | 102 | スピーカー | 2026-06-09 |
| 1 | Electronics | 103 | ケーブル | 2026-06-08 |
| 2 | Books | 201 | SQL本 | 2026-06-12 |
| 2 | Books | 202 | 小説A | 2026-06-11 |
| 2 | Books | 203 | 図鑑 | 2026-06-05 |
| 3 | Apparel | 301 | Tシャツ | 2026-06-07 |
| 3 | Apparel | 302 | 帽子 | 2026-06-03 |
総合問題 — サポート管理ダッシュボードに応用4技を1本のクエリで
応用編の総まとめとして、実務頻出のサポートチケット管理ダッシュボードを組み立てます。「特定顧客の未対応チケットを優先度順に、各チケットの最新応答付きで、カーソル方式で取得する」——応用編の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 ?;
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)
| ticket_id | customer_email | subject | priority | due_date | status |
|---|---|---|---|---|---|
| 101 | alice@example.com | 注文未着 | 3 | 2026-06-05 | open |
| 102 | Alice@Example.com | 返金希望 | 2 | 2026-06-08 | open |
| 103 | alice@example.com | パスワード再設定 | 2 | 2026-06-08 | open |
| 104 | alice@example.com | キャンセル依頼 | 2 | 2026-06-09 | open |
| 105 | alice@example.com | 配送遅延 | 1 | 2026-06-15 | open |
| 106 | bob@example.com | 商品違い | 3 | 2026-06-06 | open |
| 107 | ALICE@example.com | 商品破損 | 2 | 2026-06-11 | open |
| 108 | alice@example.com | (完了済み) | 2 | 2026-06-04 | closed |
| reply_id | ticket_id | replied_at | body |
|---|---|---|---|
| 1 | 101 | 2026-06-06 09:00 | 物流から回答待ち |
| 2 | 102 | 2026-06-08 14:00 | 返金処理を開始します |
| 3 | 103 | 2026-06-09 10:00 | 再設定リンク送付済 |
| 4 | 104 | 2026-06-10 11:00 | キャンセル受付 |
| 5 | 105 | 2026-06-15 09:00 | 配達予定: 06-16 |
| 6 | 107 | 2026-06-11 16:00 | 交換手続き開始 |
| ticket_id | subject | priority | due_date | last_replied_at | last_message |
|---|---|---|---|---|---|
| 107 | 商品破損 | 2 | 2026-06-11 | 2026-06-11 16:00 | 交換手続き開始 |
| 105 | 配送遅延 | 1 | 2026-06-15 | 2026-06-15 09:00 | 配達予定: 06-16 |