Jika daftar data di aplikasi Laravel mulai melambat ketika jumlah baris naik ke ratusan ribu atau jutaan, penyebab yang sering muncul adalah offset pagination. Query seperti LIMIT 50 OFFSET 500000 terdengar sederhana, tetapi database tetap harus menelusuri atau membuang banyak baris sebelum mengembalikan hasil.
Untuk kasus seperti orders, logs, atau audit_logs yang terus bertambah, pendekatan yang lebih stabil biasanya adalah cursor pagination ditambah index yang selaras dengan pola query. Di Laravel, ini berarti memahami kapan paginate() masih cukup, kapan cursorPaginate() lebih tepat, dan bagaimana mendesain index agar WHERE dan ORDER BY dapat dijalankan efisien oleh database.
Masalah utama: kenapa OFFSET makin mahal
Laravel paginate() pada umumnya menghasilkan query berbasis LIMIT dan OFFSET. Untuk halaman awal, ini biasanya masih cepat. Masalah muncul ketika pengguna membuka halaman yang jauh, atau ketika API melakukan iterasi data lama pada tabel yang terus tumbuh.
Contoh SQL yang umum:
SELECT id, customer_id, status, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 100000;Secara logis, database harus menemukan urutan data yang benar, lalu melewati 100.000 baris sebelum mengambil 50 berikutnya. Walaupun ada index, OFFSET tetap memaksa database untuk berjalan lebih jauh di hasil yang sudah diurutkan.
Gejala yang sering terlihat:
- Halaman 1 cepat, halaman 2000 lambat.
- CPU database naik saat traffic ke endpoint list meningkat.
- Query time tidak stabil meski ukuran halaman kecil.
- EXPLAIN menunjukkan pemindaian baris yang jauh lebih besar dari jumlah hasil yang diminta.
Intinya: ukuran page bukan satu-satunya faktor. Pada offset pagination, posisi halaman juga menentukan biaya query.
paginate() vs cursorPaginate() di Laravel
Kapan paginate() masih tepat
paginate() tetap masuk akal jika:
- Jumlah data belum besar.
- Pengguna benar-benar butuh navigasi nomor halaman, misalnya halaman 1, 2, 3, 4.
- Query relatif sederhana dan halaman yang diakses biasanya dekat awal.
- Anda butuh total jumlah baris untuk UI, misalnya “1-50 dari 18.240 data”.
Contoh Eloquent:
$orders = Order::query()
->where('status', 'paid')
->orderByDesc('created_at')
->orderByDesc('id')
->paginate(50);Perlu diingat, offset pagination sering juga disertai query COUNT(*) untuk menghitung total halaman. Pada tabel besar, ini dapat menjadi biaya tambahan tersendiri.
Kapan cursorPaginate() lebih tepat
cursorPaginate() cocok ketika tujuan utama adalah mengambil data berikutnya secara konsisten dan cepat, tanpa biaya offset yang membesar. Alih-alih mengatakan “lompat ke halaman 2000”, cursor pagination mengatakan “ambil 50 baris setelah item terakhir yang saya lihat”.
Contoh Eloquent:
$orders = Order::query()
->where('status', 'paid')
->orderByDesc('created_at')
->orderByDesc('id')
->cursorPaginate(50);Secara konsep, Laravel akan mengirim cursor yang merepresentasikan posisi terakhir. Query lanjutan akan diterjemahkan menjadi kondisi seperti:
SELECT id, customer_id, status, created_at
FROM orders
WHERE status = 'paid'
AND (
created_at < '2025-01-10 12:34:56'
OR (created_at = '2025-01-10 12:34:56' AND id < 987654)
)
ORDER BY created_at DESC, id DESC
LIMIT 50;Di sini database tidak perlu menghitung dan membuang 100.000 baris. Ia cukup melanjutkan dari titik terakhir sesuai urutan index.
Perbedaan praktis yang perlu dipahami
- paginate(): mendukung nomor halaman, tapi makin mahal saat offset membesar.
- cursorPaginate(): lebih efisien untuk tabel besar dan data yang terus bertambah, tapi UX nomor halaman hilang.
- paginate() sering bergantung pada
COUNT(*)untuk metadata total. - cursorPaginate() lebih cocok untuk infinite scroll, tombol next/previous, atau API list.
Kenapa index harus mengikuti WHERE + ORDER BY
Banyak kasus lambat bukan hanya karena memilih paginate(), tetapi karena index tidak cocok dengan pola query. Menambahkan index tunggal pada setiap kolom sering tidak cukup. Database membutuhkan index yang membantu filtering sekaligus pengurutan.
Misalnya query utama Anda seperti ini:
SELECT id, status, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 50;Index yang lebih relevan biasanya adalah index komposit yang urutannya mengikuti pola akses:
(status, created_at, id)Kenapa urutannya penting?
statusdipakai untuk menyaring terlebih dahulu.created_atdipakai untuk urutan utama.idmenjadi tie-breaker agar urutan stabil ketika beberapa baris punyacreated_atyang sama.
Tanpa id sebagai pengurutan kedua, cursor pagination bisa menghasilkan urutan yang tidak stabil atau melewatkan/ menggandakan data saat banyak baris memiliki timestamp identik.
Contoh migrasi index di Laravel
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->index(['status', 'created_at', 'id'], 'orders_status_created_id_idx');
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex('orders_status_created_id_idx');
});
}
};Jika Anda memakai database besar di production, perhatikan bahwa pembuatan index dapat memakan waktu dan berpotensi menahan lock tergantung engine, versi database, dan cara migrasi dijalankan. Untuk sistem sibuk, rencanakan pembuatan index dengan strategi yang aman, bukan asal menjalankan migration pada jam ramai.
Contoh bottleneck nyata pada tabel yang terus tumbuh
Kasus 1: orders untuk dashboard operasional
Tim operasional membuka daftar order berstatus paid dan shipped setiap hari. Awalnya query ini cepat:
$orders = Order::query()
->where('status', 'paid')
->latest('created_at')
->paginate(50);Setelah data membesar, halaman awal masih masuk akal, tetapi ketika user mencari order lama melalui page tinggi, respons naik tajam. Solusi umumnya:
- Pastikan urutan stabil:
orderByDesc('created_at')->orderByDesc('id'). - Tambahkan index komposit
(status, created_at, id). - Jika UI tidak butuh nomor halaman, pindahkan ke
cursorPaginate(50).
Kasus 2: logs atau audit_logs
Tabel log biasanya append-only, terus bertambah, dan hampir selalu diakses dengan pola “yang terbaru dulu”. Ini adalah kandidat sangat kuat untuk cursor pagination.
$logs = AuditLog::query()
->where('tenant_id', $tenantId)
->orderByDesc('created_at')
->orderByDesc('id')
->cursorPaginate(100);Index yang relevan:
(tenant_id, created_at, id)Untuk tabel seperti ini, offset pagination sering cepat rusak karena pengguna atau API sering menelusuri data jauh ke belakang. Cursor pagination jauh lebih sejalan dengan sifat datanya.
Contoh implementasi Laravel yang aman dipakai
Gunakan urutan yang deterministik
Ini penting untuk semua pagination, terutama cursor. Hindari hanya mengurutkan berdasarkan kolom yang nilainya bisa sama untuk banyak baris.
$query = Order::query()
->where('status', 'paid')
->orderByDesc('created_at')
->orderByDesc('id');Lebih aman daripada:
$query = Order::query()
->where('status', 'paid')
->orderByDesc('created_at');Karena tanpa id sebagai urutan kedua, dua order dengan timestamp sama dapat menyebabkan hasil tidak konsisten antar halaman.
Contoh endpoint API
public function index(Request $request)
{
$perPage = min((int) $request->integer('per_page', 50), 100);
$orders = Order::query()
->select(['id', 'customer_id', 'status', 'total', 'created_at'])
->when($request->filled('status'), function ($query) use ($request) {
$query->where('status', $request->string('status'));
})
->orderByDesc('created_at')
->orderByDesc('id')
->cursorPaginate($perPage);
return OrderResource::collection($orders);
}Catatan praktis:
- Pilih kolom yang benar-benar dibutuhkan dengan
select(). - Batasi
per_pageagar client tidak meminta 1000 baris sekaligus. - Pastikan filter yang dipakai juga dipertimbangkan dalam desain index.
Membaca EXPLAIN secara dasar
Jangan mengandalkan tebakan. Jalankan EXPLAIN pada query SQL yang benar-benar dieksekusi. Fokusnya bukan menjadi ahli optimizer, tetapi cukup untuk menjawab: apakah database memakai index yang sesuai, dan berapa banyak baris yang harus dibaca?
Contoh:
EXPLAIN
SELECT id, customer_id, status, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at DESC, id DESC
LIMIT 50;Hal-hal yang perlu diperhatikan secara umum:
- Index yang dipakai: apakah planner memilih index komposit yang Anda harapkan.
- Estimasi baris: jika terlalu besar untuk query kecil, kemungkinan filtering atau ordering tidak efisien.
- Operasi sort tambahan: jika database masih perlu melakukan sort terpisah, index mungkin tidak mendukung
ORDER BY. - Full scan: jika seluruh tabel atau bagian besar tabel dibaca, index mungkin tidak dipakai atau tidak selektif.
Tanda-tanda index tidak terpakai
- Query tetap lambat walau index baru sudah dibuat.
- EXPLAIN menunjukkan scan besar dan sort tambahan.
- Urutan kolom pada index tidak cocok dengan
WHERElaluORDER BY. - Anda memfilter pada kolom A, tapi index dimulai dari kolom B.
- Query memakai fungsi pada kolom yang diindex, sehingga optimizer sulit memakai index secara efektif.
Contoh masalah umum:
WHERE DATE(created_at) = '2025-01-10'Ekspresi seperti ini sering membuat index pada created_at kurang berguna. Lebih aman ubah menjadi rentang:
WHERE created_at >= '2025-01-10 00:00:00'
AND created_at < '2025-01-11 00:00:00'Trade-off UX cursor pagination
Secara performa, cursor pagination sering lebih baik. Tetapi dari sisi produk dan UI, ada konsekuensi yang harus diterima.
- Tidak ada nomor halaman tradisional.
- Tidak ideal untuk fitur “lompat ke halaman 120”.
- Total halaman biasanya tidak tersedia secara alami.
- Lebih cocok untuk timeline, activity feed, log viewer, dan API list.
Karena itu, keputusan teknis sebaiknya mengikuti kebutuhan UX:
- Pakai paginate() jika pengguna butuh page number dan total count, serta dataset masih terkendali.
- Pakai cursorPaginate() jika prioritasnya adalah performa dan traversal berurutan pada data besar.
Aturan praktis: jika pengguna membaca data secara berurutan dari terbaru ke lama, cursor pagination biasanya lebih masuk akal daripada offset pagination.
Kesalahan yang sering terjadi
1. Mengganti paginate() ke cursorPaginate() tanpa memperbaiki ORDER BY
Cursor pagination butuh urutan yang stabil dan unik secara praktis. Jika Anda hanya mengurutkan berdasarkan created_at, hasil bisa tidak konsisten.
2. Menambah banyak index tunggal, bukan satu index komposit yang relevan
Index pada status, index pada created_at, dan index pada id tidak selalu seefektif index komposit (status, created_at, id) untuk query tertentu.
3. Mengabaikan biaya COUNT
Pada offset pagination, bottleneck bukan hanya OFFSET. Query count untuk total halaman juga bisa mahal pada tabel besar.
4. Select semua kolom
SELECT * memperbesar I/O dan bisa mengurangi efisiensi. Ambil hanya kolom yang dibutuhkan oleh list.
5. Tidak menguji query riil
Optimasi sebaiknya didasarkan pada query nyata dari endpoint production-like, bukan asumsi atau contoh query yang terlalu sederhana.
Checklist migrasi aman tanpa downtime besar
Jika Anda ingin memindahkan endpoint list dari offset pagination ke cursor pagination pada tabel besar, lakukan bertahap.
- Identifikasi endpoint yang paling mahal
Lihat slow query log, APM, atau metrik database. Fokus pada endpoint list dengan offset tinggi atau count mahal. - Pastikan pola query final sudah jelas
Tentukan filter utama, urutan, dan kolom yang diambil. Index harus dirancang dari query final, bukan sebaliknya. - Tambahkan index yang sesuai lebih dulu
Buat index komposit untukWHERE + ORDER BY. Perhatikan strategi pembuatan index yang aman untuk ukuran tabel dan engine database Anda. - Verifikasi dengan EXPLAIN
Cek apakah planner memakai index yang diharapkan dan apakah estimasi pembacaan baris turun. - Gunakan urutan stabil
Tambahkanidatau kolom unik lain sebagai tie-breaker diORDER BY. - Rilis endpoint cursor secara bertahap
Untuk API, Anda bisa menambah mode baru dulu, misalnya endpoint atau parameter khusus, sebelum mengganti default. - Sesuaikan UI
Ganti page number dengan next/previous atau infinite scroll jika memang pindah ke cursor pagination. - Pantau duplikasi atau data terlewat
Terutama jika urutan belum deterministik atau data sering berubah saat pengguna sedang melakukan paging. - Hapus index lama yang benar-benar tidak dipakai
Lakukan setelah observasi, bukan langsung. Terlalu banyak index juga menambah biaya write.
Rekomendasi keputusan teknis
Untuk tabel Laravel yang terus tumbuh, keputusan paling aman biasanya seperti ini:
- Jika dataset kecil atau kebutuhan UX sangat bergantung pada nomor halaman: tetap gunakan
paginate(). - Jika tabel append-heavy seperti
orders,logs, atauaudit_logsmulai membesar dan akses utamanya berurutan: pindah kecursorPaginate(). - Jangan berhenti di level Laravel saja; pastikan ada index komposit yang mendukung filter dan urutan query.
- Validasi perbaikan dengan
EXPLAIN, bukan perasaan.
Poin terpentingnya: cursor pagination bukan pengganti ajaib untuk semua kasus. Ia bekerja baik ketika pola akses data memang berurutan. Jika digabung dengan desain index yang benar, Anda bisa menghilangkan bottleneck offset yang umum muncul pada tabel yang terus tumbuh.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!