Full table scan adalah diagnosis, bukan vonis: type = ALL di EXPLAIN hanya berkata “server membaca semua baris tabel ini” — alasan mengapa server sampai pada keputusan itu yang menentukan apakah kita perlu bertindak. Sering kali alasannya sederhana dan mudah dibereskan: kolom yang difilter tidak punya index, atau bentuk query menutup jalan index yang sebenarnya ada. Chapter ini mengumpulkan lima penyebab tersering beserta obatnya, semuanya dibuktikan dengan plan sebelum dan sesudah perbaikan supaya kita terlatih mengenali polanya dalam sekali lirik. Sebagai pengingat skala: contoh kita memakai tabel sepuluh baris agar outputnya mudah dibaca — logika perbaikannya yang sama berlaku pada tabel jutaan baris, tempat ALL berubah dari “tidak masalah” menjadi “insiden”.

Menyiapkan Tabel Latihan #

Tabel pelanggan hadir dengan tiga index: idx_kota, idx_daftar, dan idx_email. Kolom nama sengaja dibiarkan telanjang di awal — dia menjadi korban percobaan pertama kita:

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);
CREATE INDEX idx_email ON pelanggan(email);

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

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

Deteksi: Membaca Alarm ALL #

Alarm full scan terbaca dalam hitungan detik: type = ALL, key = NULL, rows menyamai jumlah baris tabel, dan Extra = Using where. Query pencarian nama berikut menyalakannya:

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)

Plan tersebut sah-sah saja — tidak ada index di kolom nama, jadi memindai seluruh tabel adalah satu-satunya pilihan. Inilah penyebab pertama sekaligus termudah diperbaiki.

Penyebab 1: Kolom Tanpa Index #

Perbaikannya lurus: pasang index di kolom yang difilter, lalu ukur ulang plan-nya.

sql
CREATE INDEX idx_nama ON pelanggan(nama);

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

Output yang Dihasilkan:

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

+----+-------------+-----------+------------+------+---------------+----------+---------+-------+------+----------+-------------+
| id | select_type | table     | partitions | type | possible_keys | key      | key_len | ref   | rows | filtered | Extra       |
+----+-------------+-----------+------------+------+---------------+----------+---------+-------+------+----------+-------------+
|  1 | SIMPLE      | pelanggan | NULL       | ref  | idx_nama      | idx_nama | 242     | const |    1 |   100.00 | Using index |
+----+-------------+-----------+------------+------+---------------+----------+---------+-------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

Plan setelah perbaikan: type = ref dengan key = idx_nama — lompatan langsung ke satu baris, ditambah bonus Using index karena index baru itu mengandung id dan nama sehingga query menjadi covering. Kolom yang layak di-index adalah yang muncul di filter query panas; kolom yang dicari sekali setahun tidak membutuhkannya.

Penyebab 2: Fungsi pada Kolom #

Index bisa hadir di kolom, tetapi query membungkus kolom itu di dalam fungsi — YEAR(), UPPER(), SUBSTRING() — sehingga yang dibandingkan bukan lagi nilai kolom yang tersimpan di index:

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)

possible_keys kosong dan key = NULL: idx_daftar tidak bisa melayani YEAR(tanggal_daftar) karena index menyimpan nilai tanggal, bukan hasil fungsi tahun. Obatnya menulis ulang niat “tahun 2026” sebagai rentang pada kolom mentah:

sql
EXPLAIN SELECT id, nama FROM pelanggan
WHERE tanggal_daftar >= '2026-01-01' AND tanggal_daftar < '2027-01-01';

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)

Rentang dua batas mengembalikan akses index (type = range), dengan batas atas eksklusif < '2027-01-01' yang aman dari jebakan ujung tahun. Kebalikan polanya kadang juga berguna: kalau fungsi memang tak terhindarkan dan query ini panas, index di atas kolom hasil fungsi (generated column + index) menjadi jalannya — topik yang sudah kita bahas di section indexes.

Penyebab 3: Leading Wildcard pada LIKE #

Pola LIKE yang wildcard-nya di depan — '%example.com' — tidak menyisakan awalan pasti untuk dipandu index. Kecocokan bisa terjadi di posisi mana pun dalam nilai:

sql
EXPLAIN SELECT id, nama FROM pelanggan WHERE email LIKE '%example.com';

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 |    11.11 | Using where |
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

type = ALL walaupun idx_email berdiri — index B-tree mengurutkan berdasarkan awalan nilai, dan pola tanpa awalan tidak punya titik lompat. Bandingkan dengan wildcard di belakang:

sql
EXPLAIN SELECT id, nama FROM pelanggan WHERE email LIKE 'budi%';

Output yang Dihasilkan:

text
+----+-------------+-----------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------+
| id | select_type | table     | partitions | type  | possible_keys | key       | key_len | ref  | rows | filtered | Extra                 |
+----+-------------+-----------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------+
|  1 | SIMPLE      | pelanggan | NULL       | range | idx_email     | idx_email | 402     | NULL |    1 |   100.00 | Using index condition |
+----+-------------+-----------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------+
1 row in set, 1 warning (0.00 sec)

type = range dengan key = idx_email: pola 'budi%' menyisakan awalan budi sehingga index melompat ke posisi awal dan membaca searah. Untuk pencarian kata di tengah teks panjang, alat yang tepat bukan LIKE melainkan full-text index (section tersendiri), yang memang dirancang untuk pencarian teks bebas.

Penyebab 4: Type Mismatch #

Literal yang tipenya berbeda dari kolom memaksa konversi per baris, dan hasil konversi itu tidak ada di index:

sql
EXPLAIN SELECT id, nama FROM pelanggan WHERE email = 12345;

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_email     | NULL | NULL    | NULL |   10 |    10.00 | Using where |
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 3 warnings (0.00 sec)

Perhatikan kolom possible_keys: idx_email tercatat layak, tetapi key = NULL — optimizer tahu index ada, hanya saja perbandingan teks-lawan-angka tidak bisa dilayani index. Solusinya satu kalimat: kirim literal dengan tipe yang sama dengan kolom — string untuk VARCHAR, angka untuk kolom numerik — biasanya diperbaiki di sisi aplikasi yang membangun parameter.

Penyebab 5: OR Lintas Kolom #

OR yang menghubungkan dua kondisi pada kolom berbeda meminta dua struktur index bekerja sama. Ketika salah satu sisi tidak bisa dilayani index, seluruh kondisi jatuh ke full scan:

sql
EXPLAIN SELECT id, nama FROM pelanggan
WHERE kota = 'Jakarta' OR 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  | idx_kota      | NULL | NULL    | NULL |   10 |   100.00 | Using where |
+----+-------------+-----------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

Sisi kota = 'Jakarta' punya idx_kota, tetapi sisi YEAR(tanggal_daftar) tidak terindekskan — cukup satu sisi lemah untuk menjatuhkan semuanya ke ALL. Kita perbaiki sisi lemahnya dengan rentang seperti penyebab 2:

sql
EXPLAIN SELECT id, nama FROM pelanggan
WHERE kota = 'Jakarta'
   OR (tanggal_daftar >= '2026-01-01' AND tanggal_daftar < '2027-01-01');

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_kota,idx_daftar | NULL | NULL    | NULL |   10 |    28.89 | Using where |
+----+-------------+-----------+------------+------+---------------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

Kini kedua sisi tercatat sebagai kandidat (possible_keys: idx_kota,idx_daftar), tetapi optimizer tetap memilih ALL — pada tabel sepuluh baris, menyapu sekali memang masih lebih murah daripada membaca dua index lalu menggabungkan hasilnya. Pelajarannya penting: membuat kedua sisi OR indexable adalah prasyarat, bukan jaminan — keputusan akhir tetap dihitung dari biaya. Pola index_merge sendiri nyata terjadi ketika kedua sisinya equality dan biayanya sebanding:

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

Output yang Dihasilkan:

text
+----+-------------+-----------+------------+-------------+-------------------+-------------------+---------+------+------+----------+---------------------------------------------+
| id | select_type | table     | partitions | type        | possible_keys     | key               | key_len | ref  | rows | filtered | Extra                                       |
+----+-------------+-----------+------------+-------------+-------------------+-------------------+---------+------+------+----------+---------------------------------------------+
|  1 | SIMPLE      | pelanggan | NULL       | index_merge | idx_kota,idx_nama | idx_kota,idx_nama | 162,242 | NULL |    4 |   100.00 | Using union(idx_kota,idx_nama); Using where |
+----+-------------+-----------+------------+-------------+-------------------+-------------------+---------+------+------+----------+---------------------------------------------+
1 row in set, 1 warning (0.00 sec)

type = index_merge dengan key = idx_kota,idx_nama dan Extra: Using union(...) — dua index dibaca terpisah, hasilnya digabungkan, tabel tidak disapu. OR antar kondisi pada kolom yang sama tidak terkena masalah ini karena itu adalah range biasa. Bila index_merge tidak dipilih oleh optimizer atau kita ingin jalur yang eksplisit dan terprediksi, menulis ulang sebagai dua query UNION yang masing-masing ber-index adalah alternatif yang selalu tersedia — perilaku yang sudah kita lihat di chapter EXPLAIN.

Kapan Full Scan Tidak Perlu Diobati #

Setelah lima penyakit itu, penutup yang jujur: ALL bukan musuh yang harus dibasmi di semua tabel. Tabel latihan kita berisi 10 baris — memindainya lebih cepat daripada biaya lompatan index, dan optimizer sering sengaja memilih ALL pada tabel kecil karena memang termurah. Query laporan yang membaca mayoritas baris (agregat bulanan atas seluruh pesanan) juga paling efisien menyapu tabel, karena index tidak bisa memangkas baris yang memang harus dibaca semua. Skalakan kekhawatiran: tabel ribuan baris dengan LIMIT wajar; tabel jutaan baris dengan filter selektif dan type = ALL adalah pekerjaan rumah yang harus dikerjakan hari ini, bukan besok.

Membersihkan Database Latihan #

sql
DROP DATABASE belajar_opt;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

Referensi #

Lima penyebab full scan kini sudah kita operasikan dan kita obati: kolom tanpa index, fungsi pembungkus kolom, wildcard depan, type mismatch, dan OR berat sebelah. Pola-pola ini akan kembali muncul dari arah berbeda di dua chapter berikutnya: pertama sebagai masalah paginasi — tabel yang sehat pun dipaksa menyapu ribuan baris oleh OFFSET besar — lalu sebagai bagian dari proses diagnosis sistematis untuk query yang sudah terlanjur lambat.