定義
リストを「何ページ目」ではなく 「最後に見た項目の次から」 取得したいときに使うページネーション方式である。各項目に付いたソート基準値(ソートキー)を座標として、その値以降(または以前)の行だけを要求する。
正確には、Keyset pagination(別名 cursor pagination、seek method)は WHERE (sort_key, id) < :cursor ORDER BY sort_key, id LIMIT n の形で、「n 番目から数えよ」(offset)の代わりに カーソル(cursor — 最後に見た行のソートキーを収めた不透明トークン) が指す地点をインデックスで直接見つけ(seek)、その次のまとまりだけを読む。
なぜ必要か
整数 offset でページを送ると、3 つの問題が生じる。
- 後ろに行くほど遅くなる。
LIMIT 10 OFFSET 10000は DB が先頭 1 万行を実際に数え切ってから 10 件を返す — O(offset)。ページが深くなるほどコストが線形に増える。 - 挿入・削除で項目がずれる。 1 ページ目を見ている間に先頭に新しい記事が 1 件追加されると、2 ページ目の要求時にさっき見た最後の記事が再び出てくる(重複)。削除されると逆に飛ばされる(欠落)。無限スクロールのようにリストがリアルタイムに伸びる画面では特に頻繁に壊れる。
- 「3 ページ目」という座標自体が不安定である。 順序が変われば、同じページ番号が別の内容を指す。
keyset は座標をページ番号ではなく 値(最後に見た項目のソートキー)に付ける。間に何が挿入・削除されても「この値の次」は変わらないため重複・欠落がなく、インデックス seek なので O(log n) で深いページも一定して速い。
動作原理
ソートキー k と、同点を崩す tiebreaker(通常は id)で作った複合座標 (k, id) をカーソルとして使う。
- カーソルのエンコード:
(created_at, id)を base64 などの 不透明トークン で包んでクライアントに渡す。クライアントが内部構造に依存できないようにし、後でソート方式が変わっても API を壊さないようにする。 - 双方向(before / after): 無限リストは下方向にも(より古いもの、
after)上方向にも(より新しいもの、before)伸びうる。beforeは不等号とORDER BYの向きを反転して取得したうえで、結果の順序を再び反転して クライアントに渡す(画面の表示順を合わせるため)。 - インデックス:
(sort_key, id)の 複合インデックス があってこそ seek が O(log n) になる。インデックスがなければ keyset もフルスキャンする。
実務での適用
無限スクロール API の典型的な契約:
- 最初の要求 はカーソルなしで始める(
firstPageParam = null)。双方向リストは前(prevCursor)・後(nextCursor)の 2 つのカーソルを分けて管理する。 - クライアントが上にスクロールすれば
dir=before&cursor=prevCursor、下にスクロールすればdir=after&cursor=nextCursor。 - 入力検証は境界で: パース不能なカーソルや範囲外の
limitは 400 で即座に拒否 する。黙って最初のページにフォールバックすると、クライアントは位置を失ったことに気づけない。
React Query 系の無限クエリと組み合わせる際は、getNextPageParam/getPreviousPageParam がレスポンスの nextCursor/prevCursor をそのまま返すようにし、保持ページ数の上限(maxPages) を設けて古いページを evict する(メモリを bound する)。
トレードオフ
| 項目 | offset | keyset |
|---|---|---|
| 深いページの性能 | O(offset)、遅くなる | O(log n)、一定 |
| 挿入・削除の安定性 | 重複・欠落が発生 | 安定 |
| 「N 番目のページへジャンプ」 | 容易 | 困難(任意ページへの直接移動不可) |
| 総ページ数の表示 | 可能 | 別途 count クエリが必要 |
| 実装の難易度 | 低い | 複合ソート・カーソルエンコードが必要 |
keyset の最大の代償は 任意ページへのジャンプができない ことである。「5 ページ目へ直接移動」のような UI が必要なら、offset かハイブリッドの方がよい。
使ってはいけない場合
- 番号付きページネーション UI(1 2 3 … 10 ジャンプ)が要件なら keyset は合わない。
- ソート基準が毎リクエスト自由に変わる 画面 — カーソルは特定のソート
(k, id)に紐づくため、ソートを変えるとカーソルを捨てて最初からやり直す必要がある。 - データが 小さくほとんど変わらない リストなら、offset の単純さが得になる(YAGNI)。
よくある失敗
- tiebreaker の欠落: ソートキーに同点があるのにカーソルへ
idのような一意値を入れないと、境界で行が欠落したり重複したりする。常に(sort_key, unique_id)の複合座標を使う。 - 複合インデックスがない:
(sort_key, id)インデックスがないと seek がフルスキャンに落ち、keyset の性能上の利点が消える。クエリプランを実際に確認する。 - 双方向で順序を反転しない:
beforeの取得結果を reverse せずにそのまま渡すと、画面で項目が逆順に積み上がる。 - 不正なカーソルを黙ってフォールバック: パース失敗時に最初のページを返すと、ユーザーは位置を失ったことに気づかない — 400 で明示的に失敗させる。
- maxPages 上限がない: append-only の無限スクロールでページを無限に積むと、ヒープが増え続ける。
関連概念
- fractional-index-ordering — 挿入時に隣を変えない安定ソートキーを作る別のアプローチ
- list-virtualization-windowing — keyset で受け取ったページを、画面に見える分だけレンダリング
- virtual-list-scroll-restore-authority — 双方向 fetch 後に戻る/再読み込みの位置を復元