SQL パフォーマンス最適化 — Anti Join・ソート消去の応用

応用期間条件付き Anti JoinFILTERピボット + 比率複合indexとソート消去ブランチ Top-NHAVING × FILTERPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

期間条件付き Anti Join — 「直近90日に参加がない会員」を NOT EXISTS で正しく速く

NOT EXISTS相関条件部分一致除外複合index活用
前提知識

NOT EXISTS は「対応する行が一度も存在しない」対象を取得できます。実務ではさらに、「直近90日に参加がない(= 休眠)」のような期間条件付きの除外が頻出します。鍵は追加条件をサブクエリの内側に書くこと——「90日以内の参加が存在しない」を素直に表現でき、プランナは Hash Anti Join + 複合 index による絞り込みを選べます。

-- ✗ NOT IN + 期間条件: NULL が混ざると結果0行
WHERE member_id NOT IN (SELECT member_id FROM event_entries
                     WHERE entered_at >= DATE '2026-03-14')

-- ✓ 条件はサブクエリ内側へ: NULL 安全 + Anti Join
WHERE NOT EXISTS (SELECT 1 FROM event_entries e
                  WHERE e.member_id = m.member_id
                    AND e.entered_at >= DATE '2026-03-14')
「存在しない」の範囲を式で宣言する:NOT EXISTS の中の WHERE は「何が存在しなければ通過か」の定義そのものです。member_id の一致 AND 期間内を内側に書けば「期間内の参加が無い人」、外側に書こうとすると(除外なのに)意味が壊れます。除外条件の粒度はすべてサブクエリ内側で完結させるのが原則です。
問題

members テーブル(実体10万行)と event_entries テーブル(実体300万行・member_idNULL 許容でゲスト参加を表す)があります。status が active で、かつ 2026-03-14 以降に一度も参加していない「休眠会員」を、NULL に壊されず Anti Join に最適化される形で取得してください。出力列は member_id, name(member_id 昇順)。あわせて、サブクエリ側の照合を速くする複合 index も定義してください。

使用テーブル
- members
member_idnamestatus
1Aokiactive
2Babaactive
3Chibainactive
4Doiactive
5Endoactive
- event_entries(member_id は NULL 許容)
entry_idmember_identered_at
122026-05-10
242026-01-20
3NULL2026-05-01
452026-06-01
522026-02-11
期待出力
member_idname
1Aoki
4Doi
QUESTION 7

FILTER × GROUP BY のピボット集計 — カテゴリ別の件数・売上・完了率を1走査で

FILTER句クロス集計NULLIF / COALESCE走査1回
前提知識

基礎編2では FILTER で「全体の条件別集計」を1走査にまとめました。応用編では GROUP BY と組み合わせたクロス集計(ピボット)に進みます。さらに実務レポートで必ず登場する「比率」——完了率・解約率など——も、集約値同士の式として同じ1走査の中で計算できます。落とし穴は2つ:整数同士の除算は0に切り捨てられること、該当行ゼロのグループで SUM が NULL になることです。

-- ✗ 整数除算: 2 / 3 = 0(小数が消える)/ 分母0なら division by zero
COUNT(*) FILTER (WHERE …) / COUNT(*)

-- ✓ 100.0 を掛けて numeric 化 + NULLIF で分母0を NULL に逃がす
ROUND(100.0 * COUNT(*) FILTER (WHERE …) / NULLIF(COUNT(*), 0), 1)
NULLIF(x, 0) は「ゼロ除算ガード」の定石:NULLIF(a, b) は a = b なら NULL、それ以外は a を返します。分母を NULLIF(分母, 0) で包めば、分母0のとき式全体が NULL になりエラーで落ちません。同様に、該当行ゼロで NULL になる SUM(…) FILTERCOALESCE(…, 0) で0に戻すのがレポートの作法です。
問題

orders テーブル(実体500万行)から、1回の走査だけで category ごとに次の4つを集計してください:total_cnt(全件数)、completed_cnt(completed の件数)、completed_amt(completed の売上合計・該当なしは 0)、completed_rate(完了率% を小数1桁・分母0でも落ちない式)。出力は category 昇順。

使用テーブル
- orders
order_idcategorystatusamount
1bookcompleted1200
2bookcancelled800
3foodcompleted500
4foodcompleted700
5foodcompleted300
6toypending900
7bookcompleted2000
期待出力
categorytotal_cntcompleted_cntcompleted_amtcompleted_rate
book32320066.7
food331500100.0
toy1000.0
QUESTION 8

複合 index で WHERE + ORDER BY を一撃に — キーセットページネーションまで

複合indexORDER BYkeyset paginationSortノード消滅
前提知識

基礎編2では単一列 index で ORDER BY のソートを消しました。実務の一覧画面はほぼ必ず「絞り込み + 並べ替え + ページ送り」のセットです。WHERE category = 'tech' ORDER BY published_at DESC LIMIT 3 のような形は、複合 index (category, published_at DESC) で「等値で区間に飛ぶ → 区間内は整列済み → N件で打ち切り」が一撃になります。さらにページ送りは OFFSET でなくキーセット方式(前ページ最終行の値を境界に使う)にすると、深いページでも速度が落ちません。

-- ✗ OFFSET: 捨てる行も全部読む(100ページ目 = 297行を読んで捨て、3行返す)
ORDER BY published_at DESC LIMIT 3 OFFSET 297

-- ✓ keyset: 前ページ最終行の値から index で続きに飛ぶ
WHERE category = 'tech' AND published_at < '2026-06-02'
ORDER BY published_at DESC LIMIT 3
列順の原則「等値 → ソート列」:複合 index 内のエントリは第1列 → 第2列の優先順で整列しています。category(等値)を先頭に置くと、tech のエントリが連続区間にまとまり、その区間内は published_at DESC で整列済み。逆順の (published_at DESC, category) だと tech が index 全域に散らばり、ソートは消えても不要カテゴリの読み飛ばしが発生します。
問題

articles テーブル(実体200万行)から、category = 'tech' の最新記事3件(1ページ目)を published_at 降順で取得してください。あわせて、(1) このクエリのソート工程を消す複合インデックス、(2) OFFSET を使わない2ページ目のクエリ(1ページ目の最終行が published_at = '2026-06-02' だったとする)も書いてください。出力列は article_id, title, published_at

使用テーブル
- articles(ヒープ上は category でも日付でも並んでいない)
article_idcategorytitlepublished_at
1life朝の習慣術2026-05-01
2techインデックス設計2026-06-09
3life休日の整え方2026-04-12
4techJOINの基礎2026-06-11
5life睡眠の科学2026-03-30
6techソートとメモリ2026-06-02
7techCTE活用2026-05-20
8tech統計情報入門2026-04-25
期待出力

期待する出力①:

article_idtitlepublished_at
4JOINの基礎2026-06-11
2インデックス設計2026-06-09
6ソートとメモリ2026-06-02

期待する出力②:

article_idtitlepublished_at
7CTE活用2026-05-20
8統計情報入門2026-04-25
QUESTION 9

月別テーブル横断の最新N件 — ブランチごとの Top-N と Merge Append

UNION ALLブランチ内 LIMITMerge Append全件Sort回避
前提知識

基礎編2では「排他ソースの統合は UNION ALL」を学びました。応用編はその先——統合した結果から最新N件だけ欲しいケースです。素朴に書くと「全部つなげてから全体をソート」になりますが、各テーブルに created_at の index があるなら、各ブランチで先に Top-N を取り(最大 N×ブランチ数 行)、外側で並べ直して N 件にすれば、全件ソートを丸ごと回避できます。PostgreSQL は条件が揃えば Merge Append(整列済みストリームのマージ)まで使ってくれます。

-- ✗ 全件 Append → 600万行を Sort → 3件
Limit → Sort (600万行) → Append → Seq Scan ×2

-- ✓ 各ブランチ index で3件 → 高々6行を並べて3件
Limit → Sort/Merge (≤6行) → Append
        ├─ Limit → Index Scan Backward (logs_202605)
        └─ Limit → Index Scan Backward (logs_202606)
正しさの根拠:「全体の上位3件」は必ず「各ブランチの上位3件」の中に含まれます(あるブランチで4位以下の行が全体の3位以内に入ることはあり得ない)。だからブランチ内 LIMIT 3 は結果を変えずにソート対象を6行へ圧縮できます。UNION ALL の各ブランチを括弧で囲めば、ブランチごとに ORDER BY / LIMIT を書けます。
問題

ログが月別テーブル logs_202605 / logs_202606(実体それぞれ300万行・ログIDの体系は排他・各テーブルに created_at の降順 index あり)に分かれています。2テーブルを横断した最新ログ3件を、全件ソートを発生させない形で取得してください。出力列は log_id, message, created_at(created_at 降順)。

使用テーブル
- logs_202605(created_at DESC index あり)
log_idmessagecreated_at
101deploy ok2026-05-28
102cache warm2026-05-30
103batch done2026-05-15
- logs_202606(created_at DESC index あり)
log_idmessagecreated_at
201deploy ok2026-06-10
202index rebuilt2026-06-03
203backup done2026-06-11
期待出力
log_idmessagecreated_at
203backup done2026-06-11
201deploy ok2026-06-10
202index rebuilt2026-06-03
QUESTION 10

総合問題 — 月別統合 + NULL安全除外 + FILTERピボット + HAVING のレポートSQL

総合UNION ALLNOT EXISTSHAVING × FILTER
前提知識

応用編2の総仕上げです。月次売上レポートを、今回学んだ部品のフル合成で組み立てます:① 月別テーブルの統合は UNION ALL② ブロック済みアカウントの除外は NOT EXISTS(NULL 安全 + Anti Join)、③ region × status のピボットと金額は FILTER + COALESCE、そして新登場の ④ グループの選別は HAVING——集約値(FILTER 付き!)を条件に使えます。

-- 組み立ての型(データの流れ = 実行順序)
FROM ( ... UNION ALL ... ) s        -- ① 統合(Append のみ)
WHERE NOT EXISTS ( ... )            -- ② 行の除外(集計前・上流で)
GROUP BY ...                        -- ③ グループ化 + FILTER 集計
HAVING 集約 FILTER (...) >= n       -- ④ グループの選別(集計後)
ORDER BY 集約列 DESC
WHERE と HAVING の住み分けが総仕上げの肝:ブロック除外は行単位の条件なので WHERE(集計前に減らすほど後段が軽い)、「paid が2件以上の region だけ」は集約後にしか分からない条件なので HAVING。「その条件は1行を見て判定できるか?」が振り分けの問いです。
問題

月別売上 sales_202605 / sales_202606(ID体系は排他・実体それぞれ数百万行)を統合し、ブロック済みアカウントの売上を除外した上で、region ごとに paid 件数・paid 売上合計・refund 件数を集計してください。ただし paid が2件以上の region のみを、paid 売上合計の降順で出力します。blocked_accounts.customer_idNULL 許容(審査中の行が混ざる)です。出力列は region, paid_cnt, paid_amt, refund_cnt

使用テーブル
- sales_202605
sale_idregionstatusamountcustomer_id
1eastpaid100011
2westpaid150012
3eastrefund40013
4northpaid30015
- sales_202606
sale_idregionstatusamountcustomer_id
11eastpaid200011
12westrefund700999
13eastpaid80014
14westpaid120012
- blocked_accounts(customer_id は NULL 許容)
block_idcustomer_id
1999
2NULL
期待出力
regionpaid_cntpaid_amtrefund_cnt
east338001
west227000