Tidak suka iklan? Pergi Bebas Iklan Hari ini

Pemagangan Berbasis Cursor vs Pemagangan Berdasarkan Offset — Mengapa halaman=2 Menghilangkan Data Secara Tidak Disadari

Diperbarui pada

Penghalang halaman memiliki bug yang dapat direproduksi: baris baru yang dimasukkan antara permintaan halaman secara diam-diam melewatkan catatan. Berikut SQL yang membuktikannya, serta kapan sebaiknya menggunakan penghalang kursor sebagai gantinya.

Pagi Offset memiliki bug yang dapat direproduksi: baris baru yang dimasukkan antara permintaan halaman secara diam-diam melewatkan catatan. Berikut SQL yang membuktikannya, dan kapan harus menggunakan paginasi berdasarkan kursor sebagai gantinya.
IKLAN · HAPUS?

Anda sedang membangun feed. Halaman 1 dimuat, pengguna menggulir ke bawah, lalu Anda mengambil halaman 2. Namun beberapa item yang ada saat halaman 1 dimuat tidak muncul di halaman 2. Tidak ada kesalahan, tidak ada peringatan — mereka hanya tidak muncul.

Ini adalah bug paginasi offset, dan ini bukan kondisi persaingan. Ini deterministik. Setiap sistem yang menggunakan LIMIT x OFFSET y terhadap tabel yang hidup dan banyak melakukan penulisan akan mengalami hal ini akhirnya.

SQL yang menyebabkan hal ini

Pagi offset secara langsung berarti:

-- 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;

Database tidak memiliki ingatan tentang apa yang ada di halaman 1 saat Anda meminta halaman 2. Setiap kali, ia menghitung dari posisi 0 terhadap baris yang ada saat itu.

Skenario yang menghancurkan hal ini

Mulai dengan 20 postingan, ID 1 hingga 20, terbaru terlebih dahulu. Pengguna memuat halaman 1 — mendapatkan ID 20 hingga 11. Antara permintaan tersebut dan yang berikutnya, tiga postingan baru dimasukkan: ID 21, 22, 23.

Sekarang pengguna menggulir dan mengambil halaman 2:

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

Postingan 10, 9, dan 8 — yang belum pernah dilihat pengguna — baru saja dipindahkan melewati jendela offset. Tiga penambahan baru menyebabkan tepat tiga catatan yang terlewat. Matematikanya selalu tepat.

Pengguna tidak melihat apa-apa yang salah. Log Anda menunjukkan tidak ada yang salah. Catatan-catatan tersebut ada di dalam database. Mereka hanya tidak terlihat oleh sesi tersebut.

Pagi berdasarkan kursor memperbaiki hal ini

Sebaliknya dari menghitung posisi, Anda mengikat halaman berikutnya pada catatan terakhir yang sebenarnya dikembalikan. Pertanyaan menjadi "berikan saya 10 baris berikutnya setelah baris tertentu" alih-alih "berikan saya baris 11 hingga 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;

Penambahan baru tidak memengaruhi hal ini sama sekali. Mereka terletak di bagian atas urutan sortir, sebelum posisi kursor Anda. Semua dari kursor ke depan tetap stabil terlepas dari penulisan bersamaan.

Apa yang sebenarnya dimaksud dengan kursor

Kursor adalah posisi yang dienkripsi dari catatan terakhir yang dikembalikan. Dalam praktiknya, ini biasanya salah satu dari:

  • Pasangan waktu + ID yang dienkripsi — pendekatan paling umum. Seri {"created_at": "...", "id": 11} sebagai string base64 dan kembalikan sebagai next_cursor.
  • ID baris atau ID yang tidak terlihat — beberapa sistem menggunakan kunci utama langsung saat diurutkan berdasarkan waktu (ULIDs, UUID v7). Sebuah generator UUID berguna saat Anda memutuskan antara format UUID untuk skema Anda — v4 acak dan tidak diurutkan secara kronologis, v7 berdasarkan waktu dan diurutkan.
  • Token tanda tangan — pendekatan Stripe, mencegah klien membuat nilai kursor yang melompat ke posisi yang sembarang.

Kapan paginasi offset masih layak

Tidak setiap kasus perlu kursor. Paginasi offset layak ketika:

  • Datasetnya statis atau hanya ditambahkan dari bawah. Halaman arsip, dokumentasi, antrian ekspor. Jika tidak ada yang dimasukkan di bagian atas urutan sortir, offset tetap stabil.
  • Dashboard admin dengan nomor halaman eksplisit. Pengguna mengharapkan untuk melompat ke "halaman 5" dan melihat potongan tertentu. Kursor tidak bisa melakukannya — tidak ada konsep "halaman N" ketika anchor Anda adalah baris, bukan jumlah.
  • Dataset kecil yang Anda kendalikan. Mengpaginasi 200 catatan di alat internal di mana data baru datang melalui pekerjaan batch harian? Kompleksitas kursor tidak layak.
  • Penambahan hanya terjadi di ujung urutan sortir. Jika paginasi Anda berurutan berdasarkan ID dan catatan baru mendapatkan ID yang lebih tinggi secara bertahap, halaman sebelumnya tidak terpengaruh oleh penambahan baru.

Pertanyaan penentu: apakah baris baru dapat dimasukkan sebelum posisi kursor saat ini antara permintaan? Jika ya, paginasi offset akan secara diam-diam melewatkan catatan. Jika tidak, itu layak.

Perbandingan

Pagi offsetPagi berdasarkan kursor
SQLLIMIT x OFFSET yWHERE (created_at, id) < (cursor) LIMIT x
Konsisten pada penambahan bersamaanTidak — secara diam-diam melewatkan catatanYa — posisi tetap stabil
Melompat ke halaman sembarangYa (OFFSET = page * size)Tidak — hanya perjalanan maju
Jumlah total halamanMudah (COUNT(*) / size)Tidak mungkin tanpa pemindaian penuh
Kinerja pada offset besarMenurun — OFFSET 100000 mengambil 100.000 baris untuk melewatkan merekaStabil — selalu menggunakan pemindaian rentang indeks
Kompleksitas pada klienRendah — hanya nomor halamanLebih tinggi — harus menyimpan dan mengirim token kursor

Cara GitHub dan Stripe menangani hal ini

Kedua API GitHub masih menggunakan

GitHub

pada sebagian besar endpoint, tetapi API GraphQL menggunakan paginasi berdasarkan kursor dengan page dan per_page Kursor ( after dan before:

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

) dienkripsi dalam base64 — dekripsi dan Anda mendapatkan referensi baris internal, bukan nomor halaman. Pada repositori seperti next.js, ratusan masalah dapat dibuat antara permintaan halaman 1 dan halaman 2 Anda. Dengan paginasi offset, Anda akan secara diam-diam melewatkan beberapa dari mereka.Y3Vyc29yOnYyOpHOAABGPQ==Stripe's list APIs use

Stripe

— Anda mengirim ID dari objek terakhir yang Anda terima: starting_after dan ending_before Objek-objek Stripe memiliki ID yang diurutkan berdasarkan waktu (

# 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

), sehingga ID itu sendiri berfungsi sebagai kursor. API mereka tidak pernah menawarkan paginasi berbasis offset karena data pembayaran adalah tempat di mana kehilangan pembayaran menjadi masalah serius — kehilangan pembayaran bukan hanya masalah UX.ch_, cus_, pi_Menerapkan kursor: pola minimal

Berikut adalah implementasi minimal Node.js/PostgreSQL menggunakan kursor komposit waktu + ID:

Satu hal yang perlu diperhatikan: jika Anda menggunakan UUID sebagai kunci utama alih-alih bilangan otomatis, pengurutan komposit hanya bekerja jika UUID Anda diurutkan berdasarkan waktu. UUID v4 acak — pengurutan berdasarkan

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 };
}

berfungsi, tetapi Anda kehilangan jaminan pengganti yang diberikan oleh ID berurutan. UUID v7 atau ULID adalah diurutkan berdasarkan waktu dan menghindari masalah ini sepenuhnya. (created_at, uuid_v4) Pagi offset mudah diimplementasikan dan bekerja baik hingga data Anda aktif dan banyak ditulis. Saat pengguna dapat memicu penambahan antara permintaan paginasi — postingan, pembayaran, masalah, apa saja — paginasi secara diam-diam melewatkan catatan. Jumlah yang terlewat persis sama dengan jumlah penambahan baru di bagian atas urutan sortir. Tidak ada kesalahan, tidak ada peringatan.

AES-256-GCM

Pagi berdasarkan kursor mengganti "melompat ke halaman N" dengan stabilitas posisi. Untuk feed pengguna dan API yang akan dibangun oleh orang lain, ini adalah pertukaran yang tepat. OFFSET layak untuk alat admin dan ekspor statis — jangan tambahkan kompleksitas kursor di tempat di mana bug tidak bisa terjadi.

Pagi Berdasarkan Kursor vs Pagi Offset — Mengapa Halaman 2 Secara Diam-Diam Melewatkan Catatan 2

Ingin bebas iklan? Bebas Iklan Hari Ini

Instal Ekstensi Kami

Tambahkan alat IO ke browser favorit Anda untuk akses instan dan pencarian lebih cepat

Ke Ekstensi Chrome Ke Ekstensi Tepi Ke Ekstensi Firefox Ke Ekstensi Opera

Papan Skor Telah Tiba!

Papan Skor adalah cara yang menyenangkan untuk melacak permainan Anda, semua data disimpan di browser Anda. Lebih banyak fitur akan segera hadir!

IKLAN · HAPUS?
IKLAN · HAPUS?
IKLAN · HAPUS?

Pojok Berita dengan Sorotan Teknologi

Terlibat

Bantu kami untuk terus menyediakan alat gratis yang berharga

Belikan aku kopi
IKLAN · HAPUS?