Postgres: 深いページは OFFSET ではなくキーセット(カーソル)ページングで取る
OFFSET 100000 LIMIT 20 は読み飛ばす 10 万行も実際に読むので、ページが深いほど遅くなる。「前のページの最後の行」より後ろを WHERE (created_at, id) < ($1, $2) で絞ると、どの深さでもインデックスを少し読むだけで済む。
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で確認する。
出典
- @amitsinha__07 · 2026-10-26
- PostgreSQL ドキュメント「行コンストラクタの比較」: postgresql.org