テック技報最新号へ →
コード

Postgres: 深いページは OFFSET ではなくキーセット(カーソル)ページングで取る

OFFSET 100000 LIMIT 20 は読み飛ばす 10 万行も実際に読むので、ページが深いほど遅くなる。「前のページの最後の行」より後ろを WHERE (created_at, id) < ($1, $2) で絞ると、どの深さでもインデックスを少し読むだけで済む。

@amitsinha__07 · 投稿 2026-10-26
#postgres#infra#sql#pagination#performance

OFFSET が遅い理由

OFFSET n は「n 行を捨てる」処理なので、捨てる行もインデックスやテーブルから読み出している。OFFSET 100000 LIMIT 20 なら 100,020 行を読んで 20 行を返す。一覧画面の無限スクロールや、バッチで全件を順に処理するときに効いてくる。さらに、読んでいる途中で行が追加・削除されると、ページの境目で行が重複したり抜けたりする。

やり方

sql
-- インデックス(並び順と同じ列の組み合わせ)
CREATE INDEX CONCURRENTLY posts_created_at_id_idx ON posts (created_at, id);

-- 1ページ目
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- 2ページ目以降: 前ページの最後の行の (created_at, id) を渡す
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
  • (a, b) < (x, y) は行値の比較で、「a < x、または a = x かつ b < y」と同じ意味。Postgres はこの形をそのままインデックスの範囲検索に使える。
  • id を足しているのは、created_at が同じ行が複数あっても順番を一意に決めるため(タイブレーカー)。これがないと境目の行が抜ける。
  • API では最後の行の (created_at, id) を不透明なカーソル文字列(Base64 など)にして返すと扱いやすい。

注意点

  • 「5 ページ目へ直接飛ぶ」はできない。前後へ順にたどる UI(もっと見る、無限スクロール)向き。総件数やページ番号が必要なら別に数える。
  • ORDER BY の向きと < / > を揃える(降順なら <、昇順なら >)。混在(created_at DESC, id ASC)だと行値比較が使えないので、並び順は揃える。
  • created_at が NULL になりうる列だと比較から漏れる。NOT NULL にしておく。
  • MySQL も行値比較は書けるが、インデックスの使われ方は版によって違うので EXPLAIN で確認する。

出典

X の元の投稿を開く