Lompat ke konten
18 menit bacamateri 14 dari 96

Rancang pagination

Bandingkan offset dan keyset, kenapa urutan harus stabil, apa isi cursor, dan apa yang terjadi saat data berubah di tengah paginasi.

Ditinjau 26 Oktober 2026

Pertanyaan

Bagaimana klien membaca daftar yang berisi jutaan baris, halaman demi halaman, tanpa lambat, tanpa duplikat, dan tanpa melewatkan data saat daftarnya terus berubah?

Selesai baca ini, kamu bisa memilih offset atau keyset untuk sebuah endpoint, menulis urutan yang stabil, dan menjelaskan apa yang dijamin cursor kamu.

Prasyarat dan contoh

Kamu paham ORDER BY, LIMIT, dan indeks B-tree secara garis besar. Kontrak API dari M03.01 sudah jelas.

Contoh: tabel orders berisi 5 juta baris, endpoint GET /orders?page=N&size=50 dengan SQL ORDER BY created_at DESC LIMIT 50 OFFSET (N-1)*50. Halaman 1 selesai dalam 3 ms. Halaman 80.000 (offset 4.000.000) butuh sekitar 1,5 detik, karena database harus membaca dan membuang 4 juta baris sebelum mengambil 50 yang dipakai. Kebanyakan pengguna nggak sampai sana, tapi job ekspor dan crawler internal justru menelusuri semuanya. Biayanya tumbuh linear dengan kedalaman halaman.

Mekanisme

Offset. LIMIT 50 OFFSET 4000000. Kelebihan: bisa loncat ke halaman mana saja, mudah dibuat. Kekurangan: biaya naik seiring offset, dan hasilnya bergeser kalau data berubah.

Keyset (seek). Ingat nilai kunci baris terakhir, lalu minta baris setelahnya:

SELECT * FROM orders
WHERE (created_at, id) < ($last_created_at, $last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;

Dengan indeks pada (created_at, id), database langsung loncat ke posisi itu dan membaca 50 baris. Biayanya konstan di halaman mana pun (kira-kira 3 ms, bukan 1,5 detik). Harganya: nggak bisa loncat ke "halaman 4.000", dan ganti urutan sort berarti butuh indeks lain.

Urutan harus stabil dan total. ORDER BY created_at saja nggak cukup: kalau 200 baris punya created_at sama, urutan di antara mereka bebas, dan baris bisa muncul dua kali atau hilang antar halaman. Tambahkan tie-breaker unik (id) di akhir urutan. Tanpa itu, pagination nggak benar walaupun terlihat benar di data uji.

Cursor. Cursor adalah posisi terakhir yang dikodekan, misalnya base64({"t":"2026-10-26T08:00:00Z","id":9124}). Perlakukan sebagai token buram: klien nggak boleh menyusunnya sendiri. Dengan begitu kamu bebas mengubah isinya nanti. Kalau cursor membawa filter atau hak akses, tanda tangani (HMAC) agar nggak bisa dipalsukan.

Data berubah di tengah jalan. Misal klien sudah membaca halaman 1 (baris 1 sampai 50), lalu 10 pesanan baru masuk di bagian atas.

  • Offset: halaman 2 sekarang OFFSET 50, yang mencakup 10 baris terakhir dari halaman 1 sebelumnya. Klien melihat 10 duplikat. Kalau yang terjadi penghapusan, klien melewatkan baris.
  • Keyset: halaman 2 dimulai setelah baris 50 yang tadi dilihat. Nggak ada duplikat dan nggak ada yang terlewat di belakang posisi klien. Baris baru yang masuk di depan posisi itu nggak muncul, dan itu perilaku yang wajar.

Keyset bukan snapshot. Kalau kamu butuh tampilan beku (misalnya laporan), pakai snapshot transaksi, tabel hasil, atau filter created_at <= waktu_mulai yang masuk ke dalam cursor.

Saat rancangan ini gagal

  • Tie-breaker hilang. Seribu baris dengan created_at yang sama dari import massal. Keyset hanya dengan created_at melompati sisa baris yang sama, dan data hilang tanpa error.
  • Kolom sort nullable. NULL diurutkan berbeda antar database dan nggak bisa dibandingkan dengan <. Pakai COALESCE atau jadikan kolomnya NOT NULL.
  • Filter dinamis tanpa indeks. Keyset cepat hanya kalau ada indeks yang cocok dengan filter dan urutan. WHERE status = 'paid' ORDER BY created_at butuh indeks yang menyertakan status, kalau nggak, kamu kembali ke pemindaian besar.
  • Total halaman. COUNT(*) di 5 juta baris di tiap request mahal. Kembalikan has_next saja, atau perkiraan yang jelas diberi label.
  • Cursor kedaluwarsa. Cursor yang berisi posisi ke baris yang sudah dihapus tetap berfungsi untuk keyset (membandingkan nilai, bukan menunjuk baris). Cursor yang berisi nomor snapshot bisa kedaluwarsa, jadi tulis masa berlakunya di kontrak.

Latihan

Tabel events 20 juta baris, kolom id (bigint, naik terus) dan occurred_at (bisa sama untuk banyak baris, batch import 5.000 baris per detik). Endpoint daftar dengan size=100.

  1. Tulis klausa ORDER BY dan WHERE keyset untuk halaman berikutnya (terbaru dulu).
  2. Indeks apa yang dibutuhkan?
  3. Perkirakan baris yang dibaca database untuk halaman ke-100.000 dengan offset dan dengan keyset.
  4. Klien menyimpan cursor, lalu 3.000 event baru masuk. Apa yang terjadi di tiap pendekatan?
Lihat pembahasan
  1. ORDER BY occurred_at DESC, id DESC dan WHERE (occurred_at, id) < ($t, $id), dengan LIMIT 100. id menjadi tie-breaker unik karena occurred_at bisa kembar.
  2. Indeks komposit (occurred_at DESC, id DESC) atau (occurred_at, id) yang dibaca mundur. Tanpa indeks itu, database mengurutkan 20 juta baris tiap request.
  3. Offset: halaman 100.000 berarti OFFSET 9.999.900, jadi hampir 10 juta baris dibaca dan dibuang sebelum 100 baris diambil. Keyset: sekitar 100 baris plus satu pencarian indeks, sama seperti halaman 1.
  4. Offset: cursor itu sebenarnya nomor halaman, jadi tiap halaman bergeser 3.000 baris dan klien membaca sekitar 30 halaman duplikat. Keyset: klien lanjut persis setelah baris terakhirnya. 3.000 event baru ada di depan posisinya, jadi baru terlihat saat dia mulai dari awal lagi. Nggak ada duplikat dan nggak ada baris yang hilang di belakang posisi klien.

Kalau kamu hanya memakai occurred_at di nomor 1, batch 5.000 baris per detik membuat halaman bisa terbelah di tengah detik yang sama dan baris hilang. Itu bug yang jarang muncul di data uji dan sering muncul di produksi.

Terkait

  • Kontrak bentuk respons: M03.01 Tentukan spesifikasi API.
  • Indeks komposit, urutan kolom, dan rencana query dibahas di M04. Modul itu belum tersedia.
  • Rancangan D03 (katalog produk dan API daftar) belum tersedia.

Catatan

  • Asumsi: basis data relasional dengan indeks B-tree dan data yang sebagian besar ditambahkan di ujung terbaru.
  • Angka 1,5 detik dan 3 ms adalah ilustrasi yang realistis untuk perangkat keras biasa, bukan hasil ukur. Ukur sendiri di datamu.
  • Yang nggak dibahas: pagination untuk hasil pencarian teks (relevansi bukan urutan stabil) dan paginasi di penyimpanan wide-column.
  • Terakhir ditinjau: 26 Oktober 2026.

Isi · System Design