Article
🐘 PostgreSQLのページネーションをOFFSETからKeysetへ:重複を防ぐSQL設計
個人開発しているもの
日々の開発で得た知識を発信しながら、「あったら便利」「作ってみたい」と思ったアイデアを形にしています。
これまでに、グループ旅行アプリ Trip Supporter、スコア管理サービス PointPia、リアルタイム共有サービス JustNow などを開発しました。
さらに、刺繍やオリジナルキャラクターを中心としたアパレルブランド MAD TOMMY WITH... も運営しています。
開発したサービスや技術記事は chicchi.work で紹介しています。
記事一覧や履歴APIの「次の20件」を取得するとき、LIMIT 20 OFFSET 10000 のようなSQLを書いていないでしょうか。
OFFSET は理解しやすい反面、ページが深くなるほど読み飛ばす行が増えます。また、ページを移動する途中で新しい行が追加されると、同じ行が再登場することもあります。
今回は、**Keyset Pagination(キーセット方式、Seek Paginationとも呼ぶ)**へ切り替えるSQLを、実際に試せるデータ付きで解説します。これは、前ページ最後の行の「並び順の値」を使って、次の行から読み進める方法です。
対象はPostgreSQL。公式ドキュメントの
LIMIT/OFFSET、行の比較、複合インデックス、EXPLAINの説明を2026年11月5日に確認しています。性能改善の大きさはデータ量や実行計画によって異なり、本記事では未計測の速度を数値で断定しません。
1. OFFSET方式の落とし穴
典型的なページングは次のように書きます。
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;
ORDER BY:結果の順序を決めます。LIMIT 20:最大20行を返します。OFFSET 10000:先頭の10,000行を読み飛ばします。
PostgreSQL公式ドキュメントは、OFFSETで飛ばす行もサーバー内で計算する必要があり、大きなOFFSETは非効率になり得ると説明しています。また、LIMITを使う場合は一意に決まる順序をORDER BYで指定することが重要です。
created_atだけでは同時刻に作られた記事の順序が定まりません。そこで、一意なidを後ろに追加します。
より厄介なのは更新が続く一覧です。例えば1ページ3件として、最初に [6, 5, 4] を表示した後、新しい行7が先頭に追加されたとします。次にOFFSET 3を実行すると、[4, 3, 2] が返り、4が重複します。
2. 再現用のテーブルとデータ
以下は空の検証用データベースで実行する例です。既存テーブルがある本番環境にそのまま流さないでください。
CREATE TABLE posts (
id bigint PRIMARY KEY,
created_at timestamptz NOT NULL,
title text NOT NULL
);
INSERT INTO posts (id, created_at, title) VALUES
(1, '2026-11-05 09:00:00+09', 'post-1'),
(2, '2026-11-05 09:10:00+09', 'post-2'),
(3, '2026-11-05 09:10:00+09', 'post-3'),
(4, '2026-11-05 09:20:00+09', 'post-4'),
(5, '2026-11-05 09:30:00+09', 'post-5'),
(6, '2026-11-05 09:30:00+09', 'post-6');
CREATE INDEX posts_created_at_id_desc_idx
ON posts (created_at DESC, id DESC);
bigint:大きな整数を保存する型です。実際のアプリでは自動採番列を使うこともできますが、ここでは再現性のためIDを固定しています。timestamptz:時刻をタイムゾーンを考慮して扱う型です。- 複合インデックス:複数列を組み合わせて検索・並べ替えを補助する索引です。今回は
created_atとidの降順を指定します。
まず1ページ目を取得します。
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 3;
結果のIDは6, 5, 4です。5と6は同じ作成時刻ですが、id DESCで順序が安定します。
3. Keyset方式:最後の行をカーソルにする
カーソルとは、次のページの開始位置を表す情報です。データベースのカーソル機能を使うという意味ではなく、ここでは「最後に表示したcreated_atとid」というAPI上の目印を指します。
1ページ目の最後は(2026-11-05 09:20:00+09, 4)なので、2ページ目は次のSQLです。
SELECT id, created_at, title
FROM posts
WHERE (created_at, id)
< ('2026-11-05 09:20:00+09'::timestamptz, 4::bigint)
ORDER BY created_at DESC, id DESC
LIMIT 3;
結果のIDは3, 2, 1です。
(created_at, id) < (時刻, ID)は行コンストラクターの比較です。左から順番に比較し、まず時刻が古いかを判定します。時刻が同じならIDの大小を比較します。今回はどちらも降順なので「前ページの最後より小さい組」を探します。
この書き方が成立する前提は、並び順がcreated_at DESC, id DESCで、両列がNULLでないことです。片方だけ昇順にする並びやNULLを含む並びでは、そのまま使わず比較条件を組み立て直してください。
途中で新規投稿があった場合
最初のページを読んだ後、次の行を追加してみます。
INSERT INTO posts (id, created_at, title)
VALUES (7, '2026-11-05 09:40:00+09', 'post-7');
この状態でOFFSET 3なら4, 3, 2となり、4が重複します。一方、前述のKeysetの2ページ目は3, 2, 1のままです。新しい行が先頭に挿入されても、位置のずれに引きずられにくいことが分かります。
ただし、Keyset方式も「複数リクエストをまたいで完全に同じ一覧を再現する」機能ではありません。行の削除やcreated_atの変更、フィルター変更などによって見え方は変わります。完全なスナップショットが必要なら、データのバージョン固定など別の設計を検討します。
4. APIの実装で必要なこと
実際のバックエンドでは、カーソル値を文字列連結でSQLへ埋め込まず、プレースホルダーを使います。プレースホルダーとは、値をSQL本文とは別にバインドするための印です。
-- $1: 最後に表示したcreated_at
-- $2: 最後に表示したid
-- $3: ユーザーに見せる件数(例: 20)
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < ($1::timestamptz, $2::bigint)
ORDER BY created_at DESC, id DESC
LIMIT ($3 + 1);
LIMIT (件数 + 1)にして、表示予定数より1行多く取得します。例えば20件表示するAPIなら21件を読み、21件目があればhasNext: trueと判断します。クライアントに返すのは先頭20件だけで、次のカーソルは返した20件の最後の行から作ります。21件目をカーソルにすると1行飛ばしてしまうので注意してください。
初回リクエストにはカーソルがないため、WHERE条件を省いたクエリを使います。APIのレスポンスは、例えば次の形です。
{
"items": [
{ "id": 6, "title": "post-6" },
{ "id": 5, "title": "post-5" },
{ "id": 4, "title": "post-4" }
],
"hasNext": true,
"nextCursor": "サーバーが発行するカーソル文字列"
}
※このJSONはレスポンス形状の例です。上記SQLデータは6行あるので、3件表示ならhasNextはtrueになります。
カーソル文字列には時刻とIDの組をエンコードできます。Base64などによるエンコード自体に改ざん防止効果はありません。サーバーでは形式、型、ページサイズ、テナント・フィルター条件を検証し、必要なら署名を付けます。カーソルを権限チェックの代わりにはせず、アクセス制御は毎回SQL側で適用します。
テナント別の一覧なら、例えばWHERE tenant_id = $1 AND (created_at, id) < (...)のように絞り込みます。その場合は(tenant_id, created_at DESC, id DESC)のようなインデックスも候補になります。実データの選択性と実行計画を見て決めてください。
5. 効果を測る:EXPLAIN (ANALYZE, BUFFERS)
PostgreSQLは、SQLをどの順序・方法で実行するかを実行計画として選びます。インデックスを作れば必ず採用されるわけではありません。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < ('2026-11-05 09:20:00+09'::timestamptz, 4::bigint)
ORDER BY created_at DESC, id DESC
LIMIT 20;
EXPLAIN:DBが選んだ実行計画を表示します。ANALYZE:実際にクエリを実行し、処理時間や行数などを計測します。更新系SQLで使う場合は実際に変更が起きるので要注意です。BUFFERS:共有バッファなどへのアクセス情報を表示します。
比較時は、同程度の結果件数、十分なデータ量、適切なカーソル位置を使い、actual rows・実行時間・バッファアクセスを確認します。上記の6行だけの例では深いページの性能差を適切に測れません。また、キャッシュ状態や統計情報によって計測値は変動します。
6. 使い分けとハマりどころ
| 要件 | 候補 | 理由 |
|---|---|---|
| 「次を読み込む」無限スクロール | Keyset | 既知の終端位置から読み進めやすい |
| 更新頻度が高い新着一覧 | Keyset | 先頭追加によるOFFSETの重複を避けやすい |
| 任意の100ページ目へジャンプ | OFFSETなど | Keysetは前のカーソルなしに任意ページへ直接飛びづらい |
| 小規模・更新の少ない管理画面 | OFFSET | 実装の単純さが勝ることもある |
| 厳密な固定時点の一覧 | 別途スナップショット設計 | Keysetだけでは複数リクエスト間の整合性を保証しない |
特に次の点で設計が崩れがちです。
- 同じ時刻の行を考慮しない:
created_at < カーソル時刻だけでは、同時刻の未取得行を飛ばすことがあります。一意なidをタイブレーカー(同値時の決着用のキー)にします。 - ソート列が途中で変更される:並び順のキーが変われば、重複・取りこぼしが生じ得ます。新着一覧なら通常変更されない作成日時などを選びます。
- インデックスとORDER BYが合っていない:複合インデックスの列順・昇降順・絞り込み条件を確認します。
- カーソルに認可情報を任せる:カーソルは位置情報であってアクセス権限の証明ではありません。
- 件数+1を返却範囲に混ぜる:余分に取得した1行は存在確認用で、次ページを飛ばす材料にしません。
まとめ
OFFSETは簡潔ですが、深いページほど読み飛ばす行が増え、新しい行が先頭に追加される一覧ではページ境界の重複が起き得ます。created_at DESC, id DESCのように一意な並び順を用意し、最後のキーで次を探すKeyset方式は、続き読み型APIに向いた選択肢です。
ただし、万能な最適化ではありません。必要なページ移動UX、データ更新の仕方、インデックス、認可条件を合わせて設計し、EXPLAIN (ANALYZE, BUFFERS)で実測してください。