ページネーション — 数えて飛ばすか、続きから取るか
offset は「ページ番号」という画面の都合から来ている。件数が増えると遅くなり、データが動くと境界がずれる。カーソル方式が何を解いて、何を捨てるのか。
この記事の進み方
What — offset が何をしているのか
?offset=10000&limit=20 は「10001件目から20件」という意味です。データベースがこれをどう処理するかを見てください。
- 1索引の先頭から読む並び順に沿って、1件目から軽い
- 210000件を読み捨てる返さないものを、返さないために読む重い
- 3次の20件を返すようやく必要な分軽い
- 41ページ進む捨てる量が 10000 → 10020 に増える重い
カーソル方式なら、この工程の最初の2つが消える。「どこから読み始めるか」が値で分かるので、数えずに到達できる。
10000件は捨てるために読まれる。ページが後ろへ行くほど、捨てる量が増える。
この「数える」性質から、2つの別々の問題が生まれます。遅さとずれです。
先頭から何件目か、で位置を表す
GET /logs?offset=10000&limit=20- 深いページの速さ
- ページが進むほど遅い
- 途中で増減したとき
- 抜けるか重複する
- 任意のページへ飛ぶ
- できる
- 総ページ数の表示
- できる
- 並び順の変更
- そのまま効く
- –画面に「1 2 3 … 42」を出せるのは、この方式だけ
- –件数が少なく、データが動かない管理画面では十分
- –ずれは件数に関係なく起きる。少なければ安全、ではない
遅さとずれは別の問題。offset は両方を抱え、カーソルは両方を同時に解く。偶然ではなく、位置の表し方が違うから。
- オフセット
- 先頭から数えた位置。10000 件目から、のような指定。
- カーソル
- 最後に読んだ行を指す値。次はここより後ろ、という起点になる。
- キーセットページング
- 並び替えに使う列の値で範囲を絞る方式。カーソル方式の実体。
- タイブレーカー
- 並び替えの値が同じ行を一意に決めるために添える列。id を使うことが多い。
ずれは「並び順」で形が変わる
冒頭の事故は「抜ける」形でしたが、同じ仕組みから「重複」も起きます。
| 並び | ページを読む途中で | 起きること |
|---|---|---|
| 新しい順 | 新しい行が増える | 全体が後ろへずれ、読み飛ばす |
| 古い順 | 新しい行が増える | 末尾に足されるだけなので影響なし |
| 新しい順 | 途中の行が消える | 全体が前へずれ、同じ行を2回読む |
| 古い順 | 途中の行が消える | 同じく前へずれ、読み飛ばす |
どちらが起きるかは、増減の向きと並びの組み合わせで決まります。 「重複するだけなら害がない」と考えるのは危険で、同じ設計が条件次第で抜ける側に回ります。
新しい順の一覧を offset で読んでいます。1ページ目と2ページ目の間に新しい行が3件追加されました。何が起きますか?
Why — なぜ offset が主流だったのか
offset は、データの都合ではなく画面の都合から来ています。
紙の本にページ番号があるように、一覧にも「1 2 3 … 42」を出したい。任意のページへ飛びたい。全部で何ページあるか知りたい。この3つを満たすには、位置を通し番号で表す必要があります。offset はそのための素直な表現でした。
問題は、データベース側に「10001件目」を直接指す手段が無いことです。数えるしかない。 SQL の OFFSET は、読み飛ばす行を1件ずつ読んでから捨てます(data/execution-plan で見た LIMIT が効かない話と同じ構造です)。
カーソルは何を変えたのか
カーソル方式は、位置を通し番号ではなく順序の値で表します。
-- offset: 先頭から数える
SELECT * FROM logs ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 10000;
-- カーソル: 最後に見た行より後ろ
SELECT * FROM logs
WHERE (created_at, id) < ('2026-09-17 10:00:00', '01H9...')
ORDER BY created_at DESC, id DESC LIMIT 20;
下の形は、索引の上でその位置に直接到達できます。数え捨てが無いので、1ページ目も1000ページ目も同じ速さです。
そして、位置が「順序の値」で表されているので、途中で行が増減しても位置の意味が変わりません。「この時刻のこの行より後ろ」は、何件追加されても同じ行を指します。ずれが消えるのは副産物ではなく、表現を変えたことの当然の帰結です。
捨てたもの
カーソル方式は万能ではありません。任意のページへ飛べません。 「42ページ目」を出すには、そこまでの順序の値が要るからです。総件数も、別に数えないと出せません。
並び順を変えたときにカーソルが無効になるのも、同じ理由です。カーソルは並び順に紐づいた値なので、並びが変われば意味を失います。
created_at だけをカーソルに使うと、どんな問題が起きますか?
演習 — まず自分で判断する
解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。
この条件で、一覧 API のページングをどう設計しますか?
- 社内の管理画面。注文の一覧を表示する
- 注文は営業時間中、毎分10〜30件のペースで増える
- オペレーターは新しい順に見て、上から処理していく
- 「全部で何件か」を画面に出したいという要望がある
- 月末に、その月の全件を CSV に書き出すバッチもこの API を使う
- · 読んでいる最中にデータが増えることの影響
- · 画面の要望と、バッチの正確さのどちらを優先するか
- · 件数の表示は、ページングと同じ仕組みで解く必要があるか
「offset で十分では?」と言うメンバーに、何を確認すべきか説明してください
- 相手はページ番号を出したいという要望を受けている
- 扱う一覧は、常に新しいデータが入ってくる種類のもの
- カーソル方式を知らないわけではない。必要性を感じていない
読み終わりましたか?
読了にすると、これを前提とする記事がロードマップで開放されます。