ページネーション設計 - オフセット方式とカーソル(キーセット)方式の使い分け

ページネーション設計 - オフセット方式とカーソル(キーセット)方式の使い分け

作成日:
読了:8
更新日:

一覧を「次へ」で分割表示するページネーション。素朴に LIMIT/OFFSET で実装すると、データが増えた途端に遅くなり、一覧が動くと行が重複・欠落します。この記事では、2大方式(オフセットカーソル/キーセット)の違いと使い分けを、PostgreSQL 公式・Use The Index, Luke・Stripe・Slack を一次ソースに整理します。

2つの方式

  • オフセット方式: LIMIT n OFFSET m(=page番号)。「先頭から m 件飛ばして n 件」
  • カーソル/キーセット方式: 前ページの最後の行の値を基準に「その続き」を取る

オフセット方式

オフセット方式(深いページは遅い)
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 100000;
  • 利点: 任意ページへジャンプできる、総件数・総ページ数を出せる、実装が簡単
  • 欠点その1(性能): PostgreSQL 公式いわく「OFFSET でスキップした行もサーバー内で計算されるため、大きな OFFSET は非効率」。OFFSET 100000 は10万行を処理してから捨てます
  • 欠点その2(ページずれ): ページ送りの最中に挿入・削除が起きると、行が重複したり欠落します(page drift)

WARNING

ORDER BY が一意でない LIMIT/OFFSET は危険です。PostgreSQL 公式も「順序を一意に固定しないと、毎回予測不能な部分集合が返る」と警告しています。必ず id などでタイブレークを入れます。

カーソル(キーセット)方式

前ページの最後の行のキー(例: created_at, id)を使い、WHERE で「まだ見ていない範囲」だけを取る方式です。

キーセット方式(インデックスで高速)
-- 複合インデックスが前提
CREATE INDEX idx_posts_created_id ON posts (created_at DESC, id DESC);
 
-- 前ページ末尾の値 (created_at, id) を受け取って続きを取得
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < ('2024-01-15', 42)
ORDER BY created_at DESC, id DESC
LIMIT 10;
  • 利点: 大規模でも一定の速さ(インデックスで開始位置へ直行)、挿入・削除に強く安定
  • 欠点: 任意ページへ飛べない(前後のみ)、総ページ数を出しにくい一意なタイブレークが必須
  • インデックスが要: ORDER BY の列=インデックスの列にする。仕組みはデータベースインデックス入門を参照

(created_at, id)行値コンストラクタ比較で複合インデックスを効かせます(PostgreSQL は最適化可。MySQL は最適化に制限がある場合があり要確認)。

比較表

観点オフセットカーソル(キーセット)
深いページの性能悪化(スキップ分を走査)一定(インデックスで直行)
ページずれ起きる起きにくい
任意ページジャンプ可能不可(前後のみ)
総ページ数出せる出しにくい
実装難易度簡単やや複雑
向く用途管理画面・検索結果無限スクロール・大規模・更新が多い

カーソルと API 設計

カーソルは不透明(opaque)に保つのが定石です。キー値を Base64 等でエンコードし、クライアントには中身を解析させません(内部実装を変えやすく、IDの露出も防げる)。Stripe・Slack・GitHub も同方針です。

Stripe 方式(starting_after / has_more)
GET /v1/customers?limit=10&starting_after=cus_xyz
{ "has_more": true, "data": [ { "id": "cus_xyz" } ] }
Slack 方式(next_cursor を不透明文字列で)
{ "ok": true, "messages": [], "response_metadata": { "next_cursor": "dGVhbTpDMDYx" } }

GraphQL では Relay Cursor Connectionsedges { node, cursor }pageInfo { hasNextPage, endCursor }firstafter)が事実上の標準です。

NOTE

総件数の SELECT COUNT(*) は大規模テーブルで重いので、カーソル方式では「次があるか(has_more)」だけ返す(= LIMIT n+1 で1件多めに取り、あふれたら次ありと判断)と軽く済みます。

使い分け

  • 管理画面で任意ページに飛びたい・件数を見せたいオフセット(データ規模が中程度まで)
  • 無限スクロール・APIの一覧・大規模・更新が多いカーソル(キーセット)
  • 併用も可(一覧はカーソル、管理画面だけオフセット 等)

よくある落とし穴

  • ORDER BY が一意でない: 同値の並びが不定になり重複・欠落。id でタイブレーク
  • OFFSET の性能劣化を見落とす: 開発時は小規模で速く、本番で増えてから露見
  • 毎回 COUNT(*): 重い。has_more 方式に
  • カーソルにソートと違う列: 正しい位置へ飛べない。カーソル=ORDER BY の列に揃える
  • カーソルをクライアントで解析・改ざん: 不透明前提で設計する

まとめ

  • オフセットは任意ジャンプ・総件数が強み、ただし深いページで遅く・ページずれ
  • カーソル(キーセット)は大規模でも高速・安定、ただし任意ジャンプ・総ページ数が苦手
  • キーセットは (sort_col, id) の複合インデックス+一意タイブレークが肝
  • カーソルは不透明に。API は next_cursor / has_more(GraphQL は Relay Connections)
  • 管理画面=オフセット、API/無限スクロール=カーソルが基本線

「とりあえず OFFSET」で始めても、規模が増えると性能とページずれで詰みがちです。一覧APIは最初からキーセットを選んでおくと、後が楽になります。

参考リンク

データベースインデックス入門 - B-treeの仕組みと、効くクエリ・効かないクエリ

データベースインデックス入門 - B-treeの仕組みと、効くクエリ・効かないクエリ

10

データベースのインデックスを実務目線で整理します。フルスキャンとの違い、B-tree インデックスがなぜ速いのか、等価・範囲・前方一致・ORDER BY・JOIN で効く理由、複合インデックスの左端プレフィックス、カバリングインデックス(index-only scan)、列に関数を使うと効かない・前方ワイルドカード LIKE が効かないといった落とし穴、書き込みコストやストレージのトレードオフ、InnoDB のクラスタ化インデックスと PostgreSQL の違い、EXPLAIN の読み方まで、PostgreSQL・MySQL 公式と Use The Index, Luke を一次ソースにまとめます。

Webhook 受信側のベストプラクティス - 署名検証・リトライ・冪等性・順序

Webhook 受信側のベストプラクティス - 署名検証・リトライ・冪等性・順序

9

Webhook を安全・堅牢に受け取るための設計を実務目線で整理します。HMAC-SHA256 による署名検証とリプレイ攻撃対策(タイムスタンプ)、生のボディで検証する理由、定数時間比較、at-least-once 配信を前提にした冪等な処理、保証されない配信順序への備え、まず素早く 2xx を返して非同期処理する受信パターン、未知イベントの扱い、シークレットのローテーションや CSRF 除外まで、Stripe・GitHub・Standard Webhooks を一次ソースにまとめます。