VillageSQL is a drop-in replacement for MySQL with extensions.
All examples on this page work on VillageSQL. Install Now →
LIMIT 20 OFFSET 200000 は 200,020 行を読み、そのうち 200,000 行を捨てます。これはバグではなく、OFFSET の意味そのものです。サーバーには任意の並び順で 200,001 行目に直接ジャンプする方法がないため、そこまで歩いて行きます。1 ページ目は速く、10,000 ページ目は遅く、その遅さはページの深さに比例して増えます。キーセットページネーションは、先頭から数えるのではなく、直前のページがどこで終わったかを覚えておくことでこれを解決します。
OFFSET ページネーション: 問題が出るまでは問題ない
おなじみのパターンです。- コストがページ番号とともに増える。
OFFSET 200000は、何かを返す前に 200,000 件のインデックスエントリをスキャンして捨てます。 - リクエストの間に行がずれる。 ユーザーがページをめくっている間に行が挿入されると、ページの境界が動き、ユーザーは重複を見るか、行を見逃します。
キーセットページネーション: 止まった場所を覚える
行を数える代わりに、クライアントが最後に見た値で絞り込みます。WHERE id > 20 句によって、サーバーはインデックス内を直接シークし、ページの深さに関係なくちょうど 20 行だけを読めます。10,000 ページ目のコストは 1 ページ目と同じです。境界は位置ではなく値なので、挿入された行がページの境界をずらすこともありません。
トレードオフ: キーセットページネーションが提供するのは「次へ」と「前へ」であり、「47 ページ目へジャンプ」ではありません。無限スクロールや API では、たいていそれが本来望んでいたインターフェースです。
キー以外で並べ替える
実際の一覧がid で並ぶことはまれです。一意でない列で並べ替える場合はタイブレーカーが必要です。そうしないと、同じ値を持つ行がページをまたいでスキップされたり繰り返されたりします。クライアントは最後に見た行の両方の値を送り返し、クエリは両方で比較します。
WHERE (created_at, id) > ('2026-08-01 12:00:00', 143) という、より短い行コンストラクタの書き方もあります。同じ行を返しますが、MySQL のオプティマイザはこれをインデックスの範囲シークに変換しません。EXPLAIN は、行コンストラクタに対してはフルスキャン(クエリがインデックスでカバーされるかどうかに応じて type: ALL または type: index)を、上に示した展開形に対してはシーク(type: range)を示します。展開形を使用してください。
どちらの場合も、インデックスは ORDER BY と一致していなければなりません。
EXPLAIN でプランを確認してください。そのインデックスに対する type: range が表示され、filesort がないことが望ましい状態です。何を見るべきかについては、EXPLAIN の読み方を参照してください。
総件数は必要ですか?
大きな絞り込み済みセットに対するSELECT COUNT(*) は、ページクエリ自体よりもコストがかかることがあります。キーセットページネーションの上に構築されたインターフェースは、たいてい正確な件数をやめて「さらに読み込む」という操作に置き換えます。プロダクトが本当に件数を必要とする場合は、リクエストごとに数えるのではなく、キャッシュするか集計テーブルで管理してください。
どちらを使うか
関連項目
- MySQL のインデックス — キーセットページネーションが依存するインデックス設計
- EXPLAIN の読み方 — スキャンではなくシークであることの確認
- 主キーの戦略 — 並べ替え可能なキー(UUIDv7 など)は良いカーソルになります
- MySQL の LIMIT — LIMIT と OFFSET に関するチュートリアルのレッスン
- MySQL の ORDER BY — ページングが依存する並べ替え