SQL パフォーマンス最適化 — LATERAL Top-N・式INDEXの応用

応用LATERAL Top-NNOT IN と NULL式インデックス複合index列順 / INCLUDEIndex-Only ScanPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 1

グループごとの上位N件 — LATERAL JOIN で「行数ぶんのソート」を消す

LATERALTop-N per groupウィンドウ関数index相性
前提知識

「各ユーザーの最新3件の注文」のような グループごとの上位N件 は実務で頻出ですが、書き方で性能が桁違いに変わります。代表的な3つの形のうち、データ量と index 設計に応じて選び分けられるかが肝です。

-- [NG-1] 相関サブクエリ:外側の行ごとに ORDER BY + LIMIT が走る(N+1の派生)
SELECT u.user_id,
  (SELECT array_agg(o.order_id) FROM (
     SELECT order_id FROM orders
     WHERE user_id = u.user_id ORDER BY ordered_at DESC LIMIT 3) o)
FROM users u;

-- [NG-2] ウィンドウ関数:orders 全行を読んでから PARTITION ソート → rn<=3 でフィルタ
SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ordered_at DESC) AS rn
  FROM orders) x WHERE rn <= 3;

-- ✓ LATERAL:各ユーザーで index (user_id, ordered_at DESC) の先頭3件だけ読む
SELECT u.user_id, o.order_id, o.ordered_at
FROM users u
CROSS JOIN LATERAL (
  SELECT order_id, ordered_at FROM orders
  WHERE user_id = u.user_id ORDER BY ordered_at DESC LIMIT 3) o;
選び方:ユーザー数が少なく orders が巨大なら LATERAL(index で先頭から3件×ユーザー数)、全行を走査する集計と合わせるなら ウィンドウ関数。「相関 = 常に悪」ではなく、index の物理配置と LIMIT の組み合わせで評価します。
問題

users(10万行)と orders(5000万行、index (user_id, ordered_at DESC) あり)から、各ユーザーの最新3件の注文を取得してください。ただし「orders を全件走査せず、ユーザーごとに高々3行ずつしか読まない」形で書くこと。出力列は user_id, name, order_id, ordered_at(user_id 昇順、同一 user 内は ordered_at 降順)。注文0件のユーザーは結果から除外して構いません。

使用テーブル
- users
user_idname
1Sato
2Suzuki
3Tanaka
- orders(index: user_id, ordered_at DESC)
order_iduser_idordered_at
10112026-05-01
10212026-05-03
10312026-05-05
10412026-05-07
20122026-04-20
20222026-05-02
30132026-05-04
30232026-05-06
30332026-05-08
30432026-05-09
30532026-05-10
期待出力
user_idnameorder_idordered_at
1Sato1042026-05-07
1Sato1032026-05-05
1Sato1022026-05-03
2Suzuki2022026-05-02
2Suzuki2012026-04-20
3Tanaka3052026-05-10
3Tanaka3042026-05-09
3Tanaka3032026-05-08
QUESTION 2

NOT IN と NULL の罠 — 副問合せに1行混じれば「全件消える」

NOT INNULLNOT EXISTSアンチジョイン
前提知識

基礎編で扱った 三値論理 の最も危険な発露が、NOT IN + NULL を含む副問合せです。x NOT IN (a, b, NULL) は内部的に x <> a AND x <> b AND x <> NULL。最後の比較がUNKNOWNになるため、AND 全体が TRUE になることが永遠にない——結果は必ず空です。クエリは成功し、ログにも何も出ません。

-- ✗ cancellations.user_id に NULL が1行でもあると、結果は常に空
SELECT u.* FROM users u
WHERE u.user_id NOT IN (SELECT user_id FROM cancellations);

-- ✓ NOT EXISTS:相関で「対応する行が存在しない」を判定。NULL に強い
SELECT u.* FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM cancellations c WHERE c.user_id = u.user_id);

-- ✓ アンチジョイン:LEFT JOIN + IS NULL(プランナにより NOT EXISTS と同等になることが多い)
SELECT u.* FROM users u
LEFT JOIN cancellations c ON c.user_id = u.user_id
WHERE c.user_id IS NULL;
性能面でも:PostgreSQL は NOT EXISTSLEFT JOIN ... IS NULLAnti Join に変換します。一方 NOT IN は NULL 安全性のために毎行 NULL チェックが必要で、最適化の手数が落ちる傾向。正しさと速さが同じ方向に向きます。
問題

退会記録テーブル cancellations(一部の行で user_id が NULL のことがある)を用いて、退会していないユーザーを抽出してください。ただし cancellations.user_id に NULL が混じっていても正しく動き、かつ Anti Join に展開される形で書くこと。出力列は user_id, name(user_id 昇順)。

使用テーブル
- users
user_idname
1Sato
2Suzuki
3Tanaka
4Ito
5Kato
- cancellations
cancel_iduser_id
912
924
93NULL
期待出力
user_idname
1Sato
3Tanaka
5Kato
QUESTION 3

Sargable と式インデックス — 列に関数をかけても遅くしない設計

Sargable式インデックス関数索引Index Scan
前提知識

Sargable(Search ARGument ABLE)とは、WHERE 条件が index 走査の引数として使える形であることを指します。原則は単純で、「列を関数で包むと index が使えない」。WHERE LOWER(email) = 'a@x.com' と書いた瞬間、email の通常 index は無効化されます——index は 素の email 値 でソートされており、LOWER した値 でソートされた構造ではないからです。

-- ✗ 列を関数で包む → 通常の (email) index は使えず Seq Scan
WHERE LOWER(email) = 'user@example.com'

-- ✗ 暗黙の型変換も列を包む扱いになる(CAST が裏で挿入される)
WHERE created_at::date = '2026-05-01'
WHERE phone_number = 8012345678   -- phone_number が VARCHAR の場合

-- [OK-A] 式 index:式そのものを索引化(クエリの式形と一致させる)
CREATE INDEX idx_users_email_lower ON users (LOWER(email));

-- [OK-B] クエリを書き換えて列を裸にする(半開区間など)
WHERE created_at >= '2026-05-01' AND created_at < '2026-05-02'
判定法:クエリの WHERE 式と index の式が 文字通り同じ形 でなければプランナは使えません。LOWER(email) の式 index は LOWER(email) = ? には効きますが email ILIKE ? には効かない——「式の形が一致する」までが Sargableです。
問題

ユーザー登録時の大文字小文字を無視したメールアドレス検索を実装してください。ストレージは email 列(VARCHAR、混在ケースで登録される可能性あり)。ユーザー数100万件、検索は1秒間に100回。クエリと index の両方を提示し、検索クエリは email = 'User@Example.COM' のような任意のケースの入力に対して動くこと。出力列は user_id, email, name

使用テーブル
- users(100万行、index 検討中)
user_idemailname
1sato@example.comSato
2Suzuki@Example.comSuzuki
3TANAKA@example.comTanaka
4ito@example.comIto
......(100万件)...
期待出力
user_idemailname
2Suzuki@Example.comSuzuki
QUESTION 4

OFFSETの罠とKeysetページネーション — 100ページ目を1ページ目と同じ速さで

OFFSETKeysetページングseek法複合タプル比較
前提知識

ページネーションを OFFSET n LIMIT k で書くのは直感的ですが、OFFSET は「読み捨て」です。100ページ目(OFFSET 9900) を取るために、DB は 条件を満たす行を9900件読んでから捨てて、ようやく次の100件を返します。ページが深くなるほど線形に遅くなる——これが Deep Pagination 問題です。

-- ✗ OFFSET は「読み飛ばし」ではなく「読んで捨てる」
SELECT * FROM articles
ORDER BY created_at DESC, id DESC
OFFSET 9900 LIMIT 100;
-- 実行コスト: 9900 + 100 = 10000 行を読む(しかも整列後に)

-- ✓ Keyset(seek法):「前ページの最後のキー」より小さい行を index で seek
SELECT * FROM articles
WHERE (created_at, id) < ('2026-05-04 10:00', 501)  -- ← タプル比較で「次のページ」を表現
ORDER BY created_at DESC, id DESC
LIMIT 100;
-- 実行コスト: 何ページ目でも 100 行(index 上で先頭から seek するだけ)
タプル比較の意味:(a, b) < (X, Y)a < X OR (a = X AND b < Y) と等価です。同じ created_at の行が複数あっても、(created_at, id) の辞書順で一意な「次の位置」が決まる——これが Keyset の核心です。
問題

articles(100万行、複合 index (created_at DESC, id DESC) あり)を 新しい順 にページングします。前のページの最後の記事(created_at='2026-05-04 10:00', id=501) だったとき、次の100件を取得するクエリを書いてください。OFFSET を使わず、何ページ目でも同じ性能で動く形にすること。出力列は id, title, created_at

使用テーブル
- articles(100万行、index: created_at DESC, id DESC)
idtitlecreated_at
510新着A2026-05-04 15:00
505新着B2026-05-04 12:00
501記事X2026-05-04 10:00
499記事Y2026-05-04 10:00
498記事Z2026-05-04 09:00
490過去A2026-05-03 18:00
......(100万件)...
期待出力
idtitlecreated_at
499記事Y2026-05-04 10:00
498記事Z2026-05-04 09:00
490過去A2026-05-03 18:00
......(最大100件)...
QUESTION 5

複合インデックスの列順とINCLUDE — Index-Only Scanで「ヒープを読まない」

複合index列順INCLUDEIndex-Only Scan
前提知識

複合インデックスの設計には3つの黄金律があります。(1) 等値で絞る列を先頭、(2) 範囲・ソート列を次、(3) 出力に必要な列は INCLUDE。これが揃うと、プランナはヒープ(テーブル本体)を一切読まず index だけで結果を組み立てられます——これが Index-Only Scan。実務でのI/O削減効果は桁が変わります。

-- 想定クエリ: 「pending タスクのうち、ある日付以降の title 一覧(新しい順、100件)」
SELECT title FROM tasks
WHERE status = 'pending' AND created_at >= '2026-05-01'
ORDER BY created_at DESC LIMIT 100;

-- [NG-A] 列順が逆:(created_at, status) では status の絞り込みが遅れる
CREATE INDEX ... ON tasks (created_at, status);

-- [NG-B] title を含まない:index で絞れてもヒープを毎回見に行く(Index Scan 止まり)
CREATE INDEX ... ON tasks (status, created_at);

-- ✓ 等値→範囲・ソートの列順 + INCLUDE で出力列を index に同居
CREATE INDEX ... ON tasks (status, created_at DESC) INCLUDE (title);
INCLUDE と キー列の違い:INCLUDE 列はキーとしては使われない(ソート・検索の対象外)が、index のリーフに値だけ同梱される。「出力に必要だが絞り込みには使わない列」を入れるための専用機能です(PostgreSQL 11+)。
問題

巨大な tasks(5000万行、status は'pending'が約1%)テーブルに対し、次のクエリを Index-Only Scan で実行できる複合インデックスを1本だけ設計してください。クエリは変更不可。EXPLAIN で Index Only Scan と表示され、Heap Fetches が 0 になる形を目指します。

SELECT title FROM tasks
WHERE  status = 'pending' AND created_at >= '2026-05-01'
ORDER BY created_at DESC
LIMIT 100;
使用テーブル
- tasks(5000万行 / status 偏り: 'pending'≈1%, 'done'≈99%)
task_idstatuscreated_attitleassignee
1done2026-04-01......
2pending2026-05-03請求書確認Sato
3pending2026-05-02在庫棚卸Suzuki
4done2026-05-01......
5pending2026-05-05ABテスト集計Tanaka
期待出力
Limit  (cost=...) (actual rows=100 loops=1)
  -> Index Only Scan using idx_tasks_pending_covering on tasks
        Index Cond: ((status = 'pending') AND (created_at >= '2026-05-01'))
        Heap Fetches: 0 ← ヒープアクセスゼロ
Buffers: shared hit=N read=M (index ページのみ)