グループごとの上位N件 — LATERAL JOIN で「行数ぶんのソート」を消す
「各ユーザーの最新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;
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件のユーザーは結果から除外して構いません。
| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
| order_id | user_id | ordered_at |
|---|---|---|
| 101 | 1 | 2026-05-01 |
| 102 | 1 | 2026-05-03 |
| 103 | 1 | 2026-05-05 |
| 104 | 1 | 2026-05-07 |
| 201 | 2 | 2026-04-20 |
| 202 | 2 | 2026-05-02 |
| 301 | 3 | 2026-05-04 |
| 302 | 3 | 2026-05-06 |
| 303 | 3 | 2026-05-08 |
| 304 | 3 | 2026-05-09 |
| 305 | 3 | 2026-05-10 |
| user_id | name | order_id | ordered_at |
|---|---|---|---|
| 1 | Sato | 104 | 2026-05-07 |
| 1 | Sato | 103 | 2026-05-05 |
| 1 | Sato | 102 | 2026-05-03 |
| 2 | Suzuki | 202 | 2026-05-02 |
| 2 | Suzuki | 201 | 2026-04-20 |
| 3 | Tanaka | 305 | 2026-05-10 |
| 3 | Tanaka | 304 | 2026-05-09 |
| 3 | Tanaka | 303 | 2026-05-08 |
NOT IN と NULL の罠 — 副問合せに1行混じれば「全件消える」
基礎編で扱った 三値論理 の最も危険な発露が、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;
NOT EXISTS や LEFT JOIN ... IS NULL を Anti Join に変換します。一方 NOT IN は NULL 安全性のために毎行 NULL チェックが必要で、最適化の手数が落ちる傾向。正しさと速さが同じ方向に向きます。退会記録テーブル cancellations(一部の行で user_id が NULL のことがある)を用いて、退会していないユーザーを抽出してください。ただし cancellations.user_id に NULL が混じっていても正しく動き、かつ Anti Join に展開される形で書くこと。出力列は user_id, name(user_id 昇順)。
| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
| 4 | Ito |
| 5 | Kato |
| cancel_id | user_id |
|---|---|
| 91 | 2 |
| 92 | 4 |
| 93 | NULL |
| user_id | name |
|---|---|
| 1 | Sato |
| 3 | Tanaka |
| 5 | Kato |
Sargable と式インデックス — 列に関数をかけても遅くしない設計
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'
LOWER(email) の式 index は LOWER(email) = ? には効きますが email ILIKE ? には効かない——「式の形が一致する」までが Sargableです。ユーザー登録時の大文字小文字を無視したメールアドレス検索を実装してください。ストレージは email 列(VARCHAR、混在ケースで登録される可能性あり)。ユーザー数100万件、検索は1秒間に100回。クエリと index の両方を提示し、検索クエリは email = 'User@Example.COM' のような任意のケースの入力に対して動くこと。出力列は user_id, email, name。
| user_id | name | |
|---|---|---|
| 1 | sato@example.com | Sato |
| 2 | Suzuki@Example.com | Suzuki |
| 3 | TANAKA@example.com | Tanaka |
| 4 | ito@example.com | Ito |
| ... | ...(100万件) | ... |
| user_id | name | |
|---|---|---|
| 2 | Suzuki@Example.com | Suzuki |
OFFSETの罠とKeysetページネーション — 100ページ目を1ページ目と同じ速さで
ページネーションを 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。
| id | title | created_at |
|---|---|---|
| 510 | 新着A | 2026-05-04 15:00 |
| 505 | 新着B | 2026-05-04 12:00 |
| 501 | 記事X | 2026-05-04 10:00 |
| 499 | 記事Y | 2026-05-04 10:00 |
| 498 | 記事Z | 2026-05-04 09:00 |
| 490 | 過去A | 2026-05-03 18:00 |
| ... | ...(100万件) | ... |
| id | title | created_at |
|---|---|---|
| 499 | 記事Y | 2026-05-04 10:00 |
| 498 | 記事Z | 2026-05-04 09:00 |
| 490 | 過去A | 2026-05-03 18:00 |
| ... | ...(最大100件) | ... |
複合インデックスの列順とINCLUDE — Index-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 列はキーとしては使われない(ソート・検索の対象外)が、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;
| task_id | status | created_at | title | assignee |
|---|---|---|---|---|
| 1 | done | 2026-04-01 | ... | ... |
| 2 | pending | 2026-05-03 | 請求書確認 | Sato |
| 3 | pending | 2026-05-02 | 在庫棚卸 | Suzuki |
| 4 | done | 2026-05-01 | ... | ... |
| 5 | pending | 2026-05-05 | ABテスト集計 | 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 ページのみ)