Rancang pagination
Bandingkan offset dan keyset, kenapa urutan harus stabil, apa isi cursor, dan apa yang terjadi saat data berubah di tengah paginasi.
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_atyang sama dari import massal. Keyset hanya dengancreated_atmelompati sisa baris yang sama, dan data hilang tanpa error. - Kolom sort nullable.
NULLdiurutkan berbeda antar database dan nggak bisa dibandingkan dengan<. PakaiCOALESCEatau jadikan kolomnyaNOT NULL. - Filter dinamis tanpa indeks. Keyset cepat hanya kalau ada indeks yang cocok dengan
filter dan urutan.
WHERE status = 'paid' ORDER BY created_atbutuh indeks yang menyertakanstatus, kalau nggak, kamu kembali ke pemindaian besar. - Total halaman.
COUNT(*)di 5 juta baris di tiap request mahal. Kembalikanhas_nextsaja, 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.
- Tulis klausa
ORDER BYdanWHEREkeyset untuk halaman berikutnya (terbaru dulu). - Indeks apa yang dibutuhkan?
- Perkirakan baris yang dibaca database untuk halaman ke-100.000 dengan offset dan dengan keyset.
- Klien menyimpan cursor, lalu 3.000 event baru masuk. Apa yang terjadi di tiap pendekatan?
Lihat pembahasan
ORDER BY occurred_at DESC, id DESCdanWHERE (occurred_at, id) < ($t, $id), denganLIMIT 100.idmenjadi tie-breaker unik karenaoccurred_atbisa kembar.- Indeks komposit
(occurred_at DESC, id DESC)atau(occurred_at, id)yang dibaca mundur. Tanpa indeks itu, database mengurutkan 20 juta baris tiap request. - 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. - 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.