仕組みから学ぶ Web
応用読了目安 14#ページネーション#offset#カーソル#API 設計

ページネーション — 数えて飛ばすか、続きから取るか

offset は「ページ番号」という画面の都合から来ている。件数が増えると遅くなり、データが動くと境界がずれる。カーソル方式が何を解いて、何を捨てるのか。

この記事の進み方

What — offset が何をしているのか

?offset=10000&limit=20 は「10001件目から20件」という意味です。データベースがこれをどう処理するかを見てください。

Figureoffset=10000 を渡したとき、データベースが何をしているか
  1. 1索引の先頭から読む並び順に沿って、1件目から軽い
  2. 210000件を読み捨てる返さないものを、返さないために読む重い
  3. 3次の20件を返すようやく必要な分軽い
  4. 41ページ進む捨てる量が 10000 → 10020 に増える重い

カーソル方式なら、この工程の最初の2つが消える。「どこから読み始めるか」が値で分かるので、数えずに到達できる。

1/4
索引の先頭から読む10001件目を直接指す手段は無い。順番に数えるしかない。ここが offset の正体。

10000件は捨てるために読まれる。ページが後ろへ行くほど、捨てる量が増える。

この「数える」性質から、2つの別々の問題が生まれます。遅さずれです。

Compare位置の指し方

先頭から何件目か、で位置を表す

GET /logs?offset=10000&limit=20
深いページの速さ
ページが進むほど遅い
途中で増減したとき
抜けるか重複する
任意のページへ飛ぶ
できる
総ページ数の表示
できる
並び順の変更
そのまま効く
  • 画面に「1 2 3 … 42」を出せるのは、この方式だけ
  • 件数が少なく、データが動かない管理画面では十分
  • ずれは件数に関係なく起きる。少なければ安全、ではない

遅さとずれは別の問題。offset は両方を抱え、カーソルは両方を同時に解く。偶然ではなく、位置の表し方が違うから。

オフセット
先頭から数えた位置。10000 件目から、のような指定。
カーソル
最後に読んだ行を指す値。次はここより後ろ、という起点になる。
キーセットページング
並び替えに使う列の値で範囲を絞る方式。カーソル方式の実体。
タイブレーカー
並び替えの値が同じ行を一意に決めるために添える列。id を使うことが多い。

ずれは「並び順」で形が変わる

冒頭の事故は「抜ける」形でしたが、同じ仕組みから「重複」も起きます。

並びページを読む途中で起きること
新しい順新しい行が増える全体が後ろへずれ、読み飛ばす
古い順新しい行が増える末尾に足されるだけなので影響なし
新しい順途中の行が消える全体が前へずれ、同じ行を2回読む
古い順途中の行が消える同じく前へずれ、読み飛ばす

どちらが起きるかは、増減の向きと並びの組み合わせで決まります。 「重複するだけなら害がない」と考えるのは危険で、同じ設計が条件次第で抜ける側に回ります。

確認 — ここまで読めたか

新しい順の一覧を offset で読んでいます。1ページ目と2ページ目の間に新しい行が3件追加されました。何が起きますか?

まず選ぶ(解答例は a〜d の記号で説明します)

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 だけをカーソルに使うと、どんな問題が起きますか?

まず選ぶ(解答例は a〜d の記号で説明します)

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

この条件で、一覧 API のページングをどう設計しますか?

与えられた条件
  • 社内の管理画面。注文の一覧を表示する
  • 注文は営業時間中、毎分10〜30件のペースで増える
  • オペレーターは新しい順に見て、上から処理していく
  • 「全部で何件か」を画面に出したいという要望がある
  • 月末に、その月の全件を CSV に書き出すバッチもこの API を使う
この軸で考える
  • · 読んでいる最中にデータが増えることの影響
  • · 画面の要望と、バッチの正確さのどちらを優先するか
  • · 件数の表示は、ページングと同じ仕組みで解く必要があるか
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

「offset で十分では?」と言うメンバーに、何を確認すべきか説明してください

与えられた条件
  • 相手はページ番号を出したいという要望を受けている
  • 扱う一覧は、常に新しいデータが入ってくる種類のもの
  • カーソル方式を知らないわけではない。必要性を感じていない

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。