ページネーションとカーソル
1)ペジネーションが必要な理由
ページネーションは、クライアントが送信およびレンダリングするデータの量を制限し、ストレージ/ネットワークへの負荷を軽減し、コレクションを「歩く」決定的な方法を設定します。実際のシステムでは、ページネーションは'page=1&limit=50'だけでなく、一連のプロトコル契約と整合性の不変量である。
典型的な目的:- リクエストごとのレイテンシとメモリ制御。
- データセットを変更するときの安定したナビゲーション(挿入/削除)。
- 場所から再開する能力(再開)。
- キャッシュとプリロード(プリフェッチ)。
- 乱用に対する保護(レート制限、背圧)。
2)ペジネーションモデル
2.1オフセット/リミット(ページング)
アイデア: 「N行をスキップし、Mを返します。」
長所:シンプルさ、ほぼすべてのSQL/NoSQLと互換性があります。
短所:- 線形劣化:大きなOFFSETにより、フルスキャン/スキップコストが発生します。
- リクエスト間の挿入/削除中の不安定さ(オフセット「float」)。
- 正確な「再生可能性」を確保することは困難です。
sql
SELECT
FROM orders
ORDER BY created_at DESC, id DESC
OFFSET 1000 LIMIT 50;
2.2カーソル/キーセット/シークページネーション
アイデア:"Kキーで乗ります。"カーソルはソートされたセット内の位置です。
長所:- O (1)インデックスを続行するアクセス。
- コレクション変更時の安定性。
- ディープな「ページ」で最高のレイテンシ。
- 厳密に定義されたユニークで単調なソートキーが必要です。
- 実装とデバッグがより困難です。
sql
-- Resumption after steam (created_at, id) = (:last_ts,:last_id)
SELECT
FROM orders
WHERE (created_at, id) < (:last_ts,:last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;
2.3継続トークン
アイデア:サーバーは、「position」がエンコードされている不透明なトークンを返します(おそらくシャード/フィルタの状態)。クライアントは内部を理解せず、次のページのトークンを返すだけです。
長所:柔軟性、APIを壊すことなくスキームを変更する機能。
短所:トークン寿命管理、預金との互換性。
2.4時間とロジックカーソル
タイムベース:「すべてのレコードをTまで」、カーソル-タイムスタンプ(追加のみのスレッドに適しています)。
Log-sequence/offset-based: cursor-ログ内のオフセット(Kafka offset、 journal seq)。
グローバルな単調ID:安定したseekのためのsortableキーとしてSnowflake/UUIDv7して下さい。
3)コースとトークンの設計
3.1良いカーソルのプロパティ
不透明-クライアントはフォーマットに依存しません。
著者/完全性:なりすまし/操作を防ぐHMACの署名。
コンテキスト:ソート、フィルタ、スキーマバージョン、テナント/シャードが含まれます。
Lifetime:インデックス/アクセス権を変更する際のTTLと「非リプレイ」。
サイズ:URLに適したコンパクト(<=1-2 KB)。
3.2トークン形式
推奨スタック:JSON→圧縮(zstd/deflate)→Base64URL→HMAC。
ペイロード構造(例):json
{
"v": 3 ,//token version
"sort": ["created_at:desc","id:desc"],
"pos": {"created_at":"2025-10-30T12:34:56. 789Z","id":"987654321"},
"filters": {"user_id":"42","status":["paid","shipped"]},
"tenant": "eu-west-1",
"shards": [{"s ": "a, "" hi":"..."}] ,//optional: cross-shard context
"issued_at": "2025-10-31T14:00:00Z",
"ttl_sec": 3600
}
'mac=HMAC (secret、 payload)'が上部に追加され、すべてが1つの文字列トークンにエンコードされます。
3.3安全性について
標識(HMAC/SHA-256)。
必要に応じて、機密値(PII)の存在下で暗号化(AES-GCM)。
サーバー検証:バージョン、TTL、ユーザー権限(RBAC/ABAC)。
4)一貫性および不変性
4.1安定した選別
完全な決定:'ORDER BY ts DESC、 id DESC'を使用します。
ソートキーは一意でなければなりません(tiebreakerとして'id'を追加してください)。
インデックスはカバーインデックスと一致している必要があります。
4.2スナップショットと分離
ジャンボページ以外の場合は、読み取り整合スナップショット(MVCC/txid)を使用します。
スナップショットが非現実的(高価/大量のデータ)の場合、コントラクトを定式化します。"カーソルは位置の前に要素を厳密に返します。"これはニュースフィードにとって自然なことです。
4.3ページ間の挿入/削除
Seek-modelは「重複/省略」を最小限に抑えます。
ドキュメントの削除/変更の動作:ページ間のまれな「穴」は許可されていますが「、時間内に戻る」ことはできません。
5)インデックス作成とIDスキーム
複合インデックスは厳密にソート順です:'(created_at DESC、 id DESC)'。
モノトーンID: Snowflake/UUIDv7は時間の順序を与える→seekをスピードアップします。
ホットキー:shard-key ('tenant_id'、 'region'など)で配布し、shard内でソートします。
IDジェネレータ:衝突を避け「、クロックスキュー」-NTPジャンプ中の時間同期、「回帰」。
6)クロスシャードページネーション
6.1スキーム
Scatter-Gather:すべてのシャードへの並列リクエスト、ローカルシークコース、アグリゲータへのk-wayマージ。
シャードカーソルごと:トークンには、各シャードの位置が含まれます。
バウンディングファンアウト-ステップごとのシャード数を制限します(レート制限/タイムアウトの予算)。
6.2マルチシャード用トークン
配列'{shard_id、 last_pos}'を格納します。次のステップでは、アクティブなシャードごとに再開して再度保持し、グローバルにソートされたページを与えます。
7)プロトコル契約
7.1 REST
リクエスト:
GET /v1/orders? limit=50&cursor=eyJ2IjoiMyIsInNvcnQiOiJjcmVh... (opaque)
答え:
json
{
"items": [ /... / ],
"page": {
"limit": 50,
"next_cursor": "eyJ2IjozLCJwb3MiOiJjcmVh...==",
"has_more": true
}
}
推奨事項:
- 'limit'は上限(例えばmax=200)である。
- 'has_more=false'の場合、'next_cursor'が見つかりません。
- GET idempotence、 'next_cursor'なしの応答のキャッシュ可能性(固定フィルタとスナップショット付きの最初のページ)。
7.2 GraphQL(リレーアプローチ)
典型的な「接続」契約:graphql type Query {
orders(first: Int, after: String, filter: OrderFilter): OrderConnection!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}
type OrderEdge {
node: Order!
cursor: String! // opaque
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
'cursor'は不透明で署名されています。HMACなしで「raw Base64 (id)」を使用しないでください。
7.3 gRPC
'page_size'と'page_token'を使う:proto message ListOrdersRequest {
string filter = 1;
int32 page_size = 2;
string page_token = 3; // opaque
}
message ListOrdersResponse {
repeated Order items = 1;
string next_page_token = 2; // opaque bool has_more = 3;
}
7.4スレッドとWebSocket
連続テープの場合:カーソルを"last seeed offset/ts'とします。
再接続中に'resume_from'をサポート:json
{ "action":"subscribe", "topic":"orders", "resume_from":"2025-10-31T12:00:00Z#987654321" }
8)キャッシング、プリロード、CDN
安定したフィルタを持つ最初のページのETag/If-None-Match。
パブリックリストのための短いTTL(例えば、5-30秒)を持つキャッシュコントロール。
Prefetch: 'next_cursor'とヒント('Link: rel=「next」')を返すと、クライアントは次のページをプリロードできます。
バリエーション:'filter/sort/locale/tenant'をキャッシュ部分のキーとして考えます。
9)負荷管理および制限
上限'limit'、例: 200.
サーバー側のバックプレッシャー:リクエスト時間が>予算の場合、レスポンスの'limit'を減らします(そしてクライアントに実際のページサイズを明示的に伝えます)。
ユーザー/トークン/テナントごとのレート制限。
Timeout/Retry:指数休止、idempotent requests。
10) UXの側面
番号付けに対するスクロール: 無限スクロール→カーソル;number pages→offset(データ更新時の不正確さを説明する)
placeボタンに戻る:クライアントカーソルスタックを保存します。
空白のページ:'has_more=false'の場合、Moreボタンは表示されません。
安定した境界:安価な場合にのみ正確な'total'を表示します(そうでなければ、おおよその'approach_total'です)。
11)テストおよび端の場合
チェックリスト:- 安定したソート:同じ't'の項目は「点滅」しません。
- 「挿入/削除」(Inserts/Deletes)-重複はページの交差点に表示されません。
- ページ間の変更フィルタ:トークンは廃止/非互換性として拒否される必要があります。
- Token TTL:有効期限切れ後のエラー。
- 偉大な深さ:レイテンシーは直線的に成長しません。
- Multishard:正しいマージ順序、飢餓「スロー」シャードがない。
python
Generate N entries with random inserts between calls
Verify that all pages are merged = = whole ordered fetch
12)観察可能性およびSLO
メトリクス:- 'list_request_latency_ms' (P50/P95/P99)をページ長で指定します。
- 'search_index_hit_ratio'(カバーインデックスが残したリクエストの割合)
- 'next_cursor_invalid_rate' (検証/TTL/署名エラー)。
- 'merge_fanout' (1ページあたりのシャード数)
- 'duplicates_on_boundary'および'gaps_on_boundary'(クライアントテレメトリでの検出)。
- ログ内の'cursor_id'とマスクペイロードを関連付けます。
- タグの範囲:'page_size'、 'source_shards'、 'db_index_used'。
- 空室状況:99。9% on 'List'メソッド。
- 遅延:ローカル充電で'page_size<=50'に対して200ミリ秒をP95します。
- トークンエラー:<0。通話総数の1%
13)移行と相互運用性
トークンで'v'を有効にし、N週間の古いバージョンをサポートします。
ソートキーを変更する場合-"soft'エラー'409 Conflict'をカーソルなしで新しいリストを実行するプロンプトで送信します。
壊滅的なケース(すべてのトークンの轟音):'signing_key_id'を変更し、古いものを拒否します。
14)実装例
14.1トークン生成(擬似コード)
python payload = json. dumps({...}). encode()
compressed = zlib. compress(payload)
mac = hmac_sha256(signing_key, compressed)
token = base64url_encode(mac + compressed)
14.2トークン検証
python raw = base64url_decode(token)
mac, compressed = raw[:32], raw[32:]
assert mac == hmac_sha256(signing_key, compressed)
payload = json. loads(zlib. decompress(compressed))
assert now() - payload["issued_at"] < payload["ttl_sec"]
assert payload["filters"] == req. filters
14.3複合キーでクエリを探す
sql
-- Page # 1
SELECT FROM feed
WHERE tenant_id =:t
ORDER BY ts DESC, id DESC
LIMIT:limit;
-- Next pages, continued after (ts0, id0)
SELECT FROM feed
WHERE tenant_id =:t
AND (ts <:ts0 OR (ts =:ts0 AND id <:id0))
ORDER BY ts DESC, id DESC
LIMIT:limit;
15)安全性とコンプライアンス
PIIを派生できるトークンに生のフィールドを含めないでください。
TTLに署名して制限します。
トークンをユーザー間で耐えられないようにしてください(ペイロードに「sub/tenant/roles」と書いて、検証中にチェックしてください)。
トークンハッシュだけを記録します。
16)頻繁なエラーとアンチパターン
カーソルとしてのBase64 (id):偽装/ピックアップが簡単で、ソートを変更するときに契約を破棄します。
タイブレーカーなし:'id'→重複/ジャンプなしの'ORDER BY ts DESC'。
トークンを無効にせずにページ間のフィルタを変更します。
深いオフセット:遅く、予測不可能。
バージョンとTTLのないトークン。
17)ミニチェックリストの実装
1.ソートを定義し、ユニークなタイブレーカーを追加します。
2.この注文のスパニングインデックスを作成します。
3.モデルを選択:seek+opaqueトークン。
4.トークン署名(および必要に応じて暗号化)を実装します。
5.TTLとバージョン管理を置きます。
6.'has_more'、 'next_cursor'のコントラクトを整形して文書化します。
7.クロスシャードスキーム(必要に応じて)とk-wayマージを考慮してください。
8.メトリクス、アラート、SLOを追加します。
9.プロパティベースのページ境界をテストでカバーします。
10.トークンの移行戦略について説明します。
18)アプローチを選択するための簡単な推奨事項
「ページ番号」とおおよその合計が重要なディレクトリ/検索:'OFFSET/LIMIT'+cacheとしましょう。合計がおおよそのものであることを報告します。
フィード、アナリティクス、ディープリスト、高いRPS:カーソル/シークのみ。
シャーディ/分散コレクション:シャードカーソルごと+マージトークン。
スレッド/CDC:履歴書とオフセット/tsとしてカーソル。
19) API契約例(要約)
'GET/v1/items?limit=50&cursor=……'
答えには常に'ページが含まれます。limit'、'ページ。has_more'、オプションの'ページ。 。 。
カーソルは不透明で、TTLで署名されています。
ソートは決定論的な'ORDER BY created_at DESC、 id DESC'です。
変更の振る舞いを設定する-アイテムはカーソルから「戻る」ことはありません。
メトリックとエラーは標準化されています:'invalid_cursor'、 'expired_cursor'、 'mismatch_filters'。
この記事では、ビッグデータ、シャーディ、および積極的に変更されたレコードセットでさえも、高速で予測可能で安全なページネーションを設計するためのアーキテクチャの原則と既製のパターンを提供します。