← Semua pembelajaran / Astro Nol → Portal Berita
Fase 2 · MySQL dengan Kysely

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.

Sumber asli dev.mysql.com Resmi Rangkuman ~11 menit baca

Intisari

  • Baca tiga kolom saja dulu: type, key, dan rows.
  • type: ALL berarti pemindaian tabel penuh. Di tabel artikel, itu tidak boleh ada di jalur pembaca.
  • Index gabungan mengikuti prefiks kiri: (status, terbit_pada) berguna untuk status saja, tapi (terbit_pada, status) tidak.
  • LIKE '%kata%', OR lintas kolom, dan fungsi di sisi kiri semuanya mematikan index.
  • Penghitung hits yang di-UPDATE tiap 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;
KolomYang kamu cariYang mengkhawatirkan
typeconst, eq_ref, ref, rangeALL (pindai penuh), index (pindai seluruh index)
keyNama index yang dipakaiNULL — tidak ada index yang terpakai
rowsSedekat mungkin dengan jumlah baris yang benar-benar kamu butuhkanRatusan ribu untuk mengambil 20
ExtraUsing 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:

KueriTerpakai?
WHERE status = ?Ya
WHERE status = ? AND kategori_id = ?Ya
WHERE status = ? AND kategori_id = ? ORDER BY terbit_padaYa — 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.

AlternatifCaraCocok kalau
Jangan hitung di jalur bacaHitung dari log akses CloudflarePaling disarankan — nol beban database
Kumpulkan lalu tulis berkalaTambah di Redis, flush ke MySQL tiap menitButuh angka mendekati real-time
Tabel terpisah, tulis-sajaINSERT ke tabel event, agregasi berkalaButuh 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.