Index yang terpasang belum tentu index yang terpakai. Ada kelompok pola query yang tampak wajar, berjalan benar, mengembalikan hasil yang tepat — dan diam-diam melumpuhkan index yang sudah kita bangun dengan cermat. Karena hasilnya tetap benar, kebiasaan semacam ini bisa bertahun-tahun hidup di aplikasi tanpa ada yang curiga, sampai tabel membesar dan query mulai tersendat. Pada chapter ini kita mengumpulkan pelaku utamanya satu per satu dan membunyikan alarmnya lewat EXPLAIN: fungsi yang membungkus kolom, wildcard di awal LIKE, angka yang dicari pada kolom teks, OR lintas kolom, hingga kebiasaan != dan NULL. Setiap pola disertai pembenahannya, karena hampir semuanya punya bentuk tulis-ulang yang index-friendly.
Menyiapkan Tabel Latihan #
Tabel karyawan kembali dengan dua index yang akan menjadi korban percobaan kita, plus tabel kecil stok dengan kolom kode bertipe teks:
DROP DATABASE IF EXISTS belajar_index;
CREATE DATABASE belajar_index;
USE belajar_index;Output yang Dihasilkan:
Query OK, 0 rows affected, 1 warning (0.00 sec)
Query OK, 1 row affected (0.00 sec)
Database changedCREATE TABLE karyawan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama_depan VARCHAR(50) NOT NULL,
nama_belakang VARCHAR(50) NOT NULL,
email VARCHAR(120) NOT NULL,
kota VARCHAR(40) NOT NULL,
gaji DECIMAL(10,2) NOT NULL,
tanggal_masuk DATE NOT NULL
);
INSERT INTO karyawan (nama_depan, nama_belakang, email, kota, gaji, tanggal_masuk) VALUES
('Budi', 'Santoso', '[email protected]', 'Jakarta', 8500000.00, '2026-01-05'),
('Siti', 'Rahayu', '[email protected]', 'Bandung', 7200000.00, '2026-01-12'),
('Andi', 'Wijaya', '[email protected]', 'Surabaya', 9100000.00, '2026-02-01'),
('Rina', 'Kartika', '[email protected]', 'Jakarta', 6800000.00, '2026-02-10'),
('Dewi', 'Lestari', '[email protected]', 'Yogyakarta', 7500000.00, '2026-03-03'),
('Tono', 'Sutrisno', '[email protected]', 'Bandung', 6000000.00, '2026-03-15'),
('Sari', 'Wulandari','[email protected]','Medan', 7300000.00, '2026-04-01'),
('Joko', 'Pratama', '[email protected]', 'Jakarta', 8900000.00, '2026-04-20'),
('Nurul', 'Hidayah', '[email protected]', 'Surabaya', 7000000.00, '2026-05-05'),
('Rizky', 'Maulana', '[email protected]', 'Medan', 7700000.00, '2026-05-18');
CREATE INDEX idx_tanggal ON karyawan(tanggal_masuk);
CREATE INDEX idx_email ON karyawan(email);Output yang Dihasilkan:
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: 0Fungsi pada Kolom Mematikan Index #
Polanya klasik: mencari data setahun penuh dengan membungkus kolom tanggal di dalam YEAR(). Index idx_tanggal berdiri di atas kolom tanggal_masuk — namun yang dibandingkan query bukan lagi nilai kolomnya, melainkan hasil fungsi atas kolom itu:
EXPLAIN SELECT * FROM karyawan WHERE YEAR(tanggal_masuk) = 2026;Output yang Dihasilkan:
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------------+
| 1 | SIMPLE | karyawan | NULL | ALL | NULL | NULL | NULL | NULL | 10 | 100.00 | Using where |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)Rencananya type = ALL dengan key = NULL: index diabaikan total. Penyebabnya struktural — index mengurutkan nilai tanggal, sedangkan YEAR(tanggal_masuk) adalah nilai turunan yang tidak tersimpan di index mana pun; untuk menghitungnya, MySQL harus membaca setiap baris. Aturannya berlaku umum untuk semua fungsi: UPPER(nama), SUBSTRING(kode, 1, 3), tanggal_masuk + INTERVAL 7 DAY — semuanya sama-sama menutup akses index. Pembenahannya menulis ulang kondisi sebagai rentang pada kolom mentah:
EXPLAIN SELECT * FROM karyawan
WHERE tanggal_masuk BETWEEN '2026-01-01' AND '2026-12-31';Output yang Dihasilkan:
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------------+
| 1 | SIMPLE | karyawan | NULL | ALL | idx_tanggal | NULL | NULL | NULL | 10 | 100.00 | Using where |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)Niat yang sama — “tahun 2026” — kini dinyatakan sebagai rentang tanggal_masuk, dan rencananya berubah menjadi type = range dengan key = idx_tanggal: server melompat ke awal tahun dan membaca searah hingga akhir tahun. Dua query yang secara logika setara, dua nasib yang berlawanan.
Wildcard di Awal LIKE #
LIKE dua wajah tergantung posisi wildcard-nya. Pola 'budi%' meninggalkan awalan yang pasti, sehingga index email bisa melompat ke posisi awal dan membaca searah — kita sudah membuktikannya di chapter sebelumnya. Pola '%example.com' tidak menyisakan apa pun untuk dipandu: kecocokan bisa terjadi di posisi mana pun dalam nilai:
EXPLAIN SELECT * FROM karyawan WHERE email LIKE '%example.com';Output yang Dihasilkan:
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------------+
| 1 | SIMPLE | karyawan | NULL | ALL | NULL | NULL | NULL | NULL | 10 | 11.11 | Using where |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)type = ALL, key = NULL — full table scan, dan setiap nilai email diperiksa ujungnya satu per satu. Pada domain email yang seragam seperti data kita, bentuk yang jauh lebih baik adalah mencari domain sebagai awalan lokal yang lebih spesifik atau memakai kolom domain terpisah — dan untuk pencarian kata di tengah teks panjang, MySQL menyediakan full-text index yang memang dirancang untuk itu, jauh di luar jangkauan B-tree.
Konversi Tipe Implisit: Angka pada Kolom Teks #
Jebakan paling senyap: mencari angka pada kolom bertipe VARCHAR. Kita siapkan tabel kecil dengan kode bertipe teks:
CREATE TABLE stok (
kode VARCHAR(10) NOT NULL,
jumlah INT NOT NULL
);
INSERT INTO stok (kode, jumlah) VALUES
('100', 5),
('200', 3),
('A1', 8),
('A2', 2);
CREATE INDEX idx_kode ON stok(kode);Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
Query OK, 4 rows affected (0.00 sec)
Records: 4 Duplicates: 0 Warnings: 0
Query OK, 0 rows affected (0.00 sec)
Records: 0 Duplicates: 0 Warnings: 0Perbandingan dengan literal teks berjalan sehat:
EXPLAIN SELECT * FROM stok WHERE kode = '100';Output yang Dihasilkan:
+----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+
| 1 | SIMPLE | stok | NULL | ref | idx_kode | idx_kode | 42 | const | 1 | 100.00 | NULL |
+----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)type = ref dengan key = idx_kode — index dipakai. Sekarang tanda kutipnya kita buang:
EXPLAIN SELECT * FROM stok WHERE kode = 100;Output yang Dihasilkan:
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
| 1 | SIMPLE | stok | NULL | ALL | idx_kode | NULL | NULL | NULL | 4 | 25.00 | Using where |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 3 warnings (0.00 sec)type = ALL dan key = NULL, padahal possible_keys = idx_kode mencatat bahwa index itu ada dan layak. Yang terjadi: MySQL mengkonversi kolom teks kode menjadi angka pada setiap baris untuk membandingkannya dengan 100 — dan hasil konversi per baris tidak ada di index. Aturan praktisnya satu kalimat: tipe literal harus mengikuti tipe kolom — teks dicari dengan teks, angka dicari dengan angka.
Konversi ini juga membawa kejutan semantik yang jarang disadari. Nilai teks yang tidak dapat dibaca sebagai angka dikonversi menjadi 0, sehingga membandingkan kolom teks dengan angka nol justru menangkap semua kode non-numerik:
SELECT * FROM stok WHERE kode = 0;Output yang Dihasilkan:
+------+--------+
| kode | jumlah |
+------+--------+
| A1 | 8 |
| A2 | 2 |
+------+--------+
2 rows in set (0.00 sec)Dua baris A1 dan A2 muncul — bukan karena kodenya nol, melainkan karena 'A1' dan 'A2' sama-sama dikonversi menjadi 0 lalu lolos pembanding = 0. Query semacam ini biasanya lahir dari kesalahan tipe parameter aplikasi, dan hasilnya yang “kebetulan benar” di data kecil bisa berubah menjadi kesalahan data di production.
OR Antar Kolom yang Berbeda Index #
Kondisi OR yang menyambungkan dua kolom berbeda — WHERE kota = 'Jakarta' OR gaji > 8000000 — tidak bisa dilayani satu index biasa, karena tidak ada satu struktur pun yang mengurutkan kedua hal itu sekaligus. Optimizer punya dua pilihan: index merge, yaitu membaca dua index terpisah lalu menggabungkan hasilnya lewat union; atau full table scan bila perkiraan biayanya lebih murah — dan pilihan ini berganti-ganti mengikuti data serta statistik, sehingga perilaku OR lintas kolom kurang dapat diprediksi daripada pola lain. Bentuk tulis-ulang yang lebih terkendali adalah memecah menjadi UNION dua query yang masing-masing memakai satu index; hasilnya sama, jalurnya eksplisit. OR antar kondisi pada kolom yang sama tidak terkena masalah ini — itu adalah range scan biasa.
Operator != dan NULL #
Dua pola terakhir tidak mematikan index secara teknis, tetapi menjauhkannya dari manfaatnya. Operator != dan NOT LIKE umumnya memilih hampir seluruh baris — baris yang tidak sama jauh lebih banyak daripada yang sama — sehingga optimizer kerap memutuskan membaca seluruh tabel saja, atau memakai range scan yang membaca hampir seluruh entri index; keduanya nyaris tidak menyisakan penghematan. Pola IS NULL dan IS NOT NULL sebaliknya dapat dilayani index dengan baik, tetapi manfaatnya bergantung sebarannya: bila hampir semua baris bernilai NULL, pencarian IS NOT NULL yang langka justru sangat terbantu, sedangkan IS NULL pada mayoritas baris memang tidak bisa dihemat. Pelajarannya menyatu dengan tema chapter: efektivitas index ditentukan oleh berapa baris yang tersisa setelah kondisi diterapkan, bukan sekadar apakah kondisi menyebut kolom terindeks.
Membersihkan Database Latihan #
Database belajar_index kita jatuhkan kembali:
DROP DATABASE belajar_index;Output yang Dihasilkan:
Query OK, 2 rows affected (0.00 sec)Referensi #
- WHERE Clause Optimization — MySQL 8.4 Reference Manual
- How MySQL Uses Indexes — MySQL 8.4 Reference Manual
- EXPLAIN Output Format — MySQL 8.4 Reference Manual
Peta jebakan kini lengkap: fungsi pada kolom, wildcard awalan, konversi tipe implisit, OR lintas kolom, dan operator seleksi-sisa-besar — semuanya bisa ditulis ulang atau dihindari dengan bentuk yang lebih index-friendly. Pertanyaan pamungkas section ini bukan lagi “bagaimana index dipakai”, melainkan “berapa harga yang kita bayar untuk memilikinya”: setiap index yang berdiri ikut dikerjakan pada setiap penulisan data. Chapter penutup menghitung harga pemeliharaan itu dan menyusun aturan praktis untuk memutuskan index mana yang layak hidup.