Query yang sama bisa berjalan sepersekian milidetik atau berjam-jam, dan perbedaannya sering tidak terlihat dari teks SQL-nya. Dua query yang tampak sederhana — mencari pelanggan di kota tertentu, memfilter pesanan satu tahun — bisa memilih jalur eksekusi yang sama sekali berbeda bergantung pada index mana yang tersedia dan dipilih. Menebak penyebab query lambat dari intuisi saja adalah kebiasaan yang mahal: kita mengubah query, menambah index, atau menaikkan spesifikasi server tanpa tahu letak masalah yang sebenarnya. MySQL sudah menyimpan jawabannya dalam dokumen bernama query execution plan, dan section ini mengajari kita cara membacanya sampai tuntas.

Menyiapkan Tabel Latihan #

Kita pakai tabel pelanggan yang akan menemani sisa section ini — sepuluh pelanggan di enam kota, dengan dua index yang akan sering menjadi bahan pertimbangan optimizer:

sql
DROP DATABASE IF EXISTS belajar_opt;
CREATE DATABASE belajar_opt;
USE belajar_opt;

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
sql
CREATE TABLE pelanggan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(60) NOT NULL,
  kota VARCHAR(40) NOT NULL,
  email VARCHAR(100) NOT NULL,
  tanggal_daftar DATE NOT NULL
);

INSERT INTO pelanggan (nama, kota, email, tanggal_daftar) VALUES
  ('Budi Santoso',   'Jakarta',    '[email protected]',  '2026-01-05'),
  ('Siti Rahayu',    'Bandung',    '[email protected]',  '2026-01-12'),
  ('Andi Wijaya',    'Surabaya',   '[email protected]',  '2026-02-01'),
  ('Rina Kartika',   'Jakarta',    '[email protected]',  '2026-02-10'),
  ('Dewi Lestari',   'Yogyakarta', '[email protected]',  '2026-03-03'),
  ('Tono Sutrisno',  'Bandung',    '[email protected]',  '2026-03-15'),
  ('Sari Wulandari', 'Medan',      '[email protected]',  '2026-04-01'),
  ('Joko Pratama',   'Jakarta',    '[email protected]',  '2026-04-20'),
  ('Nurul Hidayah',  'Surabaya',   '[email protected]', '2026-05-05'),
  ('Rizky Maulana',  'Medan',      '[email protected]', '2026-05-18');

CREATE INDEX idx_kota ON pelanggan(kota);
CREATE INDEX idx_daftar ON pelanggan(tanggal_daftar);

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 10 rows affected (0.00 sec)
Records: 10  Duplicates: 0  Warnings: 0

Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0

Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0

Apa Itu Query Execution Plan #

Query execution plan adalah rencana eksekusi yang disusun oleh optimizer MySQL sebelum satu baris data pun dibaca. Rencana itu memuat keputusan-keputusan paling menentukan biaya query: index mana yang dipakai untuk setiap tabel (atau tidak sama sekali), tabel mana yang dibaca lebih dulu pada join (join order), bagaimana setiap tabel diakses (access type — lompat langsung ke satu baris, rentang nilai, atau memindai seluruh tabel), serta langkah tambahan seperti pengurutan di memori. Dua query dengan hasil identik bisa memiliki dua plan yang biayanya berjauhan.

Kita buktikan langsung dengan dua query yang niatnya sama — “pelanggan tahun 2026” — dan EXPLAIN sebagai juri. Query pertama membungkus kolom di dalam fungsi YEAR():

sql
EXPLAIN SELECT id, nama FROM pelanggan WHERE YEAR(tanggal_daftar) = 2026;

Output yang Dihasilkan:

text
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table     | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | pelanggan | NULL       | ALL  | NULL          | NULL | NULL    | NULL |   10 |   100.00 | Using where |
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

Query kedua menyatakan niat yang sama sebagai rentang pada kolom mentah:

sql
EXPLAIN SELECT id, nama FROM pelanggan
WHERE tanggal_daftar BETWEEN '2026-01-01' AND '2026-12-31';

Output yang Dihasilkan:

text
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table     | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | pelanggan | NULL       | ALL  | idx_daftar    | NULL | NULL    | NULL |   10 |   100.00 | Using where |
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

Pada plan pertama, type = ALL berarti MySQL berniat memindai seluruh tabel baris demi baris, dan key = NULL menegaskan tidak ada index yang terpakai. Pada plan kedua, type = range dengan key = idx_daftar — server melompat ke awal tahun lalu membaca searah sampai batas akhir rentang. Hasil kedua query persis sama; jalurnya tidak. Inilah alasan membaca plan mengalahkan menebak: keputusan optimizer hanya terlihat di sana.

Posisi Plan dalam Alur Eksekusi #

Sebelum plan ada, query melewati dua tahap: parser memeriksa sintaks dan membangun struktur internal, lalu optimizer membandingkan kandidat jalur eksekusi dan memilih yang termurah berdasarkan statistik tabel. Plan adalah keluaran tahap optimizer — dokumen final yang dibaca executor saat query dijalankan. Posisinya tepat di tengah alur berikut:

Karena plan disusun sebelum eksekusi, seluruh angka di dalamnya adalah estimasi — perkiraan optimizer dari statistik tabel, bukan hasil pengukuran. Angka rows = 10 berarti optimizer memperkirakan sepuluh baris akan diperiksa, bukan janji bahwa tepat sepuluh baris dibaca saat query berjalan. Ketika estimasi dan kenyataan berjauhan, optimizer bisa memilih plan yang keliru; untuk membandingkan estimasi dengan pengukuran aktual, MySQL menyediakan EXPLAIN ANALYZE yang kita bahas di chapter tersendiri.

Empat Kolom yang Dibaca Pertama #

Output EXPLAIN berbentuk tabel dua belas kolom, dan membacanya dari kiri ke kanan seperti membaca novel itu tidak efisien. Praktik yang umum: mulai dari empat kolom yang paling banyak bercerita, baru melengkapi detail bila perlu.

KolomPertanyaan yang dijawabNilai yang menggembirakanNilai yang perlu diwaspadai
typeBagaimana tabel diakses?const, eq_ref, ref, rangeALL (full table scan)
keyIndex mana yang dipakai?nama index terisiNULL padahal query ber-WHERE
rowsBerapa baris diperkirakan diperiksa?angka kecilangka mendekati jumlah baris tabel
ExtraLangkah tambahan apa yang terjadi?Using indexUsing filesort, Using temporary

Mari kita uji urutan baca itu pada pencarian di kolom tanpa index:

sql
EXPLAIN SELECT id, nama FROM pelanggan WHERE nama = 'Budi Santoso';

Output yang Dihasilkan:

text
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table     | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | pelanggan | NULL       | ALL  | NULL          | NULL | NULL    | NULL |   10 |    10.00 | Using where |
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

Dengan empat kolom andalan kita langsung terbaca: type = ALL (full scan), key = NULL (tidak ada index terpakai — wajar, kolom nama memang belum di-index), rows = 10 (seluruh tabel diperkirakan diperiksa), dan Extra = Using where (filter diterapkan sambil memindai). Bandingkan dengan pencarian di kolom yang ber-index:

sql
EXPLAIN SELECT id, nama FROM pelanggan WHERE kota = 'Jakarta';

Output yang Dihasilkan:

text
+----+-------------+-----------+------------+------+---------------+----------+---------+-------+------+----------+-------+
| id | select_type | table     | partitions | type | possible_keys | key      | key_len | ref   | rows | filtered | Extra |
+----+-------------+-----------+------------+------+---------------+----------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | pelanggan | NULL       | ref  | idx_kota      | idx_kota | 162     | const |    3 |   100.00 | NULL  |
+----+-------------+-----------+------------+------+---------------+----------+---------+-------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)

Plan kedua jauh lebih sehat: type = ref (lompat ke kelompok nilai lewat index), key = idx_kota terisi, dan rows jatuh ke angka kecil — optimizer memperkirakan hanya tiga baris Jakarta yang diperiksa, bukan sepuluh. Kolom filtered menambahkan satu lapis informasi: dari baris yang diperiksa, berapa persen yang diperkirakan lolos kondisi. Pada tabel sepuluh baris, perbedaannya terasa kecil; pada tabel sepuluh juta baris, selisih rows = 3 melawan rows = 10000000 adalah selisih antara query yang lincah dan query yang menggendong seluruh server.

Membersihkan Database Latihan #

Database belajar_opt kita jatuhkan kembali:

sql
DROP DATABASE belajar_opt;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

Referensi #

Kita sekarang tahu apa itu execution plan, posisinya dalam alur parser–optimizer–executor, dan empat kolom mana yang dibaca lebih dulu. Tetapi tabel dua belas kolom itu masih menyimpan banyak kolom yang belum dibedah — id, select_type, key_len, ref — dan tiap kolom menyimpan ceritanya sendiri. Chapter berikutnya membedah EXPLAIN kolom demi kolom sampai tidak ada satu sel pun yang misterius.