Di produksi, query yang tadinya cepet tiba-tiba loading 30 detik. Lo buka error log — cuma liat “timeout”. Jangan panik. Gak perlu DBA. Yang lo butuh: EXPLAIN ANALYZE.
Pernah ngalamin ini: aplikasi jalan normal, tiba-tiba salah satu halaman loading 30 detik. Lo check log — cuma ada “timeout”. Gak ada error, gak ada exception. Cuma loading.
9 dari 10 developer yang nulis query SQL manual pasti ngalamin cerita klasik ini. Kasusnya: database PostgreSQL di server produksi, tabel users udah 2 juta baris, tabel orders 5 juta. Query JOIN yang kemarin 200ms, sekarang — gak balik-bali. Lo panik, buka Query Tool, jalarin EXPLAIN, dan liat output kayak teka-teki alien.
Yang mayoritas developer gak sadar: baca execution plan itu skill yang bisa lo kuasai dalam 15 menit. Bukan ilmu hitam. Bukan sihir DBA. Cuma 3 angka yang perlu lo liat.
Step 1: Jangan Cuma EXPLAIN, Pake EXPLAIN ANALYZE
Kesalahan pertama yang bikin lo buta: EXPLAIN doang. EXPLAIN cuma nunjukin rencana eksekusi — perkiraan database, bukan eksekusi beneran. EXPLAIN ANALYZE ngejalanin query beneran (dalam transaction yang di-rollback) dan ngasih angka real.
Mulai dengan ini:
BEGIN;
EXPLAIN ANALYZE
SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2025-01-01'
ORDER BY o.total DESC;
ROLLBACK; Bungkus pake BEGIN/ROLLBACK biar gak beneran ngeubah data. Execute di production dengan hati-hati — query berat tetep jalan, cuma di-rollback.
Step 2: Baca dari Bawah ke Atas
Output EXPLAIN ANALYZE keliatan dari dalam ke luar — operasi yang jalan paling duluan ada di baris paling dalem/paling bawah. Jadi baca dari baris terbawah, naik ke atas.
Contoh query yang lemot:
Sort (cost=48230.45..48321.78 rows=36532 width=68)
(actual time=28450.123..28462.890 rows=34500 loops=1)
Sort Key: o.total DESC
Sort Method: external merge Disk: 2848kB
-> Hash Join (cost=8912.34..43210.56 rows=36532 width=68)
(actual time=450.234..28230.456 rows=34500 loops=1)
Hash Cond: (u.id = o.user_id)
-> Seq Scan on users u
(cost=0.00..23400.00 rows=234000 width=32)
(actual time=0.023..450.123 rows=234000 loops=1)
Filter: (created_at > '2025-01-01')
-> Hash (cost=5600.00..5600.00 rows=280000 width=40)
(actual time=0.000..0.000 rows=280000 loops=1)
-> Seq Scan on orders o
(cost=0.00..5600.00 rows=280000 width=40)
(actual time=0.015..890.234 rows=280000 loops=1) Baca dari bawah: PostgreSQL scan semua baris di tabel orders (Seq Scan), baru scan semua baris di users. Baru di-join pake Hash Join, di-sort pake Sort. Total 28 detik.
Dua tanda bahaya di sini: Seq Scan di tabel besar. Kalau lo punya tabel dengan jutaan baris dan dapet Seq Scan — artinya database gak punya index yang cocok.
Step 3: 3 Angka yang Lo Cari
Dari output EXPLAIN ANALYZE, lo cukup liat 3 angka doang untuk nemuin bottleneck:
- actual time — berapa milidetik tiap step. Angka paling gede di baris mana, itu bottleneck lo.
- rows (actual) vs rows (estimate) — perbandingan baris yang balik vs yang database nebak. Kalau beda jauh (misal estimate 100, actual 1 juta), statistik database udah kadaluarsa.
- loops — berapa kali database ulang step ini. Loops tinggi + waktu tinggi = nested loop yang gak efisien.
Di contoh di atas, Hash Join butuh 28,2 detik — itu bottleneck utama. Tapi penyebabnya ada di baris bawah: Seq Scan di orders butuh 890ms, Seq Scan di users butuh 450ms. Total waktu baca data besar banget, terus di-join.
Bedakan antara cost (angka pertama, estimate planner) dan actual time (angka setelah “actual”). Kalau actual time >> cost, database salah nebak. Kalau aktual ≈ cost, query-nya emang seberat itu.
Step 4: Fix Paling Sederhana — Tambah Index
Begitu lo nemuin Seq Scan di tabel gede, solusi paling instant: tambah index. Tapi gak asal tambah. Index harus cocok sama filter dan JOIN condition.
Query kita filter WHERE u.created_at > '2025-01-01' dan JOIN ON u.id = o.user_id.
Index yang tepat:
CREATE INDEX idx_users_created_at ON users(created_at);
CREATE INDEX idx_orders_user_id ON orders(user_id);
VACUUM ANALYZE users;
VACUUM ANALYZE orders; Jalani lagi EXPLAIN ANALYZE-nya:
Sort (cost=1280.45..1321.78 rows=34500 width=68)
(actual time=320.123..332.890 rows=34500 loops=1)
Sort Key: o.total DESC
Sort Method: quicksort Memory: 2848kB
-> Hash Join (cost=256.34..980.56 rows=34500 width=68)
(actual time=15.234..280.456 rows=34500 loops=1)
Hash Cond: (u.id = o.user_id)
-> Index Scan using idx_users_created_at on users u
(cost=0.00..420.00 rows=234000 width=32)
(actual time=0.023..12.123 rows=234000 loops=1)
Index Cond: (created_at > '2025-01-01')
-> Hash (cost=156.00..156.00 rows=280000 width=40)
(actual time=0.000..0.000 rows=280000 loops=1)
-> Index Scan using idx_orders_user_id on orders o
(cost=0.00..156.00 rows=280000 width=40)
(actual time=0.015..18.234 rows=280000 loops=1) 28 detik → 320ms. Perubahan cuma di dua baris: Seq Scan berubah jadi Index Scan. Tambah index doang.
Bedanya: Seq Scan baca semua blok di tabel dari disk sekaligus. Index Scan cuma baca node di index tree yang cocok, baru ngambil row dari heap. Untuk filter created_at > '2025-01-01', index nyimpen data yang terurut, jadi database langsung skip blok yang gak relevan.
Kasus 2: Nested Loop yang Gak Sadar Diri
Ini jebakan lain yang sering kejadian di ORM. Contoh:
SELECT p.*, (
SELECT COUNT(*) FROM reviews r
WHERE r.product_id = p.id AND r.rating >= 4
) as good_reviews
FROM products p
WHERE p.category_id = 5; Query ini pake subquery. Output EXPLAIN-nya:
Seq Scan on products p
(cost=0.00..245000.00 rows=5000 width=72)
(actual time=1.234..45230.456 rows=5000 loops=1)
Filter: (category_id = 5)
SubPlan 1
-> Aggregate
(cost=45.00..48.00 rows=1 width=8)
(actual time=9.045..9.046 rows=1 loops=5000)
-> Seq Scan on reviews r
(cost=0.00..40.00 rows=200 width=0)
(actual time=0.010..8.890 rows=15 loops=5000)
Filter: ((product_id = p.id) AND (rating >= 4)) Loops 5000 — artinya database jalanin subquery 5000 kali, sekali tiap baris produk. Masing-masing 9ms × 5000 = 45 detik. Ini nested loop implisit lewat subquery.
Fix: ganti pake JOIN + GROUP BY, atau tambah index di reviews(product_id, rating).
CREATE INDEX idx_reviews_product_rating ON reviews(product_id, rating); Index ini mencakup dua kolom — database bisa liat langsung dari index tanpa baca tabel. Dari Seq Scan berubah jadi Index Only Scan, loops tetep 5000 tapi tiap loop cuma 0.05ms. Total: 45 detik → 250ms.
Step 5: VACUUM ANALYZE — Obat Paling Murah
Kadang query lemot bukan karena kurang index, tapi karena statistik database basi. Database pake statistik buat nebak jumlah baris. Kalau statistik gak fresh — misal abis INSERT/DELETE jutaan baris — estimasi cost planner bisa ngaco dan milih plan yang salah.
VACUUM ANALYZE; Satu baris ini aja bisa nurunin query dari 30 detik jadi 1 detik. Jalanin di jam sepi. Gak butuh restart database. Gak perlu index baru.
Kapan Lo Butuh DBA Beneran?
Ada batas yang bisa lo selesain sendiri dan ada yang butuh DBA. Lo bisa handle sendiri kalau:
- Tambah index
- Baca execution plan
- VACUUM ANALYZE
- Rewrite query (ganti subquery jadi JOIN, hilangin OR)
Panggil DBA kalau:
- Parameter database (shared_buffers, work_mem, effective_cache_size) perlu tuning
- Deadlock berulang
- Replikasi lagging
- Table partitioning
Perbedaan utama: yang bisa lo fix dengan EXPLAIN ANALYZE + index vs yang butuh perubahan konfigurasi server atau arsitektur. Jangan panggil DBA cuma buat nambah index — itu boros.
Intinya
Baca execution plan bukan ilmu hitam. Lo cuma butuh:
- EXPLAIN ANALYZE bukan EXPLAIN doang
- Cari 3 angka — actual time, rows mismatch, loops
- Seq Scan di tabel gede = perlu index
- Loops tinggi + waktu tinggi = nested loop problem
- VACUUM ANALYZE sebelum panik
Lima langkah ini udah nutupin 80% masalah performa SQL yang dihadapi developer sehari-hari. Sisanya? Tanya DBA — tapi lo udah tau exact masalahnya, tinggal bilang aja “ada Seq Scan di tabel 5 juta baris, udah coba index tapi masih butuh parameter tuning.”