ページネーションの選択は、APIの「寿命」を決める
こんにちは。ネットワークのパケットキャプチャとルーティングテーブルの美しさに酒が飲める、インフラアーキテクトの私です。
これまで数々のWebサービスが急成長を遂げ、データベースの肥大化とともに「なぜかAPIのレスポンスが数秒もかかるようになった」「DBのCPU使用率が100%に張り付いてコネクションプールが枯渇した」という修羅場に立ち会ってきました。その原因の多くは、実はルーティングでもDBのインデックス設計でもなく、「APIのページネーション設計のミス」にあります。
数百万件、数千万件のレコードを抱える巨大なデータストアから、いかに効率よく、かつ破綻せずにデータをクライアントへ届けるか。これは単なるプログラミングの問題ではなく、OSのメモリ管理やストレージのI/O、そしてTCPのウィンドウ制御まで見据えた、れっきとしたアーキテクチャの設計課題です。
今回は、現代のWeb API設計において避けて通れない「オフセットベース(Offset-based)」と「カーソルベース(Cursor-based)」の2大ページネーション方式について、そのメカニズムと現場で使える実務的な判断基準を徹底的に解説していこう。
—
1. 伝統の「オフセットベース」:その手軽さと致命的な罠
まずは、多くの開発者が最初に手をつける「オフセットベース・ページネーション」から見ていこう。これはSQLの OFFSET と LIMIT クパースに直結しているため、非常に直感的だ。
パラメーターと通信の仕組み
一般的なエンドポイントの設計はこうなる。
GET /api/v1/users?limit=20&offset=40
limit: 1回あたりに取得する最大レコード数(ページサイズ)offset: スキップするレコード数(例:40なら、先頭から40件をスキップして41件目から取得)
内部で何が起きているのか?(DBとストレージの悲鳴)
一見すると何の問題もなさそうに見えるが、RDB(MySQLやPostgreSQLなど)の内部動作を想像してほしい。
例えば、offset=1000000&limit=20 というリクエストが飛んできたとする。データベースエンジンは、インデックス順(あるいはフルスキャン)に従って先頭から1,000,020件のレコードを実際に読み込み、最初の1,000,000件を捨てて、残りの20件を返すという処理を行っている。
つまり、ページが深くなればなるほど(offset の値が大きくなるほど)、クエリの実行時間は線形に増大していく。これが、いわゆる 「Deep Pagination問題(深いページネーション問題)」 だ。数百万件のテーブルでこれをやられると、DBのバッファプールは汚染され、ディスクI/Oがスパイクを引き起こし、最悪の場合はサービス全体が沈黙する。
さらに、リアルタイムでデータが追加・削除される環境では、次のような「データの抜け・重複」が発生する。
1. ユーザーが offset=0 で1ページ目(20件)を取得している最中に、先頭に新しいレコードが1件挿入された。
2. ユーザーが offset=20 で2ページ目をリクエストした。
3. 先頭に1件追加されたため、1ページ目で見たはずの最後のレコードが2ページ目の先頭に再び現れてしまう(あるいはデータが1件抜け落ちる)。
オフセットベースを実装する際のPythonコード例
とはいえ、データ量が数千件程度で収まるマスターデータや、管理画面の限られたログ一覧などでは、今でもオフセットベースは有効だ。SQLAlchemy等を使ったバックエンドのイメージを見てみよう。
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
db = SQLAlchemy(app)
# ユーザーモデルの定義
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50))
@app.route('/api/v1/users', methods=['GET'])
def get_users():
# パラメーターの取得とバリデーション(デフォルト値の設定)
limit = int(request.args.get('limit', 20))
offset = int(request.args.get('offset', 0))
# 負荷対策としてlimitに上限(ハードリミット)を設けるのはインフラの常識
if limit > 100:
limit = 100
# SQLAlchemyを用いたクエリ構築
query = User.query.order_by(User.id.asc())
total_count = query.count() # 全件数(UIで「全○件中」と表示するためによく使われる)
users = query.offset(offset).limit(limit).all()
# レスポンスの構築
result = [{ "id": u.id, "name": u.name } for u in users]
return jsonify({
"total": total_count,
"limit": limit,
"offset": offset,
"data": result
})
この実装における最大のメリットは、total(総件数)が簡単に取れるため、フロントエンド側で「全50ページ中の3ページ目」といったページ番号ベースのUI(Pagination UI)を容易に構築できる点にある。
—
2. 現代のデファクト「カーソルベース」:無限スクロールと大規模データの救世主
Twitter、Facebook、Slack、あるいは現代のモダンなWeb APIのほとんどは、データ件数が増大すると「カーソルベース・ページネーション(Cursor-based Pagination)」へ移行する。別名「キーセット・ページネーション(Keyset Pagination)」とも呼ばれる。
パラメーターと通信の仕組み
カーソルベースでは、offset(何番目か)の代わりに、「前回のレスポンスの最後尾にあったレコードの識別子(カーソル)」を次のリクエストに渡す。
GET /api/v1/messages?limit=20&cursor=eyJpZCI6MTM0NTJ9
※一般的に、カーソルの中身はBase64エンコードされたJSONや、不透明な文字列(Opaque Token)としてクライアントに渡される。中身は単純に {"id": 13452} のようなプライマリキーやタイムスタンプであることが多い。
なぜカーソルベースは速いのか?(インデックスのフル活用)
データベースの視点で見直してみよう。cursor=13452 で limit=20 を取得するSQLは、大体以下のようになる。
SELECT id, message, created_at
FROM messages
WHERE id > 13452
ORDER BY id ASC
LIMIT 20;
お気づきだろうか? WHERE id > 13452 という条件があるため、データベースは id カラムに貼られたB-treeインデックスを使い、一瞬で該当レコードの位置(ポインタ)を特定し、そこから連続する20件をダイレクトに読み込む。
100万件目のデータを取ろうが、1000万件目のデータを取ろうが、インデックス探索のコストはほぼ一定($O(\log N)$)であり、オフセットベースのように数百万件のレコードを読み捨てる無駄なI/Oが発生しない。これが、カーソルベースが「スケールする」と言われる所以だ。
さらに、データが途中で追加・削除されても、既に取得したカーソル以降のデータを取得し続けるため、データの抜けや重複が理論上発生しない。これは無限スクロール(Infinite Scroll)を実装する上で絶対的な要件となる。
カーソルベースを実装する際のPythonコード例
Base64エンコードを用いた、堅牢なカーソルベース・エンドポイントの実装例を見てみよう。
import base64
import json
from flask import Flask, jsonify, request
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
db = SQLAlchemy(app)
class Message(db.Model):
id = db.Column(db.Integer, primary_key=True)
body = db.Column(db.String(255))
def encode_cursor(message_id):
"""メッセージIDをBase64の不透明なカーソル文字列に変換"""
raw_data = json.dumps({"id": message_id})
return base64.urlsafe_b64encode(raw_data.encode('utf-8')).decode('utf-8')
def decode_cursor(cursor_str):
"""Base64のカーソル文字列からメッセージIDを復元"""
try:
raw_data = base64.urlsafe_b64decode(cursor_str.encode('utf-8')).decode('utf-8')
return json.loads(raw_data)["id"]
except Exception:
return None
@app.route('/api/v1/messages', methods=['GET'])
def get_messages():
limit = int(request.args.get('limit', 20))
if limit > 100:
limit = 100
cursor = request.args.get('cursor', None)
# クエリの基本構築
query = Message.query
# カーソルが指定されている場合は、そのIDより大きい(または小さい)レコードに絞り込む
if cursor:
last_id = decode_cursor(cursor)
if last_id is not None:
query = query.filter(Message.id > last_id)
# ID順に並べ、limit + 1 件取得する(次ページが存在するか判定するため)
messages = query.order_by(Message.id.asc()).limit(limit + 1).all()
has_next = len(messages) > limit
if has_next:
# limit + 1件目のデータが存在した場合は、UI用にはlimit件分だけ返す
messages = messages[:limit]
next_cursor = encode_cursor(messages[-1].id)
else:
next_cursor = None
result = [{ "id": m.id, "body": m.body } for m in messages]
# 次のページがあることを示すカーソルをレスポンスに含める
return jsonify({
"data": result,
"pagination": {
"next_cursor": next_cursor,
"has_next": has_next
}
})
ここでプロフェッショナルなテクニックとして注目してほしいのが、limit + 1 件取得している点だ。これにより、「次のページがまだ存在するかどうか(has_next)」を正確に判定し、フロントエンドに無駄なリクエストを送らせずに済む。
—
3. 実務で迷ったらどちらを選ぶべきか?(アーキテクチャ判断マトリクス)
現場のテックリードやPdMから「どっちのページネーションにすべき?」と聞かれた際、私はいつも次の基準で即決するようにしている。
| 評価軸 | オフセットベース (Offset-based) | カーソルベース (Cursor-based) |
| :— | :— | :— |
| データ量(目安) | 数千〜数万件程度(小規模) | 数十万〜数千万件以上(大規模) |
| パフォーマンス | 深いページで著しく低下 ($O(N)$) | 常に高速 ($O(\log N)$) |
| データ変動への耐性 | データの抜け・重複が発生しやすい | データの抜け・重複が発生しにくい |
| UIの柔軟性 | 「5ページ目にジャンプ」が可能 | 「次へ」「無限スクロール」に特化 |
| 実装の複雑さ | 極めてシンプル | カーソルのエンコードや複合キーの考慮が必要でやや複雑 |
インフラエンジニアからの現場の警告
もしあなたが「管理画面だから全件数が分かってページ番号でジャンプできた方が便利でしょ」という理由だけで、数百万件のログ検索APIにオフセットベースを採用しようとしているなら、ちょっと待ってほしい。最初は快適でも、半年後にデータが肥大化した瞬間、インフラ側(特にRDBのCPU)から痛烈なしっぺ返しを食らうことになる。
逆に、SNSのタイムラインやチャット、巨大なECのマスター商品一覧などであれば、最初からカーソルベースを導入しておくのが、将来の負荷分散やマイグレーションの苦しみを回避する唯一の道だ。
—
4. デバッグと実動作確認のための実用スニペット
設計したAPIが正しく動いているか、あるいはネットワーク上で想定通りのクエリが流れているかを検証するため、手元のターミナルから curl を使って挙動を追ってみよう。
オフセットベースの動作確認
# 2ページ目(オフセット20件、最大20件取得)のリクエスト
curl -X GET "https://api.example.com/v1/users?limit=20&offset=20" \
-H "Accept: application/json" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
カーソルベースの動作確認
# 初回リクエスト(カーソルなし)
curl -X GET "https://api.example.com/v1/messages?limit=20" \
-H "Accept: application/json"
# 返却されたnext_cursorを使った2回目以降のリクエスト
curl -X GET "https://api.example.com/v1/messages?limit=20&cursor=eyJpZCI6MTM0NTJ9" \
-H "Accept: application/json"
もし、カーソルベースのAPIで「次のページが取得できない」「無限ループに陥る」といったトラブルに直面した場合は、たいていの場合、以下の2点に原因がある。
1. ソート順(ORDER BY)の非一意性:並び替えのキーに重複を許すカラム(例: created_at のみなど、ミリ秒単位で重複し得るもの)を指定しているため、カーソルの境界でデータが正しくフェッチできていない。プライマリキー(id)を第2ソートキーに含めて一意性を担保すること。
2. エンコードのミス:クライアント側がURLエンコードを二重に行ってしまい、Base64のパディング文字(=)や特殊文字が崩れている。
—
最後に:美しいAPIは、インフラとアプリケーションの対話から生まれる
APIの設計は、単に「JSONをキレイに返すこと」ではない。クライアントが叩いたその1行のリクエストが、APIサーバーのミドルウェアを抜け、データベースのストレージエンジンを叩き、再びネットワークの海を渡ってレスポンスとして返ってくるまでの「すべてのパケットの旅路」を想像できるかどうかが、優れたエンジニアの分かれ道だ。
ページネーションの選択を誤ると、どれだけ高価なクラウドのインスタンスを並べても、データベースの根底にあるI/Oのボトルネックを解決することはできない。
どうか今回の解説を参考に、あなたのシステム規模やUI要件に最適なページネーションを慎重に選び抜き、10年後も耐えうる美しいAPIを作り上げてほしい。それでは、また次のパケット解析の現場でお会いしよう。
コメント