1. 開発環境の罠:「OFFSET」はなぜ本番で牙を剥くのか?

Webアプリケーションの開発において、リスト取得エンドポイントを作る際に最も手軽で広く採用されているのが「オフセットベース(Offset-based)のページネーション」です。

クライアントから GET /api/v1/articles?page=3&limit=20 のようなリクエストを受け取り、バックエンドで次のようなSQLを組み立てて実行します。

-- 典型的なオフセットベースのSQL
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

このアプローチは直感的であり、画面上に [1] [2] [3] ... [50] といったページ番号UI(ページャー)を並べるのにも都合が良いため、多くのチュートリアルやフレームワークが推奨パターンとして提示しています。

そして最大の問題は、開発環境(ローカルDBやステージング環境)ではこのコードが完璧に高速に動いてしまう点にあります。テストデータが数十件〜数千件程度であれば、クエリは常に数ミリ秒で返り、何ら異常を検知することはできません。

しかし、サービスが順調に成長し、テーブルのレコード数が数十万、数百万、数千万件へと突入した瞬間、悪夢の幕が上がります。外部のスクレイピングボットやSEOクローラー、あるいは熱心なユーザーが ?page=5000 や ?page=20000 を叩き始めた瞬間、データベースのCPU使用率が100%に張り付き、ディスクI/Oが飽和して、システム全体が連鎖的に沈没するのです。

2. B+Treeを窒息させる内部挙動:OFFSETの正体は「読んで捨てる」狂気

なぜ OFFSET は深いページで急激に遅くなるのでしょうか? 多くの開発者は「データベースがオフセット位置までワープして、そこから20件だけを拾い上げてくれている」と錯覚しています。しかし、リレーショナルデータベース(RDB)のストレージエンジン内部で起きている現実はまったく異なります。

-- 50万件オフセットした20件を取得するクエリ
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC
LIMIT 20 OFFSET 500000;

このクエリが実行されたとき、PostgreSQLの実行計画(EXPLAIN)やMySQL(InnoDB)の内部挙動を追うと、戦慄すべき事実が明らかになります。

データベースは、指定されたオフセット位置に直接ジャンプすることはできない。先頭から「50万件 + 20件」の全行をインデックスから順番に読み出し、行データをフェッチしてメモリに並べたあと、最初の50万件をゴミ箱に投げ捨て、残った20件だけをクライアントに返している。

B+Treeインデックスは木構造であり、ルートノードからリーフノードへと分岐を辿ることで「特定のキー値」を検索するコストは O(log N) で済みます。しかし、インデックスは「N番目の要素」を直接指し示すポインタを持っていません。物理的に順序付けられた双方向リスト(リーフノード)を1件ずつ順番に辿っていくしかないのです。

つまり、OFFSET N LIMIT M の計算量は本質的に O(N + M) です。オフセット N が大きくなればなるほど、クエリの実行時間は線形に増大します。仮に created_at に適切なインデックスが貼られていたとしても、50万回ものインデックス走査とランダムディスクアクセス(カバリングインデックスでない場合は実テーブルのデータページ読み出し)が発生し、大量のバッファプール(メモリ)を汚染して他の正常なクエリすら巻き添えにして遅延させます。

3. UI/UXを静かに破壊する「データドリフト(幻のアイテム)」の恐怖

OFFSETページネーションが抱える罪は、パフォーマンスの崩壊だけに留まりません。本番運用においてより深刻なバグとUX破壊を引き起こすのが、**「データドリフト(Data Drift / データのズレ)」**と呼ばれる整合性崩壊です。

リアルタイムにデータが投稿されるタイムラインやECサイトの注文一覧を想像してください。あるユーザーが1ページ目(1〜20件目)を閲覧している間に、別のユーザーが新しい記事を1件投稿しました。

【初期状態】
記事一覧: [A, B, C, D, E, F, ...] (降順)
ユーザーが1ページ目(OFFSET 0 LIMIT 3)を取得:
  → [A, B, C] が返る

【1件の新規投稿 "NEW" が挿入される】
記事一覧: [NEW, A, B, C, D, E, F, ...]

【ユーザーが「次のページ(2ページ目)」をクリック】
バックエンドで実行されるクエリ:
  SELECT ... LIMIT 3 OFFSET 3;
データベースが3件スキップして取得する対象:
  → [C, D, E] が返る!

お気づきでしょうか。1ページ目の末尾に表示されていた 記事「C」が、2ページ目の先頭にもう一度表示されてしまった のです。

逆に、ユーザーが1ページ目を閲覧している間にデータが1件削除された場合、2ページ目をリクエストすると 本来2ページ目の先頭に表示されるはずだった記事がスキップされ、二度と画面に現れなくなります。

無限スクロール(Infinite Scroll)を採用したWebアプリやモバイルアプリで、「画面をスクロールしていくと同じ投稿が何度も重複して重複表示される」「さっき見かけたはずのアイテムが消えた」という怪現象に遭遇したことがあるはずです。その原因の99%は、ステートレスなHTTP環境で安易に OFFSET を使っていることに起因しています。

4. 常に O(log N) で飛び込む「Keyset Pagination(シーク法)」の原理

これら「DBフルスキャン死」と「データドリフト」の双方を根本から解決するアーキテクチャが、**Keyset Pagination(キーセット・ページネーション / シーク法)**です。モダンなAPI設計では「Cursor-based Pagination(カーソルベース・ページネーション)」とも呼ばれます。

考え方は極めてエレガントです。「何件スキップするか」ではなく、「前回の最後のアイテムのキー(Key)は何か」を基準に、それ以降のレコードを WHERE 句で直接シーク(検索)するのです。

-- Keyset Pagination による次ページ取得クエリ
SELECT id, title, created_at
FROM articles
WHERE created_at < '2026-09-20 12:30:00'
ORDER BY created_at DESC
LIMIT 20;

このクエリの内部挙動は、OFFSETとは天と地ほどの差があります。created_at インデックスのB+Treeルートから探索を開始し、'2026-09-20 12:30:00' の位置へ O(log N) で直接着地します。そしてそこからリーフノードを前方へたった20件スキャンするだけで処理が完了します。

テーブル全体のレコード数が100万件であろうが1億件であろうが、1000ページ目であろうが、データベースが読み出すレコード数は「常にたったの20件」である。

新規投稿が何千件割り込もうが、既読キー(ピボット)より前のデータだけを厳密に抽出するため、データの重複や欠落といったドリフト現象も原理的に一切発生しません。

5. 現場で踏み抜く3大実装トラップ

「なるほど、WHERE句で前の値より小さいレコードを取ればいいのか」と教科書通りに実装すると、本番環境で確実に炎上します。Keyset Paginationを現場で破綻させないための3つの鉄則を押さえましょう。

地雷①:ソートキーが一意でない「タイブレーク問題」

最も頻出するバグがこれです。created_at は同一マイクロ秒、あるいはバッチ処理によって全く同一のタイムスタンプを持つレコードが複数存在する可能性があります。

もし20件目のレコードと21件目のレコードの created_at が全く同じ値だった場合、WHERE created_at < '2026-09-20 12:00:00' でクエリを発行すると、同一タイムスタンプを持つ21件目のレコードが永久に取得されずにロストします。

これを防ぐため、必ず「ユニーク性が保証されているカラム(主キー id)」を第2ソートキーとして組み合わせ、複合ソートを行う必要があります。

-- 必須となる複合インデックス
CREATE INDEX idx_articles_created_at_id 
ON articles (created_at DESC, id DESC);

地雷②:タプル比較(Row Value Comparison)とインデックス最適化

複合ソート ORDER BY created_at DESC, id DESC をシークする場合、SQLでは次のように「タプル比較(行値コンストラクタ)」を使って記述するのが最も美しく、オプティマイザにも優しい書き方です。

-- タプル比較によるシーク(PostgreSQL, MySQL 8.0+ 対応)
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ('2026-09-20 12:00:00', 4920)
ORDER BY created_at DESC, id DESC
LIMIT 20;

PostgreSQLやMySQL 8.0以降では、このタプル比較は複合インデックス (created_at, id) を用いた単一のインデックスレンジスキャンとして最適に処理されます。

しかし、一部の古いORMやDBエンジンではタプル比較構文に対応していない場合があります。その際は以下の等価な論理式へ展開しますが、OR条件によってインデックスマージに失敗しないか必ず EXPLAIN ANALYZE で検証してください。

-- OR展開による互換構文
WHERE created_at < '2026-09-20 12:00:00' 
   OR (created_at = '2026-09-20 12:00:00' AND id < 4920)

地雷③:内部スキーマを隠蔽する「Opaque Cursor(不透明トークン)」設計

APIのクエリパラメータとして ?last_created_at=2026-09-20T12:00:00Z&last_id=4920 のように生のカラム名や値をそのまま露出させてはいけません。

実務におけるベストプラクティスは、シークに必要な内部状態をJSON形式にまとめ、それをBase64URL等でエンコードした「不透明なカーソル文字列(Opaque Cursor)」としてクライアントとやり取りすることです。

6. Go言語によるミニマルかつ堅牢な実装

外部の巨大なページネーションライブラリに依存せず、Goの標準ライブラリだけで完結する堅牢なCursorページネーションの実装例を示します。

ポイントは、「クライアントが要求した件数(LIMIT)に対して、DBからは +1 件多めにフェッチする」というテクニックです。これにより、追加のカウントクエリを発行することなく「次のページが存在するか(has_more)」を判定できます。

package repository

import (
	"context"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"time"
)

// Cursor はページネーションのピボット状態を保持する不透明トークンの中身
type Cursor struct {
	CreatedAt time.Time `json:"c"`
	ID        int64     `json:"i"`
}

// Encode はCursor構造体をBase64文字列に変換する
func (c *Cursor) Encode() string {
	if c == nil {
		return ""
	}
	data, _ := json.Marshal(c)
	return base64.RawURLEncoding.EncodeToString(data)
}

// DecodeCursor はBase64文字列からCursorを復元する
func DecodeCursor(token string) (*Cursor, error) {
	if token == "" {
		return nil, nil
	}
	data, err := base64.RawURLEncoding.DecodeString(token)
	if err != nil {
		return nil, fmt.Errorf("invalid cursor format: %w", err)
	}
	var c Cursor
	if err := json.Unmarshal(data, &c); err != nil {
		return nil, fmt.Errorf("invalid cursor payload: %w", err)
	}
	return &c, nil
}

type Article struct {
	ID        int64     `json:"id"`
	Title     string    `json:"title"`
	CreatedAt time.Time `json:"created_at"`
}

type PageResult struct {
	Items      []Article `json:"items"`
	NextCursor string    `json:"next_cursor,omitempty"`
	HasMore    bool      `json:"has_more"`
}

// FetchArticles はKeyset Paginationを用いて記事一覧を高速に取得する
func FetchArticles(ctx context.Context, db *sql.DB, cursorToken string, limit int) (*PageResult, error) {
	cursor, err := DecodeCursor(cursorToken)
	if err != nil {
		return nil, err
	}

	// 次ページの有無を判定するため、指定limitより1件多く取得する
	fetchLimit := limit + 1

	var rows *sql.Rows
	if cursor == nil {
		// 初回リクエスト(先頭ページ)
		query := `
			SELECT id, title, created_at
			FROM articles
			ORDER BY created_at DESC, id DESC
			LIMIT $1`
		rows, err = db.QueryContext(ctx, query, fetchLimit)
	} else {
		// 2回目以降のリクエスト(シーククエリ)
		query := `
			SELECT id, title, created_at
			FROM articles
			WHERE (created_at, id) < ($1, $2)
			ORDER BY created_at DESC, id DESC
			LIMIT $3`
		rows, err = db.QueryContext(ctx, query, cursor.CreatedAt, cursor.ID, fetchLimit)
	}
	if err != nil {
		return nil, fmt.Errorf("query articles failed: %w", err)
	}
	defer rows.Close()

	articles := make([]Article, 0, fetchLimit)
	for rows.Next() {
		var a Article
		if err := rows.Scan(&a.ID, &a.Title, &a.CreatedAt); err != nil {
			return nil, err
		}
		articles = append(articles, a)
	}

	hasMore := len(articles) > limit
	if hasMore {
		// 余分に取得した1件をレスポンスから切り落とす
		articles = articles[:limit]
	}

	var nextCursor string
	if hasMore && len(articles) > 0 {
		lastItem := articles[len(articles)-1]
		c := &Cursor{
			CreatedAt: lastItem.CreatedAt,
			ID:        lastItem.ID,
		}
		nextCursor = c.Encode()
	}

	return &PageResult{
		Items:      articles,
		NextCursor: nextCursor,
		HasMore:    hasMore,
	}, nil
}

この実装によって返されるAPIレスポンスは以下のようになります。クライアントは next_cursor をそのまま次のリクエストのクエリパラメータに付与するだけで、常に安全・高速なページングを行えます。

{
  "items": [
    { "id": 5021, "title": "B+Treeインデックス深層解説", "created_at": "2026-09-20T12:00:00Z" }
  ],
  "next_cursor": "eyJjIjoiMjAyNi0wOS0yMFQxMjowMDowMFoiLCJpIjo1MDIxfQ",
  "has_more": true
}

7. 「でもUIにページ番号を出したい」と言われたときの現場の落とし所

Keyset Paginationを提案すると、プロダクトマネージャーやクライアントから必ず返ってくる反論があります。

「要件定義で『全何件あるか』を表示して、任意のページ番号『[1] [2] [3] ... [48] [49]』に直接ジャンプできるUIが必要だと言われているのですが?」

この要求を前にして、安易に OFFSET と SELECT COUNT(*) に屈してはいけません。大規模データにおける COUNT(*) は、テーブル全体を走査(Seq Scan)するためそれ単体で数秒〜数十秒のレイテンシを発生させ、DBを麻痺させます。

現場で生き残るシニアエンジニアは、以下のトレードオフと現実的な代替案を提示してプロダクト側と交渉します。

  1. 無限スクロール / 「もっと見る」ボタンへの転換(UXのモダナイズ)
    Twitter、Slack、Instagram、GitHubなどのモダンWebサービスがなぜ数字のページ番号を廃止したのかを説明します。モバイル端末の利用が過半数を占める現代において、無限スクロールやCursorベースのUIの方がユーザー体験・パフォーマンス双方で圧倒的に優れています。
  2. 概算カウント(Estimated Count)の採用
    「約120,000件中 1-20件」といった表記で十分なケースが大半です。PostgreSQLのシステムカタログ(pg_class.reltuples)や、Redisでインクリメント管理した概算値を参照することで、DBに1msの負荷もかけずに総件数の目安を表示できます。
  3. 深層ページのアクセス制限(Windowing)
    どうしてもページ番号が必要な管理画面等では、ページ番号の最大値を例えば「最大100ページまで」とビジネス的に制限します。100ページ(2,000件)を超えたデータを探したいユーザーには、ページをめくらせるのではなく「日付絞り込み」「キーワード検索」といった適切なフィルタリング機能を提供すべきです。

8. まとめ:スケールに耐えうるAPI設計の第一歩

「ページネーション」という機能は、ソフトウェア開発においてあまりにも身近で平凡に見えるため、多くの現場で設計が軽視されがちです。しかし、そこにはデータベースのストレージ構造、B+Treeの走査コスト、I/O特性、そしてネットワークプロトコルの本質が凝縮されています。

プロトタイプや個人のおもちゃ開発であれば OFFSET でも構いません。しかし、スケールを見据えた本番Webサービスを作るのであれば、最初から「Keyset(Cursor)ベース」をチームの標準設計として採用してください。その1つの意思決定が、数ヶ月後の深夜障害対応と、何十万円ものインフラ増強コストからあなたを救うことになります。