EXPLAIN, index & kueri yang menjatuhkan portal
Di trafikmu, satu kueri tanpa index bukan hanya lambat — ia menahan koneksi cukup lama untuk menyeret seluruh situs. Ini materi yang paling langsung berdampak ke tagihan RDS-mu.
Intisari
- Baca tiga kolom saja dulu:
type,key, danrows. type: ALLberarti pemindaian tabel penuh. Di tabel artikel, itu tidak boleh ada di jalur pembaca.- Index gabungan mengikuti prefiks kiri:
(status, terbit_pada)berguna untukstatussaja, tapi(terbit_pada, status)tidak. LIKE '%kata%',ORlintas kolom, dan fungsi di sisi kiri semuanya mematikan index.- Penghitung hits yang di-
UPDATEtiap kali artikel dibuka adalah bom waktu di trafik portalmu.
Membaca EXPLAIN dalam tiga kolom
EXPLAIN
SELECT a.id, a.judul, k.nama
FROM artikel a
LEFT JOIN kategori k ON k.id = a.kategori_id
WHERE a.status = 'terbit' AND a.terbit_pada <= NOW()
ORDER BY a.terbit_pada DESC
LIMIT 20;
| Kolom | Yang kamu cari | Yang mengkhawatirkan |
|---|---|---|
type | const, eq_ref, ref, range | ALL (pindai penuh), index (pindai seluruh index) |
key | Nama index yang dipakai | NULL — tidak ada index yang terpakai |
rows | Sedekat mungkin dengan jumlah baris yang benar-benar kamu butuhkan | Ratusan ribu untuk mengambil 20 |
Extra | Using index (covering index — terbaik) | Using filesort, Using temporary |
EXPLAIN ANALYZE di MySQL 8.0 lebih jujur. EXPLAIN biasa menampilkan
rencana; EXPLAIN ANALYZE benar-benar menjalankan kuerinya dan melaporkan waktu
serta jumlah baris yang sesungguhnya. Kolom rows di EXPLAIN hanya perkiraan
dari statistik, dan di tabel yang sering berubah perkiraannya bisa meleset jauh.
Index yang dibutuhkan portal berita
-- Daftar artikel per kategori, terbaru dulu. Kueri paling sering di portalmu.
CREATE INDEX idx_artikel_daftar
ON artikel (status, kategori_id, terbit_pada DESC);
-- Pencarian per slug — jalur halaman artikel
CREATE UNIQUE INDEX idx_artikel_slug ON artikel (slug);
-- Artikel per penulis di halaman profil
CREATE INDEX idx_artikel_penulis ON artikel (penulis_id, terbit_pada DESC);
-- Tabel penghubung: DUA arah, keduanya dipakai
CREATE INDEX idx_at_artikel ON artikel_tag (artikel_id, tag_id);
CREATE INDEX idx_at_tag ON artikel_tag (tag_id, artikel_id);
Aturan prefiks kiri
Index gabungan (status, kategori_id, terbit_pada) bisa dipakai untuk:
| Kueri | Terpakai? |
|---|---|
WHERE status = ? | Ya |
WHERE status = ? AND kategori_id = ? | Ya |
WHERE status = ? AND kategori_id = ? ORDER BY terbit_pada | Ya — termasuk pengurutannya |
WHERE kategori_id = ? | Tidak — melewati kolom pertama |
WHERE terbit_pada > ? | Tidak |
Karena itu urutan kolom dalam index adalah keputusan, bukan formalitas. Taruh kolom yang selalu ada di
WHERE paling depan, kolom pengurutan paling belakang.
Baris ketiga itu yang paling menguntungkan. Kalau kolom ORDER BY ikut di index dan
urutannya cocok, MySQL tidak perlu filesort sama sekali — ia membaca index secara berurutan dan
berhenti setelah 20 baris. Bedanya bisa 200 ms menjadi 2 ms untuk kueri daftar beritamu.
Empat cara mematikan index tanpa sadar
-- 1. Wildcard di depan: index tidak bisa dipakai sama sekali
WHERE judul LIKE '%rupiah%' -- pindai penuh
WHERE judul LIKE 'rupiah%' -- index terpakai
-- 2. Fungsi di sisi kiri
WHERE YEAR(terbit_pada) = 2026 -- pindai penuh
WHERE terbit_pada >= '2026-01-01'
AND terbit_pada < '2027-01-01' -- index terpakai
-- 3. OR lintas kolom berbeda
WHERE judul = ? OR ringkasan = ? -- sering pindai penuh
-- gantinya: UNION dari dua kueri ber-index
-- 4. Ketidakcocokan tipe
WHERE kode_perusahaan = 12345 -- kolom VARCHAR vs angka:
-- MySQL mengonversi, index mati
WHERE kode_perusahaan = '12345' -- index terpakai
Nomor empat sangat mudah terjadi di skema CI3, di mana kolom kode sering VARCHAR tapi
kodenya numerik. Di Kysely, tipe kolom dari codegen akan menolak angka untuk kolom string — jadi masalah
ini sebagian besar hilang dengan sendirinya. Itu keuntungan yang jarang disebut.
Bom waktu: penghitung hits
// Ini ada di hampir semua portal berita CI3. Jangan bawa ke Astro.
await db.updateTable("artikel")
.set((eb) => ({ hits: eb("hits", "+", 1) }))
.where("id", "=", id)
.execute();
Di trafikmu, ini berarti ratusan UPDATE per detik ke tabel yang sama, tiap satu mengunci satu
baris. Untuk artikel viral, ribuan pembaca bersamaan mengantre menulis ke satu baris yang sama —
dan antrean itu memblokir pembacaan tabel yang sama. Lebih buruk lagi: setiap UPDATE
membatalkan kelayakan halaman itu untuk di-cache, karena kamu jadi punya alasan menyentuh database di
jalur baca.
| Alternatif | Cara | Cocok kalau |
|---|---|---|
| Jangan hitung di jalur baca | Hitung dari log akses Cloudflare | Paling disarankan — nol beban database |
| Kumpulkan lalu tulis berkala | Tambah di Redis, flush ke MySQL tiap menit | Butuh angka mendekati real-time |
| Tabel terpisah, tulis-saja | INSERT ke tabel event, agregasi berkala | Butuh rincian per jam/hari |
Menemukan kueri yang lambat
-- Aktifkan di parameter group RDS, bukan lewat SET GLOBAL
-- slow_query_log = 1
-- long_query_time = 0.5
-- log_output = FILE
-- Lalu, dari Performance Schema: kueri paling mahal secara total
SELECT
DIGEST_TEXT,
COUNT_STAR AS jumlah,
ROUND(SUM_TIMER_WAIT / 1000000000000, 2) AS total_detik,
ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS rata_ms
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 20;
Urutkan berdasarkan waktu TOTAL, bukan rata-rata. Kueri 5 ms yang dipanggil sejuta kali per jam jauh lebih merusak daripada kueri 2 detik yang dipanggil sepuluh kali. Yang pertama adalah beban sesungguhnya; yang kedua cuma menyebalkan. Di portal berita, pemenangnya hampir selalu kueri daftar artikel di beranda.
Mengukur dari sisi aplikasi
// src/lib/db.ts — plugin sederhana untuk mencatat kueri lambat
import type { KyselyPlugin } from "kysely";
const catatLambat: KyselyPlugin = {
transformQuery: (args) => args.node,
transformResult: async (args) => args.result,
};
// Cara yang lebih berguna: bungkus di lapisan lib/
export async function ukur<T>(nama: string, fn: () => Promise<T>): Promise<T> {
const t0 = performance.now();
try {
return await fn();
} finally {
const ms = performance.now() - t0;
if (ms > 100) {
console.warn(JSON.stringify({ level: "warn", kueri: nama, ms: Math.round(ms) }));
}
}
}
export const ambilArtikelTerbaru = (kategori: string, batas = 20) =>
ukur("artikel.terbaru", () => /* kueri Kysely */);
Karena semua kueri lewat src/lib/, satu pembungkus cukup untuk memberi nama pada tiap kueri.
Nama itu yang akan kamu cari di CloudWatch saat p95 naik (Fase 10).
Latihan: jalankan EXPLAIN pada kueri daftar berita CI3-mu di salinan database
produksi, dan catat type, key, dan rows. Kalau
key bernilai NULL atau Extra berisi Using filesort,
buat index gabungan yang cocok dan jalankan ulang. Catat waktu sebelum dan sesudah dengan
EXPLAIN ANALYZE. Lalu jalankan kueri Performance Schema di atas dan lihat kueri mana yang
benar-benar paling mahal secara total di portalmu — kemungkinan besar bukan yang kamu duga.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.