広告が嫌いですか? 行く 広告なし 今日

カーソルベースページネーションとオフセットページネーション — page=2 が記録を静かに削除する理由

更新日

オフセットベースのページネーションには、再現可能なバグがあります。新しい行がページ要求の間に入ると、その行が無視されます。これは証明するSQLコードと、カーソルベースのページネーションを使用すべきタイミングです。

カーソルベースとオフセットページネーション — page=2が無声にレコードを削除する理由 1

あなたはフィードを作成しています。ページ1が読み込まれ、ユーザーがスクロールを開始し、ページ2が取得されます。しかし、ページ1が読み込まれたときには存在していたアイテムがページ2に表示されません。エラーも警告もありません。ただ、表示されないだけです。

これはオフセットページネーションのバグであり、競合状態ではありません。これは決定論的です。すべてのライブで書き込みが重いテーブルを使用するシステムは、最終的にこの問題に遭遇します。 LIMIT x OFFSET y このバグを引き起こすSQL

オフセットページネーションは直接的に次のように表されます:

データベースは、ページ2を要求するとき、ページ1にあった内容を記憶していません。常に位置0から数え、その時点にあるすべての行に対して数えます。

-- Page 1: rows 1–10
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 0;

-- Page 2: rows 11–20
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;

このバグを破壊するシナリオ

最初に20件の投稿があり、ID 1から20まで、新しい順に並べられています。ユーザーがページ1を読み込みます — ID 20から11までを取得します。そのリクエストと次のリクエストの間に、3件の新しい投稿が挿入されます:ID 21、22、23。

次にユーザーがスクロールし、ページ2を取得します:

ID 10、9、8 — これらはユーザーがまだ見たことがない投稿 — が、オフセットウィンドウを通過しました。3件の新しい挿入が、ちょうど3件のスキップを引き起こしました。計算は常に正確です。

SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10;
-- Returns: IDs 13–4, not 10–1

ユーザーは何の問題も見ません。ログには問題がありません。データベースには記録は存在していますが、そのセッションでは見えません。

カーソルページネーションがこれを解決します

位置を数えるのではなく、次のページは実際に返された最後のレコードに锚を置きます。クエリは「この特定のレコードの後に次の10件のレコードを返してください」となります。

「ID 11から20のレコードを返してください」ではなく。 新しい挿入はまったく影響しません。新しいレコードはソート順のトップに配置され、カーソル位置の前に配置されます。カーソル以降のすべてのデータは、並列書き込みに関わらず安定しています。カーソルが実際に何であるか

-- Page 1: no cursor, start from top
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 10;
-- Last row returned: created_at = '2024-01-10 12:00:00', id = 11

-- Page 2: anchor to that exact row
SELECT * FROM posts
WHERE (created_at < '2024-01-10 12:00:00')
   OR (created_at = '2024-01-10 12:00:00' AND id < 11)
ORDER BY created_at DESC, id DESC
LIMIT 10;

カーソルは、最後に返されたレコードのエンコードされた位置です。実際には、次のいずれかになります:

エンコードされたタイムスタンプとIDのペア

— 最も一般的なアプローチ。シリアル化して、base64文字列として返します。

  • UUIDまたはオペレートID — 一部のシステムは、時系列順に並べられた場合(ULIDs、UUID v7)のプライマリキーを直接使用します。UUID生成器は、スキーマのUUIDフォーマットを決定する際に役立ちます — v4はランダムであり、時系列順に並びませんが、v7はタイムスタンプベースであり、時系列順に並びます。 {"created_at": "...", "id": 11} 署名トークン next_cursor.
  • — Stripeのアプローチで、クライアントが任意の位置にジャンプするカーソル値を偽造できないようにします。 オフセットページネーションがまだ適しているケース すべてのケースにカーソルが必要ではありません。オフセットページネーションが適しているのは次のケースです: データセットが静的または下端にのみ追加される場合。
  • アーカイブページ、ドキュメント、エクスポートキュー。ソート順のトップに何も挿入されない場合、オフセットは安定します。 管理者ダッシュボードで明示的なページ番号を使用する場合。

ユーザーが「ページ5」にジャンプして、その特定のスライスを確認するよう期待しています。カーソルは、行の位置ではなく、カウントに基づくため、「ページN」にジャンプすることはできません。

データセットが小さく、制御下にある場合。

  • 内部ツールで200件のレコードをページネートし、新しいデータは毎日バッチジョブで到着する場合?カーソルの複雑さはそれほど価値がありません。 挿入はソート順の末端のみに起こる場合。
  • ページネーションがIDで昇順に並び、新しいレコードが順次高いIDを持つ場合、早期のページは新しい挿入に影響を受けません。 決定的な質問:新しい行が、現在のカーソル位置に挿入される可能性があるか?もしは、オフセットページネーションはレコードを無声にスキップします。もし否なら、問題ありません。
  • オフセットページネーション カーソルページネーション
  • 並列挿入時の一貫性 — なし — レコードを無声にスキップ

— あり — 位置は安定 レコードを確認してください。 任意のページにジャンプ

比較

— なし — 前向きのみのナビゲーション総ページ数
SQLLIMIT x OFFSET yWHERE (created_at, id) < (cursor) LIMIT x
簡単(完全スキャンなしでは不可能大規模オフセット時のパフォーマンス
悪化 — 100k件の行をスキャンして除外するはい(OFFSET = page * size)安定 — 常にインデックス範囲スキャンを使用
クライアントの複雑さ低 — ただのページ番号COUNT(*) / size)高 — カーソルトークンを保存し、転送する必要がある
GitHubとStripeがどのように対応しているかGitHubのREST APIはほとんどのエンドポイントでまだ使用していますが、GraphQL APIでは適切なカーソルページネーションを使用しています。 OFFSET 100000 そのカーソル()はbase64エンコードされています — 解码すると、内部の行参照を得られます。next.jsのようなリポジトリでは、ページ1とページ2のリクエストの間に数十件の問題が提出されることがあります。オフセットページネーションでは、それらの問題の一部が無声にスキップされます。
StripeのリストAPIは— 最後に受け取ったオブジェクトのIDを渡します:Stripeのオブジェクトは時系列順に並んでいるID(

)を持っていますので、ID自体がカーソルとして機能します。そのAPIは、請求データが無視されると重大な問題になるため、オフセットベースのページネーションを提供していません。請求が漏れることは単なるUX問題ではなく、重大な問題です。

カーソルの実装:最小パターン

GitHub

ここに、compound timestamp + IDカーソルを使用するNode.js/PostgreSQLの最小実装を示します: pageper_page 注意すべきことは、プライマリキーとしてUUIDを使用している場合、UUIDが時系列順に並んでいる場合にcompound sortが機能します。UUID v4はランダムであり、 afterbefore:

query {
  repository(owner: "vercel", name: "next.js") {
    issues(first: 10, after: "Y3Vyc29yOnYyOpHOAABGPQ==") {
      pageInfo {
        endCursor
        hasNextPage
      }
      nodes {
        title
        number
      }
    }
  }
}

の並び順は機能しますが、順序の保証を失います。順序の保証を提供する順序IDは、UUID v7またはULIDを使用することで完全に回避できます。Y3Vyc29yOnYyOpHOAABGPQ==オフセットページネーションは実装が簡単で、データがライブかつ書き込みが重い場合まで問題なく機能します。ユーザーがページネーションリクエストの間に挿入をトリガーできる場合、オフセットは無声にレコードをスキップします。スキップ数は、ソート順のトップに挿入された新しいレコードの数と正確に一致します。エラーも警告もありません。

Stripe

カーソルページネーションは「ページNにジャンプ」を「位置の安定性」に交換します。ユーザー向けのフィードや、他の開発者が使用するAPIでは、これが正しいトレードオフです。OFFSETは管理者ツールや静的エクスポートには適しています — バグが発生しない場所ではカーソルの複雑さを追加しないでください。 starting_afterending_before カーソルベース vs オフセットページネーション — なぜpage=2が無声にレコードをスキップするのか 2

# First page
curl https://api.stripe.com/v1/charges   -u sk_test_xxx:   -d limit=10

# Next page — pass the last charge ID from the previous response
curl https://api.stripe.com/v1/charges   -u sk_test_xxx:   -d limit=10   -d starting_after=ch_1ABC123def456

カーソルベース vs オフセットページネーション — なぜpage=2が無声にレコードをスキップするのか 1ch_, cus_, pi_オフセットページネーションには再現可能なバグがあります:ページリクエストの間に新しい行が挿入され、レコードが無声にスキップされます。ここにそのSQLを示し、カーソルページネーションを使用すべきタイミングを示します。

カーソルの実装:最小パターン

次のシンプルなNode.js/PostgreSQLの実装は、複合タイムスタンプ+IDカーソルを使用しています。

function encodeCursor(row) {
  return Buffer.from(JSON.stringify({
    created_at: row.created_at,
    id: row.id
  })).toString('base64url');
}

function decodeCursor(cursor) {
  return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
}

async function fetchPage(db, cursor = null, limit = 10) {
  let rows;

  if (!cursor) {
    rows = await db.query(
      'SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT $1',
      [limit]
    );
  } else {
    const { created_at, id } = decodeCursor(cursor);
    // PostgreSQL supports tuple comparison natively.
    // MySQL requires: WHERE created_at < $1 OR (created_at = $1 AND id < $2)
    rows = await db.query(
      `SELECT * FROM posts
       WHERE (created_at, id) < ($1, $2)
       ORDER BY created_at DESC, id DESC
       LIMIT $3`,
      [created_at, id, limit]
    );
  }

  const nextCursor = rows.length === limit
    ? encodeCursor(rows[rows.length - 1])
    : null;

  return { rows, nextCursor };
}

注意すべき点は、自動増分整数ではなくUUIDを主キーとして使用している場合、UUIDが時系列順に並んでいる場合にのみ複合ソートが機能することです。UUID v4はランダムなので、ソート時に (created_at, uuid_v4) は機能しますが、連続したIDが提供する並び順の保証を失います。UUID v7またはULIDは時系列順に並び、この問題を完全に回避します。

短い要約

Offset pagination is simple to implement and works fine until your data is live and write-heavy. The moment users can trigger inserts between pagination requests — posts, charges, issues, anything — offset silently drops records. The skip count is exactly equal to the number of new inserts at the top of your sort order. No error, no warning.

カーソルベースページネーションは「ページNにジャンプ」を犠牲に、位置の安定性を提供します。ユーザー向けのフィードや、他の開発者が使用するAPIにはこれが適しています。オフセットページネーションは管理ツールや静的エクスポートには問題なく使えるが、バグが発生しない場所ではカーソルの複雑さを追加しないでください。

広告なしで楽しみたいですか? 今すぐ広告なしで

拡張機能をインストールする

お気に入りのブラウザにIOツールを追加して、すぐにアクセスし、検索を高速化します。

に追加 Chrome拡張機能 に追加 エッジ拡張 に追加 Firefox 拡張機能 に追加 Opera 拡張機能

スコアボードが到着しました!

スコアボード ゲームを追跡する楽しい方法です。すべてのデータはブラウザに保存されます。さらに多くの機能がまもなく登場します!

ニュースコーナー 技術ハイライト付き

参加する

価値ある無料ツールの提供を継続するためにご協力ください

コーヒーを買って