ページ分割されたレスポンスは、実行時点では正しくても、一貫性のないデータ走査を引き起こすことがあります。アプリケーションがページを取得し、データセットが変化した後に次のページを要求すると、同じ項目を再び受け取ったり、項目を見落としたりする可能性があります。これは、増え続けるアクティビティ、注文、ログなどの一覧でよく発生する問題です。
PHP APIでカーソルページネーションを使うと、こうした位置のずれを抑えられますが、それだけでデータの固定スナップショットが保証されるわけではありません。重要なのは、一覧をどう進むのか、走査中にどのような変更が起こり得るのか、クライアントにどのような契約が必要なのかを定義することです。
ページ間で結果が変わる理由

レコードを日付の降順で並べるクエリを考えてみましょう。クライアントが最初の20件を取得した後、次のページを要求するまでに新しいレコードが3件挿入されたとします。2回目のリクエストでOFFSET 20を使うと、最初のリクエスト時点の21番目ではなく、現在のデータセットの21番目から取得します。そのため、1ページ目の項目が再び表示されることがあります。
項目が欠落することもあります。オフセットより前にあるレコードが削除されると、後続の項目が1つ前にずれ、クライアントが期待していた行を飛ばす可能性があります。また、複数の行の日付が同じ場合、順序が安定するとは限りません。追加の条件がなければ、データベースが同順位の行を毎回同じ順序で返す保証はありません。
位置の変化による飛ばしを防ぐことと、データセット全体の正確なスナップショットを提供することは、別の目標です。適切に定義されたカーソルページネーションは前者に役立ちます。後者には明示的な一貫性戦略が必要であり、コストが高く、データベースにも左右されます。
読み取りパターンに応じてoffsetとカーソルを選ぶ
通常LIMITとOFFSETで表現するオフセットページネーションは単純で、既知のページへ直接移動できます。小規模または比較的静的なデータセット、ページ間の移動が頻繁なインターフェース、閲覧中の不整合を許容できるケースに適しています。大規模なデータセットでは、大きなオフセットによりデータベースが多数の行を走査または破棄しなければならないことがあります。実際のコストは、エンジン、インデックス、クエリによって異なります。
カーソルページネーションは、たとえば最後に返した並び順の値と一意キーなど、続きの取得位置を示す参照を返します。次のクエリでは行数を飛ばすのではなく、その位置より後または前にあるレコードを検索します。逐次的な走査、フィード、頻繁に項目が挿入される一覧に適しています。一方、任意のページへ直接移動する機能は標準では備わっていません。クライアントはページを順にたどるか、別の方法を利用する必要があります。
API全体で方式を統一する必要はありません。ページ番号で移動する管理用クエリではoffsetを、アクティビティフィードではカーソルを使うこともできます。インターフェースは、単一の仕組みでランダムアクセスと絶対的な安定性を約束するのではなく、サーバーが保証できることを反映すべきです。
カーソルを作る前に全順序を定義する
順序が決定的でなければ、カーソルは位置を特定できません。created_atだけで並べると、同じ日時のレコードを区別できません。idのような一意かつ不変の列をタイブレーカーとして追加します。
ORDER BY created_at DESC, id DESCこれにより、各行の順序が一意に定まります。続行用カーソルには両方の値を含めます。同じ降順で続ける場合、次のクエリでは最後に返したペアより小さいペアを検索します。
WHERE created_at < :cursor_date
OR (created_at = :cursor_date AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :page_size昇順では比較演算子を逆にします。複数の条件がある場合、条件は完全な辞書式順序に従う必要があります。つまり、最初のフィールドを比較し、同値なら次のフィールドを比較します。方向が混在する場合(たとえば日付は降順、識別子は昇順)、各比較は対応する列の方向に合わせます。すべての演算子を一度に反転すればよいわけではありません。
並び順の値にも安定したルールが必要です。列がNULLになり得る場合は、その値をどう並べるかを定義し、続行条件にもその違いを反映します。走査中に変わらない条件を使うのが望ましいです。位置を決める日付が変更されると、行がカーソルの反対側へ移動する可能性があります。
カーソルを不透明にし、検証してクエリにひも付ける
カーソルには並び順の値をシリアライズし、たとえばBase64URLでエンコードできます。不透明であるとは、利用者が内容を解釈したり組み立てたりする必要がないという意味であり、Base64が保護になるという意味ではありません。値の改変によってクエリの対象範囲が変わる可能性がある場合は、形式を検証し、HMACなどで内容に署名するか、同等の完全性保護を使います。秘密情報や不要な個人データは含めないでください。
データベースへ問い合わせる前に、型、想定フィールド、形式のバージョン、サイズ上限を検証します。値にはSQLパラメーターを使います。列名や並び順の方向をカーソルやリクエストから直接受け入れてはいけません。サーバー側の許可リストから指定します。
日付と識別子を含むカーソルが、異なるフィルターで誤って再利用され、誤解を招く続行につながることを防ぐ必要があります。関連するフィルターの正規化された表現、並び順の方向、必要に応じてページサイズを含め、続行位置とともに署名できます。現在のリクエストと一致しない場合は、別のクエリで黙って続行せず、明確なエラーを返します。PHPではエンコード、検証、署名を一元化し、コントローラー間でルールが重複しないようにします。
同時変更に対してどの一貫性を提供するか決める
更新中のデータを走査する場合、各ページはその時点で利用可能な状態を参照します。不変の並び順に基づくカーソルは、到達済みの位置より前への挿入によるずれを多くの場合防げます。しかし、スナップショットを作るわけではありません。カーソルより後に新しい行が現れたり、未取得の行が削除されたり、適用される権限やフィルターが変わったりする可能性があります。クライアントが確定済みのエクスポートと誤解しないよう、この動作を文書化してください。
すべてのページを範囲の定まったデータセットにしたい場合は、走査開始時に日付や最大識別子などの境界を固定し、各クエリに加える方法があります。選んだ条件で可能な場合、この境界より後の挿入を除外できますが、削除された行は保持されず、変更に対する完全なスナップショットも保証されません。別の選択肢としてトランザクションスナップショットがありますが、リクエストをまたいでトランザクションを開いたままにすると、運用面やリソース面の影響が生じることがあります。そのため、既定の解決策とみなすべきではありません。
契約では、サポートする並び順、維持が必要なフィルター、有効期限がある場合はその条件、無効なカーソルへの対応、同時変更によってデータセットが変わる可能性を明確にできます。採用した戦略で保証できない限り、重複や欠落が絶対にないと約束してはいけません。
境界条件をテストし、契約を文書化する

テストではレスポンスの形式だけでなく、走査全体を検証します。同じ並び順の値を持つ行を用意し、複数ページを連結した結果が重複なく期待どおりの順序になることを確認します。ページサイズによって同順位のグループが分割されるケースを含め、昇順と降順の両方を検証します。
- 2つのリクエストの間にカーソルより前と後へレコードを挿入し、合意した動作を確認する。
- 未取得の行を削除し、モデル上可能であれば並び順の列を変更して、その影響を文書化する。
- フィルター、並び順、走査方向を変更し、互換性のないカーソルが拒否されることを確認する。
- 形式不正、改変済み、過大、または型が誤った値を含むカーソルを送信する。
- ページサイズの上限と、次ページが存在しない場合を含む結果なしのケースを検証する。
診断用には、機密性のあるカーソルや個人データを出力せずに、クエリ時間、ページサイズ、検証エラーのメトリクスを記録します。重複が発生したら、まず全順序と続行条件を確認します。クエリコストが問題なら、並び順のフィールドとフィルター条件に対する実行計画とインデックスを調べます。
安定したページネーションは、Base64文字列を隠すことで実現するものではありません。決定的な並び順、一貫した比較、制御されたフィルター、同時変更に対する明確な期待に基づきます。これらを決めれば、offsetとカーソルをクライアントの実際の走査パターンに応じて選べるようになります。



