Halaman daftar data di Laravel sering terasa cepat saat tabel masih kecil, lalu mendadak lambat ketika jumlah baris bertambah. Pola yang paling sering terjadi adalah query Eloquent dengan kombinasi WHERE, ORDER BY, dan pagination tidak lagi efisien karena database tidak punya composite index yang sesuai dengan bentuk query.
Masalahnya bukan sekadar “belum ada index”, tetapi index yang ada tidak cocok dengan pola akses data. Single-column index pada tiap kolom sering terlihat benar di atas kertas, tetapi gagal membantu saat query memfilter beberapa kolom sekaligus lalu mengurutkan hasil. Di artikel ini, kita fokus pada cara mendiagnosis gejala tersebut di Laravel, membaca EXPLAIN secara dasar, lalu memilih composite index yang lebih tepat tanpa membuat klaim benchmark yang tidak bisa digeneralisasi.
Gejala query lambat pada halaman daftar/filter
Kasus yang umum adalah halaman admin atau dashboard yang menampilkan data dengan filter status, tenant, tanggal, lalu diurutkan berdasarkan waktu terbaru dan ditampilkan per halaman. Contohnya:
- Daftar pesanan per toko atau tenant
- Daftar invoice dengan filter status
- Daftar aktivitas pengguna dengan rentang waktu
- Daftar tiket support dengan sorting terbaru
Gejala yang sering muncul:
- Query terasa cepat saat data ribuan baris, lalu lambat saat ratusan ribu atau jutaan baris
- Pagination halaman awal masih masuk akal, tetapi makin berat pada halaman berikutnya
- CPU database naik saat filter tertentu dipakai
- Index sudah ada per kolom, tetapi planner tetap melakukan scan besar atau sort tambahan
- Response endpoint daftar data lambat meski relasi Eloquent sudah dioptimalkan
Dalam banyak kasus, bottleneck utamanya bukan N+1 query, tetapi query utama daftar data itu sendiri.
Contoh kasus nyata: query Eloquent yang melambat
Misalkan Anda punya tabel orders untuk aplikasi multi-tenant. Halaman daftar pesanan memfilter berdasarkan tenant, status, lalu mengurutkan data terbaru:
Schema::create('orders', function ($table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->string('status', 30);
$table->timestamp('created_at')->nullable();
$table->decimal('total_amount', 12, 2)->default(0);
$table->unsignedBigInteger('customer_id')->nullable();
$table->timestamps();
});Query Eloquent yang umum:
$orders = Order::query()
->where('tenant_id', $tenantId)
->where('status', 'paid')
->orderByDesc('created_at')
->paginate(50);SQL yang kira-kira dihasilkan:
SELECT *
FROM orders
WHERE tenant_id = ?
AND status = ?
ORDER BY created_at DESC
LIMIT 50 OFFSET 0;Sekilas query ini sederhana. Namun ketika tabel membesar, database harus menjawab tiga kebutuhan sekaligus:
- Menyaring berdasarkan
tenant_id - Menyaring lagi berdasarkan
status - Mengembalikan hasil dalam urutan
created_at DESC
Jika Anda hanya membuat index seperti ini:
$table->index('tenant_id');
$table->index('status');
$table->index('created_at');database belum tentu bisa menjalankan query secara efisien. Planner mungkin hanya memakai salah satu index, atau menggabungkan strategi yang tetap mahal, lalu masih perlu melakukan sort di luar index.
Mengapa single-column index sering gagal
Kesalahan yang umum adalah menganggap “semua kolom yang dipakai query sudah di-index, berarti aman”. Pada praktiknya, itu belum cukup.
Masalah utama: query butuh kombinasi, bukan kolom tunggal
Query di atas tidak mencari seluruh baris dengan tenant_id tertentu saja. Query mencari:
tenant_id = Xstatus = 'paid'- hasilnya harus sudah terurut berdasarkan
created_at DESC
Single-column index pada tenant_id hanya membantu menyempitkan kandidat berdasarkan tenant. Setelah itu, database masih mungkin harus memeriksa banyak baris untuk status dan melakukan sort untuk created_at.
Single-column index pada status juga sering kurang berguna jika nilainya sedikit dan distribusinya tidak selektif. Misalnya status hanya terdiri dari pending, paid, cancelled. Jika sebagian besar data adalah paid, maka index ini tidak banyak membantu karena hasil pencarian tetap sangat banyak.
Single-column index pada created_at mungkin membantu sorting, tetapi tidak otomatis efisien untuk kondisi filter gabungan. Database bisa saja memilih index untuk sorting namun tetap membaca terlalu banyak baris yang nantinya dibuang.
Urutan kolom dalam index memengaruhi hasil
Composite index bekerja berdasarkan urutan kolom. Index (tenant_id, status, created_at) berbeda secara praktis dari (created_at, tenant_id, status). Yang pertama cocok untuk query yang memfilter tenant dan status, lalu mengurutkan berdasarkan waktu. Yang kedua belum tentu berguna untuk pola query yang sama.
Intinya, index yang baik mengikuti pola akses query yang paling sering, bukan sekadar daftar kolom yang muncul di query.
Cara mendiagnosis dengan EXPLAIN secara dasar
Sebelum menambah index baru, lihat dulu bagaimana database mengeksekusi query. Anda bisa mengambil SQL dari Laravel lalu menjalankan EXPLAIN di database.
Mengambil SQL dari Laravel
Untuk inspeksi cepat, Anda bisa melihat SQL dan binding:
$query = Order::query()
->where('tenant_id', $tenantId)
->where('status', 'paid')
->orderByDesc('created_at');
$sql = $query->toSql();
$bindings = $query->getBindings();Atau gunakan query log / Laravel Debugbar / Telescope bila tersedia di lingkungan pengembangan. Tujuannya bukan alatnya, tetapi mendapatkan query SQL aktual yang berjalan.
Menjalankan EXPLAIN
Setelah mendapatkan SQL final, jalankan versi EXPLAIN-nya di database Anda. Bentuk output tergantung engine, tetapi beberapa hal dasar yang perlu diperhatikan biasanya serupa:
- Index mana yang dipakai
- Berapa banyak baris yang diperkirakan dibaca
- Apakah ada sort tambahan
- Apakah filter dikerjakan lewat index atau setelah membaca data
Anda tidak perlu menghafal seluruh kolom output EXPLAIN. Untuk kasus daftar/filter, fokus pada pertanyaan ini:
- Apakah planner memakai index yang relevan?
- Apakah jumlah baris yang dipindai masih besar?
- Apakah ada indikasi sorting yang tidak memanfaatkan index?
Tanda-tanda index belum tepat
- Planner memilih hanya index
tenant_idlalu tetap membaca banyak baris - Planner memilih index
statusyang sebenarnya tidak selektif - Masih ada operasi sort terpisah untuk
ORDER BY created_at - Perkiraan rows tetap tinggi walau filter terlihat spesifik
Kalau output menunjukkan database masih membaca banyak kandidat lalu menyortir hasilnya, itu tanda bahwa composite index yang sesuai kemungkinan dibutuhkan.
Memilih composite index yang sesuai pola query
Untuk query ini:
SELECT *
FROM orders
WHERE tenant_id = ?
AND status = ?
ORDER BY created_at DESC
LIMIT 50 OFFSET 0;pilihan yang umumnya paling masuk akal adalah composite index:
(tenant_id, status, created_at)Mengapa urutan ini sering tepat?
tenant_iddipakai sebagai filter equality dan biasanya sangat penting pada aplikasi multi-tenantstatusjuga dipakai sebagai filter equalitycreated_atdipakai untuk urutan hasil setelah subset data dipersempit oleh dua filter sebelumnya
Dengan urutan seperti itu, database memiliki peluang lebih besar untuk:
- Masuk ke subset data yang relevan lebih cepat
- Mengambil baris dalam urutan yang sudah sesuai
ORDER BY - Mengurangi kebutuhan sort tambahan
- Mengoptimalkan pengambilan halaman awal dengan
LIMIT
Kapan urutan index perlu berbeda?
Tidak ada satu urutan yang selalu benar untuk semua query. Misalnya:
- Jika query paling sering adalah
WHERE tenant_id = ? ORDER BY created_at DESC, maka index(tenant_id, created_at)bisa lebih relevan - Jika filter
statusjarang dipakai, menaruhnya di tengah bisa membuat index kurang optimal untuk query lain - Jika Anda punya banyak variasi query, mungkin perlu memilih index yang paling menguntungkan untuk pola akses paling kritis, bukan mencoba memuaskan semua variasi sekaligus
Karena itu, desain index sebaiknya dimulai dari query nyata yang paling sering dan paling mahal, bukan dari struktur tabel semata.
Implementasi composite index di migration Laravel
Di Laravel, composite index bisa ditambahkan lewat migration. Contoh:
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(
['tenant_id', 'status', 'created_at'],
'orders_tenant_status_created_at_idx'
);
});
}
public function down(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->dropIndex('orders_tenant_status_created_at_idx');
});
}
};Setelah migration dijalankan, ulangi pemeriksaan dengan EXPLAIN pada query yang sama. Jangan langsung menghapus index lama sebelum Anda yakin index tersebut memang tidak dipakai lagi oleh query lain.
Apakah perlu menghapus single-column index lama?
Jawabannya: tergantung. Jika index lama masih dipakai oleh query lain, biarkan. Jika ternyata redundant dan hanya menambah biaya write, pertimbangkan untuk menghapusnya setelah verifikasi.
Yang perlu diingat, setiap index tambahan:
- memakan ruang penyimpanan
- menambah biaya saat
INSERT,UPDATE, danDELETE - bisa membingungkan planner jika terlalu banyak pilihan yang mirip
Contoh perbaikan query Eloquent dan batasannya
Untuk kasus dasar, Eloquent-nya mungkin tidak perlu diubah sama sekali. Yang berubah adalah index di database:
$orders = Order::query()
->where('tenant_id', $tenantId)
->where('status', 'paid')
->orderByDesc('created_at')
->paginate(50);Ini poin penting: kadang query aplikasi sudah benar, tetapi struktur index belum mendukung.
Pilih kolom yang diambil bila memungkinkan
Kalau halaman daftar tidak butuh semua kolom, hindari SELECT *. Ambil hanya kolom yang diperlukan:
$orders = Order::query()
->select(['id', 'tenant_id', 'status', 'created_at', 'total_amount'])
->where('tenant_id', $tenantId)
->where('status', 'paid')
->orderByDesc('created_at')
->paginate(50);Ini tidak menggantikan index yang tepat, tetapi bisa mengurangi beban I/O dan payload data.
Offset pagination tetap punya biaya
Walau composite index membantu, pagination berbasis OFFSET tetap bisa mahal pada halaman yang sangat jauh karena database harus melewati sejumlah baris sebelum mengambil hasil. Untuk tabel yang sangat besar, pertimbangkan cursor pagination atau pola keyset pagination jika sesuai dengan kebutuhan UX dan konsistensi data.
Namun untuk banyak kasus Laravel sehari-hari, memperbaiki index dulu biasanya memberi dampak paling nyata sebelum mengganti strategi pagination.
Trade-off yang perlu dipahami
1. Write cost meningkat
Setiap index harus diperbarui saat data berubah. Tabel dengan trafik tulis tinggi akan menanggung biaya tambahan saat insert dan update. Jadi jangan menambah composite index tanpa alasan yang jelas.
2. Cardinality memengaruhi efektivitas
Cardinality secara sederhana menggambarkan seberapa beragam nilai suatu kolom. Kolom dengan variasi nilai tinggi biasanya lebih selektif. Sebaliknya, kolom seperti status sering punya cardinality rendah.
Itu sebabnya index tunggal pada status sering mengecewakan. Tetapi saat digabung dengan tenant_id dan created_at, nilainya bisa tetap berguna karena query memang memerlukan kombinasi tersebut.
3. Index tidak selalu membantu
Composite index tidak akan otomatis berguna jika:
- query sering memakai kondisi yang berbeda jauh dari urutan index
- hasil filter tetap mencakup sebagian besar tabel
- kolom di-
WHEREdibungkus fungsi yang menghambat pemanfaatan index - query memakai operator yang kurang cocok untuk pola index tertentu
- sorting dilakukan pada kolom yang tidak sejalan dengan filter utama
Contoh kesalahan yang sering terjadi:
WHERE DATE(created_at) = ?Membungkus kolom dengan fungsi seperti ini dapat membuat index pada created_at menjadi kurang efektif. Lebih baik gunakan rentang waktu yang eksplisit jika memungkinkan.
4. Satu index tidak bisa melayani semua query dengan sempurna
Kalau satu tabel melayani banyak fitur dengan pola filter berbeda-beda, Anda perlu kompromi. Fokuskan index pada query yang paling kritis dari sisi frekuensi dan biaya, bukan mencoba mengoptimalkan semua skenario sekaligus.
Checklist verifikasi sebelum dan sesudah menambah composite index
Sebelum
- Identifikasi endpoint atau halaman yang lambat
- Ambil SQL aktual dari Eloquent
- Catat pola query: kolom
WHERE,ORDER BY, dan pagination - Jalankan
EXPLAINdan lihat index yang dipakai - Periksa apakah database masih membaca terlalu banyak baris atau melakukan sort tambahan
- Cek index yang sudah ada agar tidak menambah index yang sebenarnya duplikatif
Sesudah
- Jalankan migration penambahan composite index
- Ulangi
EXPLAINpada query yang sama - Bandingkan apakah planner kini memakai composite index yang baru
- Lihat apakah estimasi rows dan kebutuhan sort membaik
- Uji beberapa variasi filter yang benar-benar dipakai user
- Pastikan performa tulis tidak terdampak signifikan pada workload Anda
- Evaluasi apakah ada index lama yang redundant
Kesalahan umum saat mengoptimalkan query Laravel
- Langsung menambah banyak index tanpa membaca query nyata. Hasilnya bisa boros storage dan write cost meningkat.
- Mengandalkan single-column index untuk query multi-kondisi. Ini salah satu penyebab paling umum daftar data tetap lambat.
- Tidak memperhatikan urutan kolom dalam composite index. Kolom yang sama dengan urutan berbeda bisa menghasilkan manfaat yang sangat berbeda.
- Fokus pada Eloquent saja, lupa database planner. ORM tidak menggantikan kebutuhan memahami cara database mengakses data.
- Mengukur dengan data kecil. Query yang tampak baik di lokal belum tentu mewakili produksi.
Penutup
Untuk kasus halaman daftar dan filter data di Laravel, query yang menggabungkan WHERE, ORDER BY, dan pagination sering melambat bukan karena query-nya “salah”, tetapi karena index tidak mengikuti pola query. Di sinilah composite index yang tepat menjadi penting.
Mulailah dari query aktual yang lambat, baca EXPLAIN secara dasar, lalu susun index berdasarkan urutan filter dan sorting yang memang dipakai. Single-column index pada tiap kolom sering tidak cukup. Composite index seperti (tenant_id, status, created_at) bisa jauh lebih masuk akal untuk query yang memfilter tenant dan status lalu mengurutkan berdasarkan waktu.
Terakhir, jangan berhenti di penambahan index. Verifikasi hasilnya, pahami trade-off terhadap operasi tulis, dan pastikan index yang Anda buat memang selaras dengan pola akses data di aplikasi Laravel Anda.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!