SQL パフォーマンス最適化 — ROW_NUMBER・型変換の基礎

基礎暗黙の型変換ROW_NUMBEROFFSETの罠キーセットページネーションEXISTSと打ち切りPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

暗黙の型変換 — 型が違う比較は index を静かに殺す(か、突然落ちる)

暗黙の型変換キャストSargable型を揃える
前提知識

WHERE 句で列とリテラル(またはパラメータ)の型が一致しないとき、何が起きるかは DBMS 任せです。PostgreSQL は varchar = integer の比較をエラーにし、MySQL や Oracle は列側を暗黙キャストして比較します。列がキャストされた瞬間、それは「列を関数で包んだ」のと同じ——index は候補から外れます(Sargable 違反)。コードに書いていないキャストが挿入されるぶん、関数包みより発見が難しい罠です。

-- ✗ varchar 列 × 数値リテラル → PostgreSQL は即エラー / MySQL等は列を暗黙キャスト
WHERE member_code = 250

-- ✗ エラー回避で「列側」をキャスト → 全行変換 + 数値化できない行で実行時エラー
WHERE member_code::int = 250

-- ✓ 「リテラル側」を列の型に揃える → varchar 同士の素直な比較で index が効く
WHERE member_code = '250'
核心:比較の両辺の型は書き手が揃えるもの。揃えるときは列ではなくリテラル/パラメータ側を寄せます。アプリのバインド変数の型(数値で渡すか文字列で渡すか)も同じルールに従います。
問題

members テーブル(実体100万行、member_code に indexあり)から、会員コードが 250 の会員を取得してください。member_code は旧システム由来の varchar 型で、ほとんどは数字ですが 'A-100' のような旧形式コードも実在します。index が使える形・実行時エラーの危険がない形で書くこと。出力列は member_id, member_code, name

使用テーブル
- members(member_code は varchar・index あり)
member_idmember_codename
1100Sato
2250Suzuki
3A-100Tanaka
430Ito
51200Watanabe
期待出力
member_idmember_codename
2250Suzuki
模範解答コード
SELECT   member_id, member_code, name
FROM     members
WHERE    member_code = '250'   -- リテラル側を列の型(varchar)に揃える → index の等価検索
ORDER BY member_id;

-- ✗ member_code = 250      … PostgreSQL では比較演算子が無くエラー
-- ✗ member_code::int = 250 … 全行キャスト(index不可)+ 'A-100' で実行時エラー

/*
  実行順序:
  1. FROM members               → 対象にする
  2. WHERE member_code = '250'  → キャストなしで idx 等価検索
  3. SELECT                     → 該当行のみ読む
  4. ORDER BY member_id         → 実質コストなし
  */
解説(テーブル変化・ポイント)
SELECT member_id, member_code, name FROM members WHERE member_code = '250' ORDER BY member_id;
LEGEND
データ取得・読込対象
1. 対象テーブル — member_code は varchar(旧形式コードが混在)
FROM members(member_code に index あり)member_code は旧システム由来の文字列型(varchar)。中身はほぼ数字ですが、'A-100' のような旧形式も実在します。「数字に見える文字列」は数値リテラルで検索したくなる罠の入口です。
1 / 5
member_idmember_code(varchar)name
1'100'Sato
2'250'Suzuki
3'A-100'Tanaka
4'30'Ito
5'1200'Watanabe
5行(実テーブルは100万行・member_code に index あり)
学習ポイント
暗黙キャストは「見えない Sargable 違反」:関数包み(既習の DATE_TRUNC(ordered_at) 等)はコードを見れば分かりますが、暗黙キャストはDBMSが勝手に挿入するためコード上は無実に見えます。発見の手がかりは EXPLAIN の出力——条件式に (member_code)::integer のような書いた覚えのないキャストが現れていたら、型不一致を疑ってください。
揃える「向き」が運命を分ける:同じ「型を揃える」でも、列側をキャストすれば index 死亡リテラル側を寄せれば index 健在です。リテラルや定数は実行前に1回変換すれば済みますが、列は全行ぶんの変換が必要だから——「定数の変換は1回で安い、列の変換は全行で高い」と覚えると応用が利きます。バインド変数も同じで、アプリから文字列型(setString 相当)で渡すのが正解です。
実行時エラーは「データ依存」で再現性が低い:::int キャストのエラーは「数値化できない行を実際に読んだ瞬間」に起きます。評価順や走査順はプランナの自由なので、テスト環境('A-100' なし)では動き、本番('A-100' あり)で落ちる——動作確認をすり抜けるタイプの事故です。型の正しさはデータの偶然に頼らず、書き方で保証します。
アンチパターン
エラーをキャスト追加のモグラ叩きで潰す:::int で落ちたら今度は CASE WHEN member_code ~ '^[0-9]+$' THEN member_code::int END = 250 ……と守りのコードを積むほど、クエリは重く・読めなくなります。数値として扱う値ならスキーマで数値型に直すのが根本治療。直せない事情があるなら「文字列として比較する」側に倒すのが次善です。
JOIN キーの型不一致を放置する:a.user_id(int)b.user_code(varchar)ON a.user_id::text = b.user_code で繋ぐ JOIN は、結合のたびに片側全行のキャストを払い続けます。システム統合・データ移行の境界面で最も起きやすく、最も気づかれにくい劣化です。マスタを跨ぐキーは型まで含めて統一するのが設計の仕事です。
実務コラム:型は「速さ」だけでなく「比較の意味」まで変える
文字列の '250''0250'別の値ですが、数値の 250 と 0250 は同じです。さらに文字列の ORDER BY では '1200' < '30'(辞書順)になり、数値の直感と食い違います。つまり型の選択は index の効き目だけでなく、等しさと順序の定義そのものを決めています。「コードや電話番号のように計算しない数字は文字列、計算する数字は数値型」が定石ですが、どちらにせよ比較の両辺で型を揃える原則は変わりません。関連する実務テーマとして、「各グループの最新1件」——相関サブクエリ(Q1)に代わる、ウィンドウ関数という新しい道具を学びます。
QUESTION 7

ウィンドウ関数 ROW_NUMBER — 「グループごとの最新1件」を1回の走査で取る

ROW_NUMBERPARTITION BY最新1件1パス
前提知識

「ユーザーごとの最新ログイン」のようなグループ内の最新1件(greatest-n-per-group)は実務最頻出の要件です。相関サブクエリで logged_at = (SELECT MAX(...)) と書くと、N+1 問題に加えて同時刻タイで2行返る正しさの問題まで抱えます。ウィンドウ関数 ROW_NUMBER() なら、PARTITION BY でグループを区切り、グループ内の並び順で各行に番号を付与——全体を1回の走査(1パス)で処理できます。

-- ✗ 行ごとに MAX を探索(N+1)+ 同時刻タイが2行とも返る
WHERE logged_at = (SELECT MAX(logged_at) FROM logins x
                   WHERE x.user_id = l.user_id)

-- ✓ 区画ごとに番号付け → 外側で rn = 1 だけ残す(必ず1件)
ROW_NUMBER() OVER (PARTITION BY user_id
                    ORDER BY logged_at DESC, login_id DESC) AS rn
核心:GROUP BY が行を「畳む」のに対し、ウィンドウ関数は行を残したままグループ単位の計算結果を各行に付与します。なおウィンドウ関数は WHERE 句に直接書けません(WHERE はウィンドウより先に評価される)——サブクエリや CTE で囲み、外側で絞ります。
問題

logins テーブルから、ユーザーごとの最新ログイン1件(日時・デバイス)を取得してください。同時刻のログインが複数ある場合は login_id が大きい方を採用し、必ずユーザー1人につき1行になることを保証すること。テーブルの走査は1回で済む形で書くこと。出力列は user_id, logged_at, device(user_id 昇順)。

使用テーブル
- logins(user_id=3 は同時刻が2件ある点に注意)
login_iduser_idlogged_atdevice
112026-06-01 09:00mobile
222026-06-01 10:00pc
312026-06-03 12:00pc
432026-06-02 08:00pc
522026-06-04 18:00mobile
632026-06-02 08:00tablet
712026-06-02 20:00tablet
期待出力
user_idlogged_atdevice
12026-06-03 12:00pc
22026-06-04 18:00mobile
32026-06-02 08:00tablet
模範解答コード
SELECT   user_id, logged_at, device
FROM (
  SELECT user_id, logged_at, device,
         ROW_NUMBER() OVER (PARTITION BY user_id                      -- ユーザーごとに区画
                            ORDER BY logged_at DESC, login_id DESC) AS rn  -- タイブレーク必須
  FROM   logins
) t
WHERE    rn = 1   -- ウィンドウ関数は WHERE に直接書けないため外側で絞る
ORDER BY user_id;

-- 別解(PostgreSQL専用・簡潔):
-- SELECT DISTINCT ON (user_id) user_id, logged_at, device
-- FROM logins ORDER BY user_id, logged_at DESC, login_id DESC;

/*
  実行順序:
  1. 内側 FROM logins    → 1回走査
  2. ウィンドウ             → user_id で区画化し ROW_NUMBER を付与
  3. 外側 WHERE rn = 1   → 区画ごとに1行
  4. SELECT            → 3列を確定
  5. ORDER BY user_id  → 昇順に整列
  */
解説(テーブル変化・ポイント)
SELECT user_id, logged_at, device FROM ( SELECT user_id, logged_at, device, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY logged_at DESC, login_id DESC) AS rn FROM logins ) t WHERE rn = 1 ORDER BY user_id;
LEGEND
データ取得・読込対象
1. 対象テーブル — ユーザー3人分のログイン履歴
FROM logins欲しいのは「ユーザー1人につき最新の1行」。user_id=3 は 06-02 08:00 のログインが2件(pc / tablet)ある点に注目——この同時刻タイが、書き方の正しさを試します。
1 / 5
login_iduser_idlogged_atdevice
112026-06-01 09:00mobile
222026-06-01 10:00pc
312026-06-03 12:00pc
432026-06-02 08:00pc
522026-06-04 18:00mobile
632026-06-02 08:00tablet
712026-06-02 20:00tablet
7行(3ユーザー・user_id=3 に同時刻タイあり)
学習ポイント
GROUP BY は畳む、ウィンドウは付与する:GROUP BY はグループを1行に潰すので「最新行の他の列(device 等)」が自然には取れません。ウィンドウ関数は行を残したまま区画単位の計算結果を各行に付けるので、rn=1 で絞れば「最新行のすべての列」がそのまま手に入ります。実行も区画キーで整列して1パス(または index 順をそのまま利用)で、行ごとの探索は発生しません。
タイブレークは「正しさの仕様」:ORDER BY logged_at DESC だけだと同時刻の rn は実行ごとに変わり得ます(非決定的)。一意キー(login_id)を最後に足せば順位は常に一意——「再実行したら結果が変わった」を構造的に封じます。MAX 相関サブクエリ版がタイで2行返すのと対照的に、ROW_NUMBER は「必ず1件」を書き方のレベルで保証できるのが本質的な強みです。
評価順序がサブクエリを要求する:論理的な評価順序は FROM → WHERE → GROUP BY → HAVING → SELECT(ウィンドウ関数はここ) → ORDER BY。WHERE はウィンドウより先なので WHERE rn = 1 を同じ階層には書けません。サブクエリ/CTEで一段囲むのは回り道ではなく評価順序の必然です。PostgreSQL なら DISTINCT ON、対応する処理系なら QUALIFY 句という短縮形もあります。
アンチパターン
MAX 相関サブクエリで「最新」を取り続ける:N+1 のコスト、タイ重複の正しさ問題に加え、「最新の device も欲しい」となると列ごとにサブクエリが増殖します(アンチパターンと同じ進行)。最新1件系の要件を見たら、まず ROW_NUMBER の4点セットを当てはめるのが定石です。
GROUP BY + MAX の寄せ集めで行を捏造する:GROUP BY user_idMAX(logged_at), MAX(device) を並べると、日時はある行から・device は別の行から……という実在しないフランケン行ができます。エラーにならず一見もっともらしい値が返るぶん、タチの悪い正しさバグです。「同じ行から取れている保証があるか」を常に問うてください。
実務コラム:greatest-n-per-group は道具箱を持っておく
「グループごとの最新/上位N件」は、最新注文・現在ステータス・部署内トップ成績などあらゆるドメインで再登場する頻出パターンです。基本形は本問の ROW_NUMBER(rn <= N にすれば上位N件に拡張)、PostgreSQL で1件だけなら DISTINCT ON が簡潔、グループ数が少なく1グループあたりの行数が膨大なら LATERAL + LIMIT 1 で index を区画ごとに刺す手もあります。さらに index を (user_id, logged_at DESC, login_id DESC) と並びまで揃えれば、既習の「ソート工程を消す」がウィンドウ処理にも効きます。関連する実務テーマとして、一覧画面のもう1つの定番——ページングの罠です。
QUESTION 8

OFFSET の罠とキーセット — 深いページは「読んで捨てる」をやめてシークする

LIMIT / OFFSETキーセット行値比較シーク法
前提知識

ページングの定番 LIMIT 20 OFFSET 100000 は、内部では100,020行を読んで100,000行を捨てています。OFFSET のコストは捨てる行数に比例するため、後ろのページほど遅くなり、途中で行が挿入されるとページずれ(重複・欠落)も起きます。キーセットページネーション(シーク法)は「前ページ最後の行のキー」を覚えておき、その続きから index を読む方式——どのページでもコストは一定です。

-- ✗ 3ページ目: 6行読んで4行捨てる(10万ページ目なら…)
ORDER BY published_at DESC, article_id DESC LIMIT 2 OFFSET 4

-- ✓ キーセット: 前ページ最終行のキーの「続き」だけ読む
WHERE (published_at, article_id) < (TIMESTAMP '2026-05-05 13:00', 5)
ORDER BY published_at DESC, article_id DESC LIMIT 2
行値(タプル)比較:(a, b) < (x, y) は辞書式で「a < x OR (a = x AND b < y)」の意味。ORDER BY と同じ列・同じ向きでカーソル条件を書くのが鉄則で、一意キーを含めることで同時刻タイでもページが安定します。
問題

articles テーブル(実体100万行、(published_at, article_id) に複合 index あり)の記事一覧を、新着順(published_at DESC, article_id DESC)に2件/ページで表示します。2ページ目の最終行が (published_at, article_id) = ('2026-05-05 13:00', 5) でした。OFFSET を使わず3ページ目の2件を取得してください。出力列は article_id, title, published_at

使用テーブル
- articles(id 5 と 6 は同時刻=タイに注意)
article_idtitlepublished_at
1春の特集2026-05-01 09:00
2新機能紹介2026-05-02 10:00
3障害報告2026-05-03 11:00
4対談記事2026-05-04 12:00
5採用情報2026-05-05 13:00
6技術ブログ2026-05-05 13:00
7夏の特集2026-05-06 14:00
8お知らせ2026-05-07 15:00
期待出力
article_idtitlepublished_at
4対談記事2026-05-04 12:00
3障害報告2026-05-03 11:00
模範解答コード
SELECT   article_id, title, published_at
FROM     articles
WHERE    (published_at, article_id) < (TIMESTAMP '2026-05-05 13:00', 5)  -- 前ページ最終行のキー
ORDER BY published_at DESC, article_id DESC  -- カーソルと同じ列・同じ向き
LIMIT    2;

-- 前提 index: CREATE INDEX ON articles (published_at, article_id);(逆順走査で DESC に対応)

/*
  実行順序:
  1. FROM articles  → 対象にする
  2. WHERE 行値比較     → index 上のシークに変換
  3. ORDER BY       → index の並びを利用しソートなし
  4. LIMIT          → 2件で打ち切り
  */
解説(テーブル変化・ポイント)
SELECT article_id, title, published_at FROM articles WHERE (published_at, article_id) < (TIMESTAMP '2026-05-05 13:00', 5) ORDER BY published_at DESC, article_id DESC LIMIT 2;
LEGEND
データ取得・読込対象
1. 対象テーブル — 新着順の一覧を2件ずつページング
FROM articles(新着順 = published_at DESC, article_id DESC)新着順の並びは 8→7→6→5→4→3→2→1。id=5 と 6 は同時刻で、並びは article_id DESC が決めます。1ページ目=8,7 / 2ページ目=6,5 まで表示済み、という状況です。
1 / 5
並びarticle_idtitlepublished_at
18お知らせ2026-05-07 15:00
27夏の特集2026-05-06 14:00
36技術ブログ2026-05-05 13:00
45採用情報2026-05-05 13:00
54対談記事2026-05-04 12:00
63障害報告2026-05-03 11:00
72新機能紹介2026-05-02 10:00
81春の特集2026-05-01 09:00
8行(実テーブルは100万行)— 前ページ最終行は id=5
学習ポイント
OFFSET のコストは「捨てる行数」に比例:LIMIT 20 OFFSET 100000 は100,020行を読み、100,000行を捨てます。1ページ目は速く、後ろへ行くほど線形に遅くなる——「最初は問題なかったのにデータが増えたら一覧が重い」の典型犯です。さらに表示中の挿入・削除で行がずれ、重複表示や取りこぼしも発生します。性能と正しさの両面で、深いページの OFFSET は構造的に不利です。
キーセットは「位置」でなく「値」でページを切る:OFFSET が「先頭から何行目」という位置指定なのに対し、キーセットは「このキーより後ろ」という値指定です。値指定なら index がシーク1回で続きに辿り着け、既習の「ORDER BY とインデックス」「Top-N の打ち切り」がそのまま合流します。鉄則はカーソル条件と ORDER BY を同じ列・同じ向きで揃えること、そして一意キーを並び順の最後に含めることです。
行値比較は「OR 展開の読みやすい形」:(a, b) < (x, y)a < x OR (a = x AND b < y) と同義の辞書式比較です。手書きの OR 展開でも動きますが、行値構文の方が意図が明瞭で、プランナも複合 index の範囲条件として扱いやすくなります。本問の id=6(同時刻タイ)が2列目の比較で正しく除外される動きは、一意キーをカーソルに含める理由そのものです。
アンチパターン
「123ページ目へジャンプ」のために OFFSET を温存する:任意ページへの直接ジャンプはキーセットの苦手分野ですが、その UI 要件自体を疑う価値があります。実際のユーザー行動は「次へ/前へ」と検索絞り込みが大半。無限スクロールや前後ナビならキーセットで完結し、総件数表示も全件 COUNT ではなく概算や「100+」表記で十分なことがほとんどです。
ORDER BY とカーソル条件がずれている:ORDER BY published_at DESC なのにカーソルが article_id < ? だけ、あるいはタイブレーク列をカーソルに含めない——この不一致は同時刻データで行の重複・欠落を起こします。「並び順キーの全列を、同じ向きで、タプルごと比較する」を機械的なチェックリストにしてください。
実務コラム:cursor-based pagination は API 設計の共通語
主要な公開 API のページングが軒並み「カーソル方式」なのは、本問のキーセットをそのまま HTTP に載せたものだからです。サーバは最終行のキーを不透明なトークン(base64 等)にして next_cursor として返し、クライアントは次のリクエストにそれを添える——DB の行値比較に直結する、薄くて速い設計です。注意点はカーソルの中身に並び順の全キーを入れること、並び替え条件が変わったらカーソルを無効化すること。関連する実務テーマとして、ページングよりさらに小さな質問——「あるか/ないか」だけを聞きたいときの最短経路を扱います。
QUESTION 9

EXISTS と COUNT — 「あるか/ないか」は数えずに最初の1件で打ち切る

EXISTSCOUNT早期打ち切りSemi Join
前提知識

「このユーザーに注文があるか?」を判定したいとき、COUNT(*) > 0 で書くと全件を数えてしまいます。本当に欲しいのは「ある/ない」のブール値であって件数ではありません。EXISTS は内側のクエリが1行でも返した瞬間に処理を打ち切る——内部表現は Semi Join で、DISTINCT なしでファンアウトを避けるときと同じ道具です。「件数で語るか、存在で語るか」の差が、走査量を百倍・千倍変えます。

-- ✗ 件数を数え切らないと判定できない(最悪は全件読む)
SELECT CASE WHEN (SELECT COUNT(*) FROM orders WHERE user_id = 42) > 0
            THEN 'yes' ELSE 'no' END;

-- ✓ 1件見つけた瞬間に打ち切り(Semi Join)
SELECT EXISTS (SELECT 1 FROM orders WHERE user_id = 42);
核心:「件数 > 0」で書きたくなったら、それは件数ではなく存在を聞いている合図です。EXISTS は意図を正確に伝えるだけでなく、プランナに「最初の1件で止まってよい」という強力なヒントを与えます。
問題

users(実体10万行)と orders(実体500万行・user_id に index あり)から、「注文が1件でもあるユーザー」と「未注文ユーザー」を抽出して、それぞれの存在フラグ付き一覧を作ってください。判定のためにユーザーごとに注文件数を数えてはいけません(件数は答えに不要・走査が無駄)。出力列は user_id, name, has_order('yes' / 'no'、user_id 昇順)。

使用テーブル
- users
user_idname
1Sato
2Suzuki
3Tanaka
4Ito
- orders(Sato/Tanaka は注文多数、Suzuki は1件、Ito はゼロ)
order_iduser_id
1011
1021
1031
1042
1053
1063
期待出力
user_idnamehas_order
1Satoyes
2Suzukiyes
3Tanakayes
4Itono
模範解答コード
SELECT   u.user_id, u.name,
         CASE WHEN EXISTS (
           SELECT 1 FROM orders o WHERE o.user_id = u.user_id  -- 1件見つけたら即打ち切り
         ) THEN 'yes' ELSE 'no' END AS has_order
FROM     users u
ORDER BY u.user_id;

-- ✗: CASE WHEN (SELECT COUNT(*) FROM orders WHERE user_id = u.user_id) > 0 …
--      → ユーザーごとに注文を全件カウント(idx(user_id) の Range Scan を最後まで走り切る)

/*
  実行順序:
  1. FROM users        → 走査
  2. SELECT 内 EXISTS   → idx で1件見つけたら打ち切り(Semi Join)
  3. CASE              → TRUE/FALSE を 'yes'/'no' に変換
  4. ORDER BY user_id  → 昇順
  */
解説(テーブル変化・ポイント)
SELECT u.user_id, u.name, CASE WHEN EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id ) THEN 'yes' ELSE 'no' END AS has_order FROM users u ORDER BY u.user_id;
LEGEND
データ取得・読込対象
1. 対象テーブル — ユーザーごとに「注文の有無」を判定したい
FROM users u(各ユーザーについて orders を問い合わせる)欲しいのは「ある/ない」のブール値、つまり1件でも見つかれば答えは確定。Sato の注文が100万件あろうと、最初の1件が見つかれば残りを読む理由はありません。
1 / 5
user_idnameorders の件数(参考)
1Sato多数(最悪100万件)
2Suzuki1件
3Tanaka多数
4Ito0件
4行(実テーブル users は10万行・orders は500万行)
学習ポイント
意図を伝えることが最適化の入口:SQL はプランナへの「何が欲しいか」の宣言です。COUNT は「件数を欲しがっている」と読まれ、最後まで数えないと答えが出ないと判断されます。EXISTS は「存在が欲しい」と読まれ、最初の1件で打ち切る最適化(Semi Join)が選ばれます。同じ結果を返す書き方でも、意図の伝え方で実行戦略が変わる——これは Q4(DISTINCT vs EXISTS)と通底する原則です。
SELECT 1 と SELECT * の差はない:EXISTS の中身は「行が返るかどうか」だけが意味を持ち、何を選んだかは捨てられますSELECT 1 / SELECT * / SELECT NULL いずれもパフォーマンスは同じ。慣習的に SELECT 1 を書くのは「列名や値は何でもよく、存在だけを聞いている」という読み手への明示のためです。中で集計や ORDER BY を書く必要も一切ありません。
存在系は EXISTS / NOT EXISTS のペアで覚える:「ある」を聞くなら EXISTS(Semi Join)、「ない」を聞くなら NOT EXISTS(Anti Join・既習)。どちらも index がある限り「1件見つかった/見つからなかった」で打ち切れるのが本質。IN / NOT IN は意味的に近いものの、NOT IN は NULL の罠があり、また EXISTS ほど打ち切りの意図が明瞭ではありません。存在判定は EXISTS 系に寄せるのが定石です。
アンチパターン
「件数の中身は使わないが、念のため数えておく」:アプリ側で if (count > 0) としているのに、SQL は COUNT を返す——これが現場で最頻出のアンチパターンです。件数を見ているのは判定だけ、表示には使っていないなら、その時点で EXISTS に置き換えられます。「数えた件数を本当に出力に使っているか?」を自問してください。
EXISTS の中で ORDER BY や LIMIT を書く:EXISTS (SELECT 1 FROM orders ORDER BY ordered_at DESC LIMIT 1) のような書き方を見かけますが、EXISTS の中の並びや件数は結果に影響しません(存在だけが意味を持つ)。冗長なだけでなく「最新の注文があるか」のような誤った意図を読み手に与える害もあります。EXISTS の中は WHERE 条件だけにしてください。
実務コラム:「件数」「存在」「最大」「最新」は別の道具
よく似て見えて、実は別物の問いです:件数を出力に使うなら COUNT、あるか/ないかなら EXISTS / NOT EXISTS、最大値そのものを出力するなら MAX、最大値を持つ行のすべての列が欲しいなら ROW_NUMBER(Q7)。本問の COUNT 誤用と MAX 相関サブクエリ誤用は同根で、いずれも「集計の道具で存在判定や行特定をしている」のが原因です。問いの形を一段精密にすると、プランナはそれに応じた最短経路を選んでくれます。最後の Q10 では、ここまでの5問(型・ウィンドウ・キーセット・EXISTS・部分インデックスなど)を1つの実務クエリに組み合わせます。
QUESTION 10

総合問題 — ウィンドウ + キーセット + 部分 index + 型を1つの管理画面SQLに

総合ROW_NUMBERキーセット部分 indexSargable
前提知識

本編の総まとめとして、実務頻出の管理画面 SQL を組み立てます。「未処理タスクのうち、各担当者の最新1件だけを、新着順にカーソルで切り出す」——5つの基礎技がきれいに役割分担します。

① 部分インデックスで 'pending' の少数派だけを索引化、② ROW_NUMBER で各担当者の最新1件に絞り、③ キーセットで OFFSET なしのページング、④ 暗黙の型変換を避けて index を生かし、⑤ EXISTS でフィルタを「存在」で表現。基礎編3で学んだ「意図を伝える / 関数で列を包まない / 集合で1回 / Top-N を打ち切る」がすべて1つのクエリに結晶します。

-- 全体像(疑似コード)
WITH ranked AS (             -- ① ROW_NUMBER で担当者ごとの最新1件
  SELECT ..., ROW_NUMBER() OVER (PARTITION BY assignee_id ORDER BY created_at DESC, task_id DESC) AS rn
  FROM tasks WHERE status = 'pending'   -- ② 部分 index が効く形(型は string で揃える)
)
SELECT ... FROM ranked
WHERE rn = 1
  AND (created_at, task_id) < (?, ?)              -- ③ キーセットで次ページへ
  AND EXISTS (SELECT 1 FROM task_tags ...)        -- ⑤ EXISTS で存在判定
ORDER BY created_at DESC, task_id DESC LIMIT ?;
狙い:個別技は単独で使える道具ですが、実務クエリは複数の論点が重なる場所で効きます。組み合わせる順序と役割分担を体に染み込ませるのが本問のゴールです。
問題

タスク管理 SaaS の「担当者ごとの最新の未処理タスク一覧(新着順)」画面を作ります。要件は以下です:

  • 担当者1人につき最新の pending タスクを1件だけ表示(タイブレーク: task_id DESC)
  • 「重要」タグが付いたタスクだけを抽出(task_tagstag = 'important' がある)
  • 新着順(created_at DESC, task_id DESC)に 2件/ページ でカーソル方式ページング
  • 前ページ最終行のカーソル: (created_at, task_id) = ('2026-06-04 11:00', 4)(カーソル位置からの次ページを取得)
  • OFFSET 不使用、テーブルの全行走査を避けるため 部分 index も合わせて提示すること(status は varchar)
使用テーブル
- tasks(status は varchar・実体100万行、'pending' は約0.5%)
task_idassignee_idtitlestatuscreated_at
11請求書確認pending2026-06-08 10:00
21議事録整理pending2026-06-05 09:00
32採用面談pending2026-06-07 14:00
42設計レビューpending2026-06-04 11:00
53監査対応pending2026-06-03 16:00
63契約書修正pending2026-06-02 09:00
74備品発注pending2026-05-30 13:00
85清掃手配done2026-06-09 09:00
- task_tags(中間テーブル)
task_idtag
1important
2internal
3important
4important
5important
6internal
7important
期待出力
task_idassignee_idtitlecreated_at
53監査対応2026-06-03 16:00
74備品発注2026-05-30 13:00
模範解答コード
CREATE INDEX idx_tasks_pending_keyset                             -- 部分 index: 'pending' 行だけを担当者ごとの最新順に索引化(Q5 + Q7)
  ON tasks (assignee_id, created_at DESC, task_id DESC)
  WHERE status = 'pending';
CREATE INDEX idx_task_tags_task_tag ON task_tags (task_id, tag);  -- 補助 index: 重要タグの存在確認用(EXISTS が刺さる)

WITH ranked AS (
  SELECT task_id, assignee_id, title, created_at,
         ROW_NUMBER() OVER (PARTITION BY assignee_id                       -- ① 担当者ごとに区画
                            ORDER BY created_at DESC, task_id DESC) AS rn  -- タイブレーク必須
  FROM   tasks
  WHERE  status = 'pending'                                              -- ② 部分 index が含意で選ばれる(文字列同士・Q6)
)
SELECT   task_id, assignee_id, title, created_at
FROM     ranked
WHERE    rn = 1                                                            -- 各担当者の最新1件だけ残す
  AND    (created_at, task_id) < (TIMESTAMP '2026-06-04 11:00', 4)       -- ③ キーセット (Q8)
  AND    EXISTS (                                                          -- ⑤ 存在判定 (Q9)
           SELECT 1 FROM task_tags tt
           WHERE  tt.task_id = ranked.task_id AND tt.tag = 'important'
         )
ORDER BY created_at DESC, task_id DESC                                  -- カーソルと同じ列・同じ向き
LIMIT    2;

/*
  実行順序:
  1. CTE 内 FROM/WHERE  → status='pending' で部分 index を採用
  2. CTE 内 ウィンドウ       → 区画化し ROW_NUMBER を付与(担当者ごとの index 順を利用)
  3. 外側 WHERE rn = 1   → 担当者ごとの先頭行
  4. キーセット行値比較         → 古い側へジャンプ
  5. EXISTS            → important の有無を1件探索(Semi Join)
  6. ORDER BY created_at DESC, task_id DESC  → 残った候補を並べて2件で終了
  */
解説(テーブル変化・ポイント)
CREATE INDEX idx_tasks_pending_keyset ON tasks (assignee_id, created_at DESC, task_id DESC) WHERE status = 'pending'; WITH ranked AS ( SELECT task_id, assignee_id, title, created_at, ROW_NUMBER() OVER (PARTITION BY assignee_id ORDER BY created_at DESC, task_id DESC) AS rn FROM tasks WHERE status = 'pending' ) SELECT task_id, assignee_id, title, created_at FROM ranked WHERE rn = 1 AND (created_at, task_id) < (TIMESTAMP '2026-06-04 11:00', 4) AND EXISTS ( SELECT 1 FROM task_tags tt WHERE tt.task_id = ranked.task_id AND tt.tag = 'important' ) ORDER BY created_at DESC, task_id DESC LIMIT 2;
LEGEND
データ取得・読込対象
除外・非表示データ
1. 元データ — tasks 100万行 + task_tags(多対多)
FROM tasks(status='pending' は約0.5%・偏った分布)100万行のうち pending は約0.5%。done を含めて全行を読み始めた瞬間に負けが確定する規模感です。task_tags は多対多の中間テーブル(既習)で、important フラグを別テーブルで管理する典型構造。
1 / 6
task_idassignee_idtitlestatuscreated_at
11請求書確認pending2026-06-08 10:00
21議事録整理pending2026-06-05 09:00
32採用面談pending2026-06-07 14:00
42設計レビューpending2026-06-04 11:00
53監査対応pending2026-06-03 16:00
63契約書修正pending2026-06-02 09:00
74備品発注pending2026-05-30 13:00
85清掃手配done2026-06-09 09:00
8行(実体は100万行・pending 約5千行)+ task_tags 7行
学習ポイント
5つの技がそれぞれ別レイヤーを担当する:部分 index は「読む行を減らす」(物理)、型を揃えるのは「index を効かせる前提条件」(言語)、ROW_NUMBER は「行を絞る論理」(集合操作)、EXISTS は「中で打ち切らせる意図」(実行戦略)、キーセットは「ページングを位置でなく値でやる設計」(API契約)。同じクエリでもレイヤーが違うため衝突せず、足し算でなく掛け算で速くなります
index の並びでクエリ要件を直接表現する:本問の (assignee_id, created_at DESC, task_id DESC) という並びは、担当者ごとのウィンドウの PARTITION BY + ORDER BY を一発で表現します。外側の全体順序(created_at DESC, task_id DESC)は担当者キーのない別の並びなので、最新1件に絞った候補へ最後に適用します。「ウィンドウ用とソート用と検索用、index を3本貼る」のは初学者の選択と決めつけず、要件ごとの並びのトレードオフを説明して index を設計する方向へ視点を移します。index は「列のセット」ではなく「列の順序付きシーケンス」だと意識すると、この設計が自然になります。
EXPLAIN で「3つのゼロ」を確認する:仕上がりの良い実務クエリは、EXPLAIN ANALYZE に (1) 巨大な Seq Scan が出ない(部分 index と含意で pending 以外を読まない)、(2) ウィンドウ前の大規模 Sort が抑えられる(担当者ごとの index 順を利用)、(3) loops が大きい SubPlan が出ない(EXISTS が Semi Join に変換され打ち切られる)——という3つの「避けたいもの」が抑えられています。最終候補の ORDER BY は残るため、実際の EXPLAIN で候補件数と Sort の規模を確認してください。
アンチパターン
論点を1個ずつ解決してクエリが膨張する:「最新1件は GROUP BY + MAX、絞り込みは JOIN + DISTINCT、ページングは OFFSET、status は念のため UPPER(status) = 'PENDING'」——個別の論点を別々に解いて積むと、関数包み + ファンアウト + 全走査 + 行捏造が一発で揃います。クエリは部品を組み合わせるのではなく、「データの流れ」として1本の経路を設計するのが正解です。
index を増やしてプランナに「お任せ」する:(assignee_id), (created_at), (status), (task_id, tag) と列単体の index を量産するアプローチは、ストレージと書込みコストを払う割に、本問のような複合要件では BitmapAnd 経由の中途半端な計画にしか繋がりません。クエリ要件から逆算した複合 index 1本のほうが、ほぼ常に速く・軽く・読みやすくなります。
実務コラム:基礎編3 完走 — 「速く・正しく・読める」SQL の地図
基礎編3を通して、私たちは10本の道具を手にしました:N+1 を JOIN+集約へ畳む(Q1)、NULL の三値論理(Q2)、日付は半開区間で Sargable に(Q3)、重複は DISTINCT で消さず EXISTS で作らない(Q4)、偏った列は部分 index で少数派を索引化(Q5)、型を揃え、列でなくリテラル側を寄せる(Q6)、greatest-n-per-group は ROW_NUMBER の4点セット(Q7)、深いページはキーセット行値比較で値ベース(Q8)、「件数 > 0」と書きたくなったら EXISTS(Q9)、そして個別技を1本のクエリに組み込む設計(Q10)。
背骨に流れているのは、「実行回数 × 1回のコスト」を両方減らすこと「列を関数や暗黙キャストで包まないこと」「中間結果のサイズを小さく保つこと」、そして「意図をプランナに正確に伝えること」です。基礎編全体の地図が手に入った今、次に必要なのはEXPLAIN の実出力を読む技術と、結合アルゴリズム・統計情報・並列実行といったプランナの内部です。これらは続編で扱います。お疲れさまでした。