Pagination adalah membagi hasil query jadi halaman-halaman kecil, misalnya 5 pesanan per halaman, alih-alih mengirim 100.000 pesanan sekaligus ke browser. Caranya ada dua, dan bedanya mirip dua cara melanjutkan baca buku. Cara pertama mengingat nomor halaman: "aku sampai halaman 37". Cara kedua memakai pembatas buku: "aku berhenti di sini". Kalau seseorang menyelipkan beberapa halaman baru di depan buku, halaman 37 sekarang isinya beda, tapi pembatas bukumu tetap di tempat yang benar.
🧪 Tebakan cepat: riwayat pesanan Kedai Senja menampilkan pesanan terbaru dulu, 5 per halaman. Kamu buka halaman 1 dan melihat pesanan #12 sampai #8. Saat itu pesanan baru #13 masuk. Lalu kamu buka halaman 2. Pesanan pertama di halaman 2 itu #7, #8, atau #3? Tebak dulu, jawabannya ada di bagian "Data bergeser: muncul dua kali atau terlewat".
Tabel yang kita pakai
Tabel orders Kedai Senja berisi 12 pesanan, id 1 sampai 12. Halaman riwayat menampilkan yang terbaru dulu, jadi urutannya ORDER BY id DESC: #12, #11, #10, dan seterusnya sampai #1.
Offset pagination: LIMIT dan OFFSET
-- halaman 1
SELECT * FROM orders ORDER BY id DESC LIMIT 5 OFFSET 0; -- #12, #11, #10, #9, #8
-- halaman 2
SELECT * FROM orders ORDER BY id DESC LIMIT 5 OFFSET 5; -- #7, #6, #5, #4, #3
-- halaman 3
SELECT * FROM orders ORDER BY id DESC LIMIT 5 OFFSET 10; -- #2, #1
LIMIT 5artinya ambil paling banyak 5 baris.OFFSET 5artinya lewati dulu 5 baris pertama, baru mulai mengambil.ORDER BYwajib. Tanpa itu, database boleh mengembalikan baris dalam urutan apa saja, dan halaman 1 dan 2 bisa berisi baris yang sama.
Nilai OFFSET dihitung dari nomor halaman: OFFSET = (halaman - 1) × perHalaman. Halaman 1 melewati 0 baris, halaman 2 dengan 10 per halaman melewati 10 baris, halaman 5 dengan 20 per halaman melewati 80 baris.
Cara ini namanya offset-based pagination, dan inilah yang memakai nomor halaman. Di Laravel, Order::orderByDesc('id')->paginate(5) membaca ?page=2 dari URL dan mengirim LIMIT 5 OFFSET 5, ditambah satu query COUNT(*) untuk menghitung jumlah halaman. simplePaginate(5) melewatkan query COUNT(*) itu, jadi cuma ada tombol sebelumnya dan berikutnya.
Halaman jauh makin lambat
OFFSET gak bisa langsung melompat. Untuk OFFSET 99990, database membaca 99.990 baris, lalu membuang semuanya, baru mengambil 10 baris berikutnya. Index gak menolong, karena database tetap harus menghitung baris satu per satu.
EXPLAIN QUERY PLAN
SELECT * FROM orders ORDER BY id DESC LIMIT 10 OFFSET 99990;
SCAN orders
Di Lab, dengan 100.000 pesanan di SQLite sungguhan, halaman 1 butuh sekitar 0,1 ms dan halaman 10.000 sekitar 1,5 ms: belasan kali lebih lambat, padahal isinya sama-sama 10 baris. Angka pastinya tergantung perangkatmu. Di database production yang barisnya lebar dan query-nya pakai JOIN, selisih ini bisa jadi detik.
Data bergeser: muncul dua kali atau terlewat
Kamu sudah melihat halaman 1: #12 sampai #8. Lalu pesanan #13 masuk, dan kamu minta halaman 2 dengan OFFSET 5. Sekarang 5 baris terbaru adalah #13, #12, #11, #10, #9, jadi yang dilewati ya lima itu:
halaman 2: #8, #7, #6, #5, #4
#8 muncul lagi di halaman 2, padahal sudah tampil di halaman 1. Itu jawaban tebakannya. Kebalikannya juga bisa terjadi: kalau pesanan #12 dihapus setelah kamu melihat halaman 1, OFFSET 5 melompati #7, dan #7 gak pernah tampil sama sekali.
Cursor pagination: ingat posisi terakhir
Alih-alih menghitung baris yang dilewati, simpan id baris terakhir yang sudah tampil, lalu minta baris sesudahnya:
-- halaman 1
SELECT * FROM orders ORDER BY id DESC LIMIT 5; -- #12 ... #8
-- halaman berikutnya: setelah #8, baris terakhir yang tampil
SELECT * FROM orders WHERE id < 8 ORDER BY id DESC LIMIT 5; -- #7 ... #3
- Nilai
8itu cursor, pembatas bukunya: posisi terakhir yang sudah dibaca. Cara ini disebut cursor-based pagination, atau keyset pagination. Dua nama itu untuk ide yang sama. id < 8karena urutannya terbaru dulu (DESC). Kalau urutannya terlama dulu (ORDER BY id), tulisWHERE id > 8 ORDER BY id LIMIT 5.- Pesanan #13 masuk di tengah jalan? Gak ngaruh.
id < 8tetap menghasilkan #7 sampai #3, tanpa dobel dan tanpa terlewat. - Cepat di halaman berapa pun.
WHERE id < ?memakai index primary key, jadi database langsung lompat ke posisinya:SEARCH orders USING INTEGER PRIMARY KEY (rowid<?). Halaman 10.000 secepat halaman 1.
Di Laravel: Order::orderByDesc('id')->cursorPaginate(5). URL-nya membawa ?cursor=..., teks yang menyimpan id terakhir tadi, dan query-nya WHERE id < ? ORDER BY id DESC LIMIT 6. Baris keenam cuma untuk mengecek apakah masih ada halaman berikutnya.
Harganya: cursor pagination gak bisa melompat ke "halaman 37", cuma ada sebelumnya dan berikutnya. Kolom urutannya juga harus unik, misalnya id.
Pilih yang mana
Pakai offset (paginate) |
Pakai cursor (cursorPaginate) |
|---|---|
| Butuh nomor halaman, misalnya tabel di halaman admin | Infinite scroll dan feed |
| Datanya kecil atau jarang berubah | Tabel besar, ratusan ribu baris atau lebih |
| Pengguna perlu lompat ke halaman tertentu | Data terus bertambah saat orang sedang membaca |
⚠️ Jebakan yang sering ketemu
- Lupa
ORDER BY. Urutannya gak dijamin, jadi baris yang sama bisa muncul di dua halaman. - Rumus offset salah.
halaman * perHalamanmembuat halaman 1 melewati satu halaman penuh. Yang benar(halaman - 1) * perHalaman:hitungOffset(1, 10)hasilnya0,hitungOffset(5, 20)hasilnya80. - Cursor di kolom yang gak unik. Urut berdasarkan
created_atsaja, dua pesanan dengan waktu yang sama bisa terlewat. Tambahkanidsebagai urutan kedua:ORDER BY created_at DESC, id DESC. - Arah cursor terbalik. Urutan
DESCpakaiid < cursor, urutan naik pakaiid > cursor.
TL;DR: Offset pagination memakai LIMIT dan OFFSET, dengan OFFSET = (halaman - 1) × perHalaman. Dia memakai nomor halaman (paginate() di Laravel), tapi makin lambat di halaman jauh karena database membaca lalu membuang semua baris sebelum offset, dan baris bisa muncul dua kali atau terlewat kalau data bertambah atau terhapus saat orang pindah halaman. Cursor pagination (atau keyset) menyimpan id terakhir dan meminta WHERE id < 8 ORDER BY id DESC LIMIT 5 (cursorPaginate() di Laravel): cepat di halaman berapa pun dan stabil, tapi gak bisa lompat ke nomor halaman.