Menjawab Langsung: Mengapa Query Laravel Bisa Lambat Saat Data Membengkak

Kalau aplikasi Laravel Anda mulai melambat saat tabel besar, penyebab paling umum adalah query yang tidak menggunakan index yang tepat. Langkah pertama adalah mengidentifikasi query mana yang paling berat dan apakah database benar-benar memanfaatkan index. Dalam artikel ini kita langsung membahas cara praktis mengecek execution plan, menambahkan index yang sesuai (termasuk composite), menggunakan pagination yang konsisten, serta menjaga indeks tetap sehat saat data tumbuh pesat.

Fokus utama adalah memastikan query tetap menggunakan index bahkan saat volume data meningkat, tanpa harus menunggu rebuild atau downtime yang panjang.

1. Identifikasi Query Lambat dan Analisis Execution Plan

Laravel menyediakan log query (– misalnya DB::listen atau Telescope) untuk merekam durasi. Namun, durasi saja belum cukup. Gunakan EXPLAIN untuk mengetahui apakah database memindai tabel penuh atau menggunakan index. Contoh pendekatan:

$query = "SELECT * FROM orders WHERE customer_id = ? ORDER BY created_at DESC";
$plan = DB::select('EXPLAIN ' . $query, [$customerId]);
// Perhatikan kolom `key`, `rows`, dan `Extra` untuk melihat penggunaan index dan apakah terjadi filesort.

Perhatikan kolom key (nama index yang dipakai), rows (jumlah baris yang dipindai), dan Extra (misal "Using filesort" atau "Using temporary"). Jika key null atau mengindikasikan penggunaan index yang tidak selektif, saatnya meninjau struktur index.

Gunakan log query dan EXPLAIN bersama-sama: urutkan query slowest, jalankan EXPLAIN, lalu catat kombinasi kolom filter/sort yang berulang.

2. Optimasi Indexing Query Laravel

Kenali kolom filter, join, dan order

Query menjadi cepat ketika database memiliki index yang sesuai dengan WHERE, JOIN, dan ORDER/BY atau GROUP/BY. Ketika kolom-kolom tersebut digunakan secara bersamaan, pertimbangkan membuat composite index.

Contoh: query berikut menyeleksi berdasarkan customer_id dan mengurutkan berdasarkan created_at:

SELECT * FROM orders
WHERE customer_id = ?
ORDER BY created_at DESC
LIMIT 25

Jika hanya terdapat index tunggal pada customer_id, database masih harus melakukan sort tambahan (filesort). Solusinya adalah index gabungan:

Schema::table('orders', function (Blueprint $table) {
    $table->index(['customer_id', 'created_at'], 'orders_customer_created_at');
});

Index tersebut membantu database menangani kondisi WHERE dan ORDER BY sekaligus. Pastikan urutan kolom pada composite index mengikuti urutan penggunaan dalam query.

Memilih index untuk join dan pagination

Untuk JOIN, index pada kolom kunci join (misal order_items.order_id) sangat penting. Jika query sering melakukan join multi-level, periksa execution plan setiap level dan tambahkan index pada kolom yang memiliki filter/filter tambahan (misal order_items.status).

Untuk pagination tradisional OFFSET, index composite yang mencakup kolom sort meminimalkan biaya sort. Namun, memiliki composite index juga berarti tambahan penulisan setiap insert/update; pertimbangkan trade-off antara read-heavy dan write-heavy.

3. Pagination Cursor dan Batch agar Response Konsisten

Pagination dengan OFFSET menjadi mahal dan tidak stabil saat data terus bertambah. Laravel menyediakan cursor() atau chunkById() untuk melakukan pagination berbasis cursor:

$users = User::where('status', 'active')
            ->orderBy('created_at')
            ->cursor();

foreach ($users as $user) {
    // proses per baris tanpa OFFSET
}

Cursor bekerja paling baik jika terdapat index yang mendukung kolom orderBy dan filter. Untuk iterasi batch yang membutuhkan ukuran halaman tetap, gunakan chunkById untuk memanfaatkan index primary saat mengambil batch berdasarkan id.

Keuntungan tambahan adalah konsistensi: cursor tidak melompat jika ada penambahan baris baru dalam proses paginate. Namun, cursor membaca satu per satu, jadi pastikan tidak menimbulkan overhead N+1 dengan eager loading.

4. Monitoring Pertumbuhan Indeks dan Skrip Migrasi Tanpa Downtime

Monitoring statistik indeks

Gunakan information_schema.STATISTICS atau tool monitoring seperti PMM/Database Monitoring untuk melihat CARDINALITY, index_length, atau rows_read. Query sederhana:

SELECT INDEX_NAME, COLUMN_NAME, CARDINALITY
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
  AND TABLE_NAME = 'orders';

Cardinality rendah menandakan index kurang selektif; pertimbangkan menambah kolom tambahan atau membuat partial index bila tersedia. Perhatikan pertumbuhan index_length agar storage tidak meledak.

Skrip migrasi dan penambahan index tanpa downtime

Laravel migration standar cukup untuk index kecil, namun pada tabel besar dapat memperlambat operasional. Untuk MySQL, gunakan opsi ALGORITHM=INPLACE dan LOCK=NONE ketika memungkinkan:

DB::statement(
    "ALTER TABLE orders ADD KEY orders_customer_created_at (customer_id, created_at) ALGORITHM=INPLACE, LOCK=NONE"
);

Pastikan versi engine mendukung opsi tersebut. Jika tidak, pertimbangkan menambahkan index pada database cadangan lalu melakukan switch, atau menggunakan gh-ost / pt-online-schema-change untuk migrasi tanpa downtime.

Setelah index ditambahkan, jalankan ANALYZE TABLE agar optimizer segera mendapatkan statistik terbaru.

Kesimpulan

Optimasi Indexing Query Laravel saat data tumbuh pesat berarti mengidentifikasi query lambat, membaca execution plan, menambahkan index tunggal/composite yang tepat, menggunakan pagination cursor atau batch untuk menekan penggunaan OFFSET, serta memantau dan menambah index tanpa mengganggu uptime. Setiap langkah harus disertai pengukuran (EXPLAIN, monitoring) agar perubahan terbukti efektif.

Selalu ukur sebelum dan sesudah perubahan, dan ingat bahwa lebih banyak index bisa memperlambat write—jadi tambahkan index dengan pertimbangan beban baca versus tulis.