Membaca EXPLAIN MySQL untuk Menemukan Query Lambat

Membaca EXPLAIN MySQL untuk Menemukan Query Lambat

Halaman daftar artikel yang tadinya 40 ms sekarang 1,4 detik. Tidak ada kode yang berubah; yang berubah jumlah barisnya, dari 300 ke 48.000. Menambah RAM tidak menolong, mengganti VPS tidak menolong. Yang menolong adalah membaca satu keluaran EXPLAIN dan menambahkan satu indeks.

EXPLAIN adalah alat diagnostik paling berguna di MySQL dan paling jarang dipakai, biasanya karena keluarannya terlihat seperti tabel penuh singkatan. Padahal hanya empat kolom yang benar-benar perlu kamu baca.

Kolom type: ALL, index, range, ref, const dan artinya

type menjawab satu pertanyaan: bagaimana MySQL menemukan barisnya? Urutkan dari yang terburuk:

ALL — pemindaian tabel penuh. Setiap baris dibaca. Pada 48.000 baris masih terasa cepat di laptop; pada 2 juta baris dengan sepuluh pengunjung bersamaan, ini penyebab situs tumbang.

index — memindai seluruh indeks, bukan tabel. Lebih ringan dari ALL tapi tetap linear. Biasanya muncul saat ORDER BY bisa dilayani indeks tapi WHERE-nya tidak menyaring apa pun.

range — membaca rentang indeks. Ini hasil yang wajar dan bagus untuk WHERE created_at >= ?, IN (...), atau BETWEEN.

ref — pencarian indeks non-unik dengan nilai tetap. Target normal untuk WHERE category = ?.

eq_ref / const — satu baris lewat kunci unik atau primer. Tercepat yang ada; const berarti MySQL bisa menyelesaikannya sekali saja.

Contoh nyata pada tabel blogs:

EXPLAIN SELECT id, title, slug FROM blogs
 WHERE status = 'published' ORDER BY published_at DESC LIMIT 12;
id  select_type  table  type  key   rows   filtered  Extra
 1  SIMPLE       blogs  ALL   NULL  48213  10.00     Using where; Using filesort

Tiga tanda bahaya sekaligus: type=ALL, key=NULL (tidak ada indeks dipakai), dan Using filesort. MySQL membaca 48.213 baris, membuang 90%, lalu mengurutkan sisanya di memori — untuk mengembalikan 12 baris.

Aturan sederhana yang bisa kamu pegang: ALL pada tabel di atas 10.000 baris di jalur yang sering diakses harus diperbaiki. ALL pada tabel 200 baris (misalnya tabel kategori) tidak masalah dan tidak perlu indeks.

rows dan filtered sebagai perkiraan biaya sesungguhnya

rows adalah perkiraan jumlah baris yang akan diperiksa MySQL; filtered adalah persentase yang diperkirakan lolos WHERE. Kalikan keduanya untuk mendapat perkiraan baris hasil:

rows = 48213, filtered = 10.00  →  MySQL memeriksa 48.213 baris untuk mendapat ±4.821

Angka ini perkiraan berbasis statistik indeks, jadi bisa meleset — terutama setelah penghapusan massal. Kalau rows terlihat tidak masuk akal, segarkan statistiknya:

ANALYZE TABLE blogs;

Untuk kebenaran mutlak, EXPLAIN ANALYZE (MySQL 8.0.18+) benar-benar menjalankan kuerinya dan melaporkan waktu nyata:

EXPLAIN ANALYZE SELECT ... ;
-- -> Limit: 12 row(s)  (actual time=412..412 rows=12 loops=1)
--    -> Sort: blogs.published_at DESC  (actual time=412..412 rows=12 loops=1)
--       -> Filter: (blogs.`status` = 'published')  (actual time=0.1..380 rows=4102 loops=1)
--          -> Table scan on blogs  (actual time=0.08..310 rows=48213 loops=1)

Baca dari dalam ke luar. rows=48213 di pemindaian tabel adalah biaya sebenarnya, dan actual time=412 di puncak adalah harga yang dibayar pengunjung. Perhatikan juga loops — nilai besar di sini menandakan kueri dijalankan berulang per baris tabel lain, pola N+1 yang tersembunyi di dalam satu pernyataan SQL.

Menemukan filesort dan temporary table pada ORDER BY

Kolom Extra memuat dua frasa yang paling sering menjelaskan kelambatan:

Using filesort — MySQL harus mengurutkan hasil sendiri karena tidak ada indeks yang sudah berurutan sesuai ORDER BY. Namanya menipu: pengurutan bisa terjadi di memori, tapi begitu hasilnya lebih besar dari sort_buffer_size, ia benar-benar menulis ke disk.

Using temporary — MySQL membuat tabel sementara, biasanya karena GROUP BY dengan urutan berbeda dari ORDER BY, atau DISTINCT pada kolom tanpa indeks. Kombinasi Using temporary; Using filesort pada tabel besar adalah kombinasi terburuk yang umum ditemui.

Yang baik dilihat di Extra:

  • Using indexcovering index: semua kolom yang dibutuhkan ada di indeks, tabelnya tidak perlu disentuh sama sekali. Ini keadaan terbaik.
  • Using where — normal, bukan masalah.
  • Backward index scan — MySQL 8 membaca indeks dari belakang untuk ORDER BY ... DESC. Bagus, artinya filesort berhasil dihindari.

Merancang indeks komposit sesuai urutan WHERE dan ORDER BY

Inilah bagian yang menyelesaikan masalah. Urutan kolom dalam indeks komposit bukan selera — ada aturannya, dan urutan yang salah membuat indeks tidak terpakai.

Aturannya: kolom kesetaraan dulu, lalu kolom pengurutan, lalu kolom rentang.

Untuk kueri kita:

WHERE status = 'published'      -- kesetaraan
ORDER BY published_at DESC      -- pengurutan

Indeks yang benar:

CREATE INDEX idx_blogs_status_published ON blogs (status, published_at);

Hasilnya:

id  select_type  table  type  key                          rows  filtered  Extra
 1  SIMPLE       blogs  ref   idx_blogs_status_published    4102  100.00    Using where

ALL jadi ref, filesort hilang (MySQL membaca indeks terbalik), dan 1,4 detik jadi 3 ms. Satu indeks.

Empat hal yang perlu diketahui tentang indeks komposit:

Prefiks paling kiri. Indeks (status, published_at) bisa melayani WHERE status = ? dan WHERE status = ? ORDER BY published_at, tapi tidak bisa melayani WHERE published_at > ? sendirian. Kolom paling kiri wajib ada dalam kueri.

Kolom rentang mengakhiri kegunaan indeks. Pada WHERE status = ? AND published_at > ? ORDER BY views, indeks bisa dipakai sampai published_at, lalu pengurutan views tetap filesort. Tidak ada urutan kolom yang menyelesaikan ini; kalau perlu, pertimbangkan indeks lain untuk pola kueri itu.

Fungsi pada kolom mematikan indeks. WHERE DATE(created_at) = '2026-09-20' akan memindai penuh. Tulis sebagai rentang: WHERE created_at >= '2026-09-20' AND created_at < '2026-09-21'.

OR sering mematikan indeks. Kondisi visibilitas seperti (status = 'published' OR (status = 'scheduled' AND published_at <= NOW())) biasanya diselesaikan dengan indeks pada (status, published_at) ditambah index_merge, tapi periksa EXPLAIN-nya — kalau hasilnya ALL, seringkali UNION ALL dari dua kueri sederhana justru lebih cepat.

Jangan membuat indeks untuk setiap kolom. Setiap indeks memperlambat INSERT dan UPDATE, dan memakan ruang. Cari indeks yang tidak pernah dipakai:

SELECT object_name, index_name, count_star
  FROM performance_schema.table_io_waits_summary_by_index_usage
 WHERE object_schema = DATABASE() AND index_name IS NOT NULL AND count_star = 0
 ORDER BY object_name;

Mengaktifkan slow query log di VPS dan menyaring pelaku utama

EXPLAIN berguna kalau kamu sudah tahu kueri mana yang bermasalah. Slow query log yang memberitahumu.

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.3;          -- detik; 0.3 cukup agresif untuk situs marketing
SET GLOBAL log_queries_not_using_indexes = 'ON';
SHOW VARIABLES LIKE 'slow_query_log_file';

Untuk permanen, taruh di /etc/mysql/mysql.conf.d/mysqld.cnf:

slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.3
log_queries_not_using_indexes = 1

Nyalakan log_queries_not_using_indexes hanya sementara — pada aplikasi yang sibuk, log akan tumbuh cepat karena kueri kecil ke tabel kecil juga tercatat.

Setelah 24 jam, ringkas dengan mysqldumpslow yang sudah ikut terpasang:

mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

-s t mengurutkan berdasarkan total waktu, dan itu urutan yang benar. Kueri 90 ms yang dijalankan 12.000 kali sehari lebih merugikan daripada laporan 4 detik yang dijalankan sekali — dan kueri yang lambat sekali sehari biasanya bukan yang membuat situs terasa berat.

Ambil tiga kueri teratas dari daftar itu, jalankan EXPLAIN pada masing-masing, dan perbaiki. Dalam pengalaman saya, tiga indeks pertama menyelesaikan 80% keluhan "situsnya lambat" pada aplikasi Express + MySQL berukuran menengah.

Langkah berikutnya

Hari ini: nyalakan slow query log dengan long_query_time = 0.3. Besok: jalankan mysqldumpslow -s t -t 10, dan untuk setiap kueri di tiga teratas catat type, rows, dan Extra dari EXPLAIN-nya. Perbaiki yang type=ALL atau ber-filesort lebih dulu, satu indeks sekali waktu, dan ukur ulang setelah masing-masing.

Satu indeks per perubahan, bukan lima. Kalau lima ditambahkan sekaligus, kamu tidak akan tahu mana yang berguna dan mana yang hanya memperlambat penulisan.