SQL パフォーマンス最適化 — 複合INDEX・JOIN戦略の基礎

基礎複合INDEXJOIN / Nested Loop / Hash Join事前集約Keyset PaginationPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

複合インデックスの列順 — 「等値 → 範囲」で並べる

複合INDEX列順左端一致Index設計
前提知識

複合インデックス (a, b, c) は B-tree 上で「a でまず辞書順、a が同じなら b、…」と並びます。WHERE 句で活用されるのは左端から連続した列であり、しかも「最初の範囲条件(<, >, BETWEEN, LIKE)」より右の列はインデックスのソート順で絞り込めません。設計の鉄則は 「等値 → 範囲 → ソート」の順で列を並べること。

-- ✓ (customer_id, created_at) ← 等値→範囲 の順
WHERE customer_id = 101
  AND created_at >= '2024-06-01'
ORDER BY created_at DESC;

-- ✗ (created_at, customer_id) ← 範囲が先頭:customer_id 単独で絞れない
列順の鉄則:(1) 等値で絞る列を必ず左、(2) 範囲条件の列はその右隣に1つだけ、(3) ORDER BY と順序も一致させると追加ソート不要(Index Order Scan)。「等値→範囲→ソート」を呪文として覚えておくと、列順設計でほぼ迷わなくなります。
問題

orders テーブル(100万行)に対し、よく流れるクエリは「特定顧客の最近の注文(日付降順 上位3件)」です。最適な複合インデックスを設計し、それを活かす SELECT 文を書いてください。

使用テーブル(概念図・8行)
- orders(実体100万行 / 例として8行)
order_idcustomer_idcreated_atamount
11002024-05-011200
21012024-06-10800
31012024-07-222000
41012024-08-303000
51022024-04-15500
61012024-09-051500
71032024-07-01700
81012024-05-20900
期待出力
order_idcustomer_idcreated_atamount
61012024-09-051500
41012024-08-303000
31012024-07-222000
模範解答コード
CREATE INDEX idx_orders_cust_date ON orders (customer_id, created_at DESC);  -- 【インデックス設計】等値(customer_id)→ 範囲(created_at)の順で並べる

-- 【クエリ】そのインデックスをフル活用する SELECT
SELECT   order_id, customer_id, created_at, amount
FROM     orders
WHERE    customer_id = 101                -- 等値:B-treeで該当ブロックに直接ジャンプ
  AND    created_at >= '2024-06-01'       -- 範囲:絞ったブロック内で範囲スキャン
ORDER BY created_at DESC                  -- index と同じ並び → 追加ソート不要
LIMIT    3;                              -- 3行読んだ瞬間に走査終了(Top-N)

/*
  実行順序(プランナの動き):
  1. idx の B-tree で customer_id に直接到達  → インデックスで対象に到達
  2. created_at の開始位置を二分探索             → 範囲の開始を特定
  3. 最新側から必要行を取り出して終了                  → 必要件数で打ち切り
  4. テーブル本体から SELECT 列を取得              → 列を取得
  */
解説(テーブル変化・ポイント)
SELECT order_id, customer_id, created_at, amount FROM orders WHERE customer_id = 101 AND created_at >= '2024-06-01' ORDER BY created_at DESC LIMIT 3;
LEGEND
データ取得・読込対象
1. 対象テーブル — orders(customer_id, created_at の複合index予定)
FROM ordersorders は customer_id(多数顧客)と created_at(日付)の組み合わせ検索が頻発します。customer_id=101 の行は order_id=2,3,4,6,8 の5行、うち 2024-06-01 以降は4行です。
1 / 5
order_idcustomer_idcreated_atamount
11002024-05-011200
21012024-06-10800
31012024-07-222000
41012024-08-303000
51022024-04-15500
61012024-09-051500
71032024-07-01700
81012024-05-20900
8行(実テーブルは100万行)
学習ポイント
「等値 → 範囲 → ソート」が複合インデックスの黄金順序:等値で絞る列を必ず左端に置き、範囲条件はその右隣に1つだけ。範囲条件より右の列はインデックス順で絞れないため、ソート用列は範囲列と兼ねるか、範囲列の直後に置くのが定石です。これを守るだけで、ほとんどのクエリは Index Range Scan に化けます。
ORDER BY の向きを index と揃えると追加ソートが消える:CREATE INDEX ... (col DESC) または ORDER BY col DESC を index の昇順から Backward Scan させることで、ソート工程そのものを省けます。EXPLAINSort ノードが出ているかどうかが目印です。
左端一致の原則 — 「(a,b,c) は (a), (a,b), (a,b,c) で使える」:複合インデックス (a, b, c) は WHERE が a 単独 / a+b / a+b+c のいずれでもインデックスが効きますが、b 単独 / c 単独 / b+c では効きません。「左から連続して使われているか」だけが判定基準です。これを意識すると、似たような単一インデックスを乱立させずに済みます。
アンチパターン
「とりあえず全列にシングルインデックス」を貼る:列ごとに別々のインデックスを作っても、1クエリで使えるのは原則1つ(Bitmap Index Scan の例外はあるが効率は複合 index に劣る)。書込みコストも索引数に比例するため、クエリの WHERE 句の組み合わせに合わせた複合 index 設計が本筋です。
範囲条件の列を左端に置く:(created_at, customer_id) のように範囲列を先頭に置くと、後続の customer_id は index で絞れず、結果として「該当期間の全顧客分」を読むハメになります。等値で絞れる列ほど左に持ってくるのが鉄則です。
実務コラム:インデックスは「クエリのために」設計する
インデックス設計の最大のコツは、「テーブル」ではなく「クエリ」を見て設計することです。pg_stat_statementsslow query log で実行頻度の高い WHERE / ORDER BY パターンを抽出し、その列順・並び順に合わせて複合 index を作る。「等値 → 範囲 → ソート」を満たす index は、しばしば1本で複数の頻出クエリを高速化します。一方、書込み側はインデックス数だけコストが増えるため、「1本で多くのクエリをカバーする」のがプロの設計です。Q7 では、複数テーブルを跨ぐ JOIN でもこの index 設計がそのまま効いてくることを見ていきます。
QUESTION 7

JOIN の基礎 — Nested Loop と Hash Join、駆動表の選び方

INNER JOINNested LoopHash Join駆動表
前提知識

JOIN は2つのテーブルを関連付ける操作で、プランナは主に2つのアルゴリズムから選びます。Nested Loop は駆動表(外側)の各行に対して内部表(内側)を都度検索する方式で、外側が小さく内側にインデックスがあるときに最速。Hash Join は片方からハッシュテーブルを構築してもう一方を1回スキャンする方式で、大規模・等値結合で有利です。

-- プランナが Nested Loop を選ぶ典型例(customersが小、ordersが大+Indexあり)
SELECT *
FROM   customers c      -- ← 駆動表(外側:ここで絞り込まれた行数分だけループが回る)
JOIN   orders o         -- ← 内部表(内側:外側の1行ごとに Index Scan される)
  ON c.customer_id = o.customer_id;

-- 大規模テーブル同士で Index がない場合は Hash Join が選ばれやすい
SELECT *
FROM   huge_table_A a   -- ← 小さい方でハッシュ表をメモリに構築
JOIN   huge_table_B b   -- ← 大きい方を1パススキャンしてハッシュ表と突合
  ON a.id = b.a_id;
駆動表(外側)と内部表(内側):Nested Loop においてループの外側に置かれるテーブルが駆動表です。原則として「絞り込み後の行数が少ない側」を駆動表(外側)にし、「結合キーにインデックスがある側」を内部表(内側)に置くのが理想です。SQL文上の左右(FROMとJOIN)に関わらずプランナが自動選択しますが、EXPLAIN で駆動表が想定と違うときは統計の更新やインデックスの見直しが必要です。
問題

customersorderscustomer_id で内部結合し、各注文に顧客名を付けた一覧を取得してください。orders.customer_id にはインデックスがあります。出力列は order_id, customer_name, amount、order_id 昇順で。

使用テーブル
- customers(3行・駆動表候補)
customer_idname
101Alice
102Bob
103Carol
- orders(5行・customer_id に index)
order_idcustomer_idamount
11011200
2103800
31012000
41023000
51031500
期待出力
order_idcustomer_nameamount
1Alice1200
2Carol800
3Alice2000
4Bob3000
5Carol1500
模範解答コード
SELECT   o.order_id,
         c.name AS customer_name,
         o.amount
FROM     customers c                     -- 小さい側を駆動表として明示的に先頭に
INNER JOIN orders o                      -- 内側:customer_id に index あり → 1ループ1ジャンプ
       ON o.customer_id = c.customer_id  -- 結合条件(等値)
ORDER BY o.order_id;                     -- 出力ソート

/*
  実行順序(Nested Loop プランの場合):
  1. customers を1行ずつ走査          → 外側=駆動表
  2. 各 c に orders を Index Scan  → インデックスで結合
  3. 結合結果を ORDER BY order_id    → 並べ替え
  */
解説(テーブル変化・ポイント)
SELECT o.order_id, c.name AS customer_name, o.amount FROM customers c INNER JOIN orders o ON o.customer_id = c.customer_id ORDER BY o.order_id;
LEGEND
データ取得・読込対象
1. 駆動表の走査 — customers (外側)
FROM customers cプランナは行数が少ない customers を駆動表(外側テーブル)として選び、全3行を1行ずつ走査します。
1 / 5
customer_idname
101Alice
102Bob
103Carol
駆動表: 3行
学習ポイント
「小さい側 × 内側 index」なら Nested Loop が最速:外側で行数を絞り、内側で index による1ジャンプ問い合わせを繰り返す形は、外側の行数だけ Index Scan する軽量プランです。WHERE で外側を絞れば絞るほどコストが直線的に減るのが特徴。「絞ってから結合」を意識すると、自然と良いプランに乗ります。
「両方大きい × 等値結合」なら Hash Join:大規模テーブル同士を等値で結合する場合、プランナは小さい側でハッシュ表を作り、大きい側を1パスする Hash Join を選びます。Nested Loop で外側×内側のループを回すより圧倒的に軽くなる場面です。work_mem が小さいとディスクに溢れて遅くなるので、メモリ設定も性能の一部です。
結合キーには必ず index を:JOIN 条件に出てくる列に index が無いと、Nested Loop が選ばれなくなり大規模時は Hash Join、それすら厳しい場合はSeq Scan × Seq Scan の壊滅的プランになります。主キー側は自動で index があるので、外部キー側にも index を貼るのが鉄則です。
アンチパターン
結合条件の型が一致していない:ON c.customer_id = o.customer_id_text のように型がずれた結合は、片側に暗黙のキャストが入って index が無効化されます。「両側 BIGINT」「両側 VARCHAR」のように同型・同サイズで揃えるのが、性能だけでなくデータ整合性のためにも重要です。
不要な OUTER JOIN:本当に「片側に存在しない行も欲しい」のでなければ、LEFT JOIN ではなく INNER JOIN を使います。OUTER JOIN はNULL 行を許容する分、プランナの選択肢が狭まることがあり、INNER に置き換えるだけで Hash Join が選ばれるケースもあります。「INNER で十分か?」を毎回問うのは良い習慣です。
実務コラム:JOIN の順序を書き換えても性能は(基本)変わらない
SQL 標準では A JOIN BB JOIN A は意味的に同じで、プランナがコストを見て駆動表を自動的に入れ替えます(join_collapse_limit の範囲内で)。「FROM の左に書いた方が外側になる」のは古い迷信です。重要なのは (1) 結合キーに index があるか(2) どちらが先に絞り込めるか(3) 統計が最新かの3点だけ。プランナが想定と違う駆動表を選んでいると感じたら、まず ANALYZE で統計を更新し、それでもダメなら結合キーや WHERE の選択率を見直しましょう。次の Q8 では「サブクエリで隠れた N+1 が起きる」典型例を扱います。
QUESTION 8

スカラサブクエリの罠 — 集約は事前に1回だけ計算する

スカラサブクエリ事前集約LEFT JOINN+1
前提知識

SELECT 句に書くスカラサブクエリ(1行1列を返すサブクエリ)は便利ですが、外側1行ごとに再評価されることがあり、外側 N 行に対しN 回サブクエリが走ることになります(いわゆる SQL レベルの N+1)。事前に GROUP BY で集約してから LEFT JOIN エンジンに任せれば、集約は1回で済みます。

-- ✗ スカラサブクエリ:customers 4 行 → SUM が 4 回実行される
SELECT c.customer_id, c.name,
       (SELECT SUM(amount) FROM orders
        WHERE  customer_id = c.customer_id) AS total
FROM   customers c;

-- ✓ 事前集約:SUM は1回(orders を1パス)、その後 LEFT JOIN で結合
「N+1」の SQL 版:外側 N 行に対し同じ集約を N 回繰り返すのは、ORM の N+1 問題と同じ構造です。「集約は事前に1回、結合は最後に1回」を守ると、orders を読むのが N パス → 1 パスになり、桁違いに軽くなります。COALESCE で NULL を 0 に潰すのも常套手段です。
問題

customers 全件に、その顧客の注文合計額(注文が無ければ 0)を付与した一覧を取得してください。事前集約 + LEFT JOIN の形で、スカラサブクエリを使わずに書いてください。出力列は customer_id, name, total、customer_id 昇順で。

使用テーブル
- customers(4行)
customer_idname
101Alice
102Bob
103Carol
104Dan
- orders(5行)
order_idcustomer_idamount
11011200
2101800
31032000
41031500
5101500
期待出力
customer_idnametotal
101Alice2500
102Bob0
103Carol3500
104Dan0
模範解答コード
WITH agg AS (                            -- 【事前集約】customer_id 単位で SUM を1回だけ計算
  SELECT   customer_id,
           SUM(amount) AS total
  FROM     orders
  GROUP BY customer_id                   -- orders を1パスで集計
)
SELECT   c.customer_id, c.name,
         COALESCE(a.total, 0) AS total   -- 【NULL → 0】注文なし顧客を 0 で埋める
FROM     customers c
LEFT JOIN agg a                          -- 【LEFT JOIN】注文なし顧客も残す
       ON a.customer_id = c.customer_id
ORDER BY c.customer_id;

/*
  実行順序:
  1. CTE agg: orders を1パスでスキャンし、customer_id 単位で SUM
  2. customers 4 行を読み込み(駆動表)
  3. 各 c に対し agg を customer_id で LEFT JOIN
  4. COALESCE で NULL を 0 に置換
  5. customer_id 昇順で並べ替え
  */
解説(テーブル変化・ポイント)
WITH agg AS ( SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id ) SELECT c.customer_id, c.name, COALESCE(a.total, 0) AS total FROM customers c LEFT JOIN agg a ON a.customer_id = c.customer_id ORDER BY c.customer_id;
LEGEND
データ取得・読込対象
1. 2テーブル読込 — customers と orders
FROM customers, orderscustomers 4 行に対し、orders は 5 行。最終的に「全顧客 × 合計額(無ければ0)」が欲しいので、orders をどう走査するかが性能の鍵です。
1 / 5
srccustomer_idval1val2
customers101Alice
customers102Bob
customers103Carol
customers104Dan
orders101o.order_id=11200
orders101o.order_id=2800
orders103o.order_id=32000
orders103o.order_id=41500
orders101o.order_id=5500
customers=4, orders=5
学習ポイント
「集約は事前に1回、結合は最後に1回」が SELECT 高速化の合言葉:スカラサブクエリは書きやすい一方、外側 N 行に対しN 回再評価される構造になりがちです。WITH や派生表で事前に1回だけ集約し、その結果を結合する形に直すと、orders などの大規模テーブルへのアクセスが激減します。
LEFT JOIN + COALESCE は「片側のみ集合の包含」の定石:顧客一覧に「あれば数値、無ければ 0」を付与するパターンは、LEFT JOIN で全顧客を残しつつ COALESCE(集約結果, 0) で NULL を埋めるのが最も読みやすく速い書き方です。INNER JOIN にすると「注文なし顧客」が消えるバグを生むので、必ず LEFT JOIN を選びます
同じ集約を複数列に使うなら CTE/派生表が圧倒的:「合計額」「件数」「最新注文日」など同じ顧客単位の集計を複数欲しい場合、スカラサブクエリを並べるとサブクエリの本数だけ走査が増えます。WITH agg AS (... GROUP BY customer_id) で一度に複数列を作れば、orders へのアクセスは1回で済みます。
アンチパターン
「とりあえずスカラサブクエリ」を量産:SELECT 句に (SELECT ... FROM ...) を3〜4本並べると、外側1行ごとにその本数だけサブクエリが実行されます。プランナが結合に書き換えてくれることもありますが、全DB・全構文で保証されるわけではなく、最初から CTE / 派生表で書く方が安全です。
LEFT JOIN の右側列を WHERE で = 比較する:FROM c LEFT JOIN agg a ON ... WHERE a.total > 0 と書くと、LEFT JOIN が実質 INNER JOIN に降格します(NULL は > 0 で FALSE のため)。NULL を残したい時は WHERE a.total IS NULL OR a.total > 0 か、COALESCE(a.total,0) > 0 のように明示的に NULL を扱える条件に書き換えます。
実務コラム:派生表・CTE・サブクエリ — 使い分けの基準
同じ「事前集約 → 結合」でも書き方は3通りあります。(1) WITH 句(CTE)は名前が付き、再利用や可読性に優れる。(2) FROM 句の派生表は CTE と同等で、シンプルなら派生表が手軽。(3) スカラサブクエリ「1行1列、外側に依存しない」場合だけ使うべきで、相関するなら避ける。PostgreSQL では古いバージョンの CTE は「最適化の境界」となり実体化されていましたが、現在は MATERIALIZED / NOT MATERIALIZED で制御できます。実務では「集約は CTE で、結合は LEFT JOIN で、NULL は COALESCE で」を機械的に適用すれば、ほとんどのレポートクエリは綺麗に書けます。Q9 ではこの集約を「グループ別の上位 K」に拡張するウィンドウ関数を扱います。
QUESTION 9

ウィンドウ関数で「グループ別 Top-N」 — ROW_NUMBER + PARTITION BY

ROW_NUMBERPARTITION BYWINDOWTop-N per group
前提知識

通常の ORDER BY ... LIMIT「全体の上位 K」しか取れません。「グループごとの上位 K」を取るにはウィンドウ関数 ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) を使い、各グループ内で番号を振ってからWHERE rn <= K で絞ります。集約と違って行が畳まれないのがウィンドウ関数の最大の特徴です。

-- グループ毎に独立に番号付け(=畳まずに列を追加)
ROW_NUMBER() OVER (
  PARTITION BY category   -- カテゴリ毎にリセット
  ORDER BY     amount DESC   -- 同一カテゴリ内の並び順
) AS rn
RANK 系3兄弟:(1) ROW_NUMBER=同値でも別番号(1,2,3,4)、(2) RANK=同値は同順位+次が飛ぶ(1,2,2,4)、(3) DENSE_RANK=同値同順位+飛ばない(1,2,2,3)。「Top-N で正確に K 件」欲しい時は ROW_NUMBER が定石です。
問題

orders からカテゴリ別の売上トップ2注文を取得してください。出力列は category, order_id, amount, rn、category 昇順 → rn 昇順で。

使用テーブル
- orders(8行・3カテゴリ)
order_idcategoryamount
1Books1200
2Books800
3Books2000
4Toys5000
5Toys3000
6Toys1500
7Food700
8Food500
期待出力
categoryorder_idamountrn
Books320001
Books112002
Food77001
Food85002
Toys450001
Toys530002
模範解答コード
WITH ranked AS (                         -- 【番号付け】各カテゴリ内で売上降順の順位を列として追加
  SELECT category, order_id, amount,
         ROW_NUMBER() OVER (
           PARTITION BY category         -- カテゴリ毎に番号をリセット
           ORDER BY     amount DESC      -- 同一カテゴリ内は売上降順
         ) AS rn
  FROM   orders
)
SELECT   category, order_id, amount, rn
FROM     ranked
WHERE    rn <= 2                         -- 各カテゴリの上位 K=2 だけを残す
ORDER BY category, rn;                   -- カテゴリ昇順、同カテ内は順位昇順

/*
  実行順序(SQLの論理的な評価順):
  1. FROM orders            → スキャン
  2. ウィンドウ評価                → PARTITION+ORDER+ROW_NUMBER で番号付与
  3. WHERE rn で上位を抽出        → 各カテゴリ上位2行を残す
  4. ORDER BY category, rn  → 表示用に並べ替え
  */
解説(テーブル変化・ポイント)
WITH ranked AS ( SELECT category, order_id, amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC ) AS rn FROM orders ) SELECT category, order_id, amount, rn FROM ranked WHERE rn <= 2 ORDER BY category, rn;
LEGEND
データ取得・読込対象
1. FROM orders — 8行読込
FROM orders3カテゴリ × 計8行のデータを読み込みます。「カテゴリ別のトップ2」を最終的に欲しいので、カテゴリ毎に独立した順位付けが必要です。
1 / 5
order_idcategoryamount
1Books1200
2Books800
3Books2000
4Toys5000
5Toys3000
6Toys1500
7Food700
8Food500
8行(3カテゴリ)
学習ポイント
ウィンドウ関数は「行を畳まない集計」:GROUP BY が 「N行 → グループ数行」に畳むのに対し、ウィンドウ関数は「N行 → N行(列が追加される)」。これにより「グループ毎の順位」「累計」「移動平均」など、個別行と集計値を同時に持ちたい場面で威力を発揮します。Top-N per group はその代表例です。
ウィンドウ関数は SELECT 句評価 → CTE で挟む:ウィンドウ関数の結果は SQL の論理評価順で「SELECT 句」で計算されるため、同じ SELECT の WHERE では直接フィルタできません。WITH ranked AS (...) SELECT ... WHERE rn <= K のようにCTE か派生表で一旦包むのが定型パターンです。
OVER 句の (PARTITION BY ... ORDER BY ...) で「窓」を定義:PARTITION BY「どこで番号をリセットするか」ORDER BY「窓の中での並び順」。両方省略すると「全行を1つの窓として」扱われ、ORDER BY だけだと「全体での累計順位」になります。「窓 = グループ + 並び順」と覚えると関数族全体が一気に身に付きます。
アンチパターン
相関サブクエリで「グループ毎の Top-N」を頑張る:WHERE (SELECT COUNT(*) FROM orders o2 WHERE o2.category = o.category AND o2.amount > o.amount) < 2 のような書き方は、外側 N 行 × 内側 N 行 = N² の計算量になりがちです。ROW_NUMBER で1パスにする方が圧倒的に速く、読みやすくもなります。
同値があるのに ROW_NUMBER を使う:amount に同値があり、「同順位は両方残したい」のに ROW_NUMBER を使うと同値の片方が任意に切られます。同順位を尊重したいなら RANKDENSE_RANK。「同値の扱いをどうしたいか」を仕様レベルで決めてから関数を選ぶのが本筋です。
実務コラム:ウィンドウ関数の「窓」は無限に応用できる
ROW_NUMBER / RANK だけでなく、SUM(...) OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN ...) のように「窓の中で累計・移動平均・前後行参照」まで行えます。累計売上(partition なし + ORDER BY date)、顧客毎の累計(PARTITION BY customer_id + ORDER BY date)、3ヶ月移動平均(ROWS BETWEEN 2 PRECEDING AND CURRENT ROW)など、レポート系の頻出指標はほぼ全部ウィンドウ関数1本で書けます。性能面ではpartition と order_by の組み合わせに合うインデックスを貼ると、ウィンドウのソート工程が省略されます。最終問題の Q10 では、ページング処理を「OFFSET の罠 → Keyset Pagination」で本質的に解決する方法を扱います。
QUESTION 10

Keyset Pagination — OFFSET の罠を seek method で解決する

Keyset Paginationseek methodOFFSET罠ページング
前提知識

ページングの定番 LIMIT ... OFFSET ... は浅いページなら問題ありませんが、OFFSET が大きくなるほど線形に遅くなる致命的な性質があります。OFFSET 10000 なら「先頭から10000行を読んで捨ててから次の20行を返す」動作で、ページが深くなるほどコストが膨らみます。Keyset Pagination(seek method)は「前ページ最後の値より大きい行から LIMIT 件」を取る方式で、ページ位置に依らず一定時間です。

-- ✗ OFFSET 方式:深いページで線形に遅くなる
SELECT ... FROM orders ORDER BY order_id LIMIT 20 OFFSET 10000;

-- ✓ Keyset 方式:前ページ最後の order_id より後ろを直接取得
SELECT ... FROM orders
WHERE  order_id > 10000          -- 前ページ最後の order_id
ORDER BY order_id LIMIT 20;
Keyset の前提:(1) ORDER BY の列にインデックスがある、(2) その列が一意(または「最終値 + tie-breaker 列」で一意になる)、(3) クライアントが「直前ページの最後の値」を覚えている。これらが揃っていれば、OFFSET の何百万倍も速くなります。
問題

orders(100万行)の order_id 昇順で 20 件ずつページングします。「直前ページの最終 order_id = 10000」が分かっている前提で、次の20件を Keyset Pagination で取得してください。出力列は order_id, customer_id, amount

使用テーブル(概念図・10行)
- orders(実体100万行 / order_id=9998〜10025 付近を抜粋)
order_idcustomer_idamount
99983011100
9999302900
10000 ←前ページ末尾3031300
10001304800
100023052200
.........
100203231700
10021324950
期待出力
order_idcustomer_idamount
10001304800
100023052200
100033061400
.........
100193221600
100203231700
模範解答コード
-- 【Keyset Pagination】前ページの最終 order_id より後ろを index で直接取得
SELECT   order_id, customer_id, amount
FROM     orders
WHERE    order_id > 10000              -- 前ページ末尾の order_id(クライアントが保持)
ORDER BY order_id                       -- index と同じ並び → 追加ソート不要
LIMIT    20;                            -- 20件で打ち切り

/*
  実行順序(プランナの動き):
  1. orders_pkey で order_id 直後のリーフに到達  → 二分探索で到達
  2. リーフを右に辿り必要行を取得                    → 必要件数で打ち切り
  3. テーブル本体から SELECT 列を取得              → 列を取得
  */
解説(テーブル変化・ポイント)
SELECT order_id, customer_id, amount FROM orders WHERE order_id > 10000 ORDER BY order_id LIMIT 20;
LEGEND
データ取得・読込対象
1. 対象テーブル — orders(100万行・order_id に PK 索引)
FROM ordersorders は order_id を主キーに持つ100万行のテーブル。order_id=10000 付近を表示しています。「20件ずつページング」したい時、深いページほど OFFSET 方式は線形に遅くなります。
1 / 5
order_idcustomer_idamount
99983011100
9999302900
10000 (前ページ末尾)3031300
10001304800
100023052200
100203231700
10021324950
100万行(order_id=10000 が前ページ末尾)
学習ポイント
OFFSET はページ深度に比例して線形に遅くなる:LIMIT K OFFSET N は内部的に「N 行を index でたどって全部捨て、その後 K 行を返す」動作です。OFFSET が小さい間は問題ありませんが、N が数万を超えるあたりから体感できる遅延が出始め、N が数百万になると実質クエリが死にます。深いページネーションが想定される画面では最初から Keyset 設計が原則です。
Keyset は ORDER BY 列の index が前提:Keyset Pagination の本質は WHERE 並び順列 > 前ページ最後の値 ORDER BY 並び順列 LIMIT K。これが Index Range Scan に乗るためには並び順列に index が必須です。複合キーで並べる場合は同じ順序の複合 indexを貼り、(created_at, order_id) > (...) のタプル比較や OR 展開で正しく境界を作ります。
無限スクロール UI と Keyset は完全に相性が良い:「次の20件」「もっと読む」型の UI では直前ページの最後の order_idをクライアントが持っていれば良いだけなので、Keyset Pagination が自然に成立します。逆に「N ページ目に飛ぶ」UI は OFFSET が必要で本質的に深いページが遅くなるため、UX 設計の段階で「ランダムアクセスが本当に必要か」を問い直す価値があります。
アンチパターン
OFFSET 100万で「最終ページに飛ぶ」を放置:管理画面の「最終ページ」リンクがそのまま OFFSET 大値 を生成する設計は、データ増加で突然死します。最終ページが本当に必要なら、ORDER BY を逆順にして OFFSET 0 から取るのが代替手段です(並び順を反転させると「最後の K 件」は「逆順の最初の K 件」になる)。
ORDER BY が一意でないのに Keyset を使う:ORDER BY created_at LIMIT 20 で created_at に重複があると、境界で行が欠ける/二重表示される事故が起きます。必ずtie-breaker 列(多くは主キー)を ORDER BY に足し、WHERE もタプル比較するのが原則です。「ORDER BY は一意であるべし」と覚えておくとバグが激減します。
実務コラム:高速なページングは「全件カウント(COUNT)」を捨てることから
OFFSET 方式の遅延と並んで、ページング画面を重くする最大の元凶が ページ数を表示するための SELECT COUNT(*) です。数百万件のテーブルでは、検索条件に一致する全件を数えるだけで秒単位の時間がかかります。Keyset Pagination を導入して「もっと見る」「次へ」型の UI に変更した場合、もはや総ページ数は不要になります。実務でのベストプラクティスは、「1ページの表示件数が 20 件なら、DB からは LIMIT 21 で取得する」ことです。21件目が存在すれば「次のページがある」と判定して「次へ」ボタンを表示し、実際の画面には20件だけを表示します。この LIMIT K+1 手法 と Keyset を組み合わせることで、DB への負荷を最小限に抑えた超高速な一覧画面が完成します。