Database

SQL Query Optimization: Panduan Lengkap EXPLAIN, Indexes & Query Plans

Pelajari teknik mengoptimalkan query SQL untuk performa tinggi β€” EXPLAIN, indexes, query plans, dan best practices untuk database berukuran besar

1. Pengenalan Query Optimization

Query Optimization adalah proses membuat query SQL berjalan seefisien mungkin. Saat database Anda kecil (ribuan baris), hampir semua query terasa cepat. Tapi begitu data bertambah jutaan baris, perbedaan antara query yang dioptimalkan dan tidak bisa sangat dramatis β€” dari milidetik menjadi menit.

Database modern seperti MySQL, PostgreSQL, dan SQL Server memiliki komponen yang disebut Query Optimizer β€” sebuah mesin internal yang menentukan cara terbaik untuk mengeksekusi setiap query. Namun, optimizer hanya sebaik struktur data dan query yang Anda berikan.

Mengapa Query Optimization Penting?

Diagram: Proses Eksekusi Query SQL
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                   PROSES EKSEKUSI QUERY                          β”‚
β”‚                                                                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚  Query    │───▢│   Parser     │───▢│   Query Rewriter   β”‚    β”‚
β”‚  β”‚  SQL      β”‚    β”‚  (Syntax     β”‚    β”‚  (Optimasi awal)   β”‚    β”‚
β”‚  β”‚  String   β”‚    β”‚   Check)     β”‚    β”‚                    β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚                                               β”‚                 β”‚
β”‚                                               β–Ό                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚  Hasil    │◀───│  Executor    │◀───│  Query Optimizer   β”‚    β”‚
β”‚  β”‚  Data     β”‚    β”‚  (Eksekusi   β”‚    β”‚  (Pilih plan       β”‚    β”‚
β”‚  β”‚  (Rows)   β”‚    β”‚   fisik)     β”‚    β”‚   terbaik)         β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚                                                                 β”‚
β”‚  Optimizer memilih:                                              β”‚
β”‚  β€’ Jenis JOIN (Nested Loop, Hash, Merge)                        β”‚
β”‚  β€’ Urutan tabel                                                 β”‚
β”‚  β€’ Index yang digunakan                                         β”‚
β”‚  β€’ Strategi filter dan aggregasi                                β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Kapan Perlu Optimasi?

Indikator Threshold Aksi
Response time query> 1 detikPerlu investigasi
Full table scan pada tabel besar> 100K barisTambah index
CPU usage tinggi> 80%Optimasi query berat
Disk I/O tinggi> 80%Kurangi data yang dibaca
Slow query logBanyak entryPrioritas optimasi

2. EXPLAIN β€” Membaca Query Plans

EXPLAIN adalah perintah paling penting dalam query optimization. Perintah ini menunjukkan bagaimana database akan mengeksekusi query Anda tanpa benar-benar menjalankannya. Dengan EXPLAIN, Anda bisa melihat apakah query menggunakan index, berapa baris yang dipindai, dan di mana bottleneck terjadi.

SQL β€” EXPLAIN Dasar
-- =============================================
-- Setup: Buat tabel untuk contoh
-- =============================================
CREATE TABLE orders (
    id          INT PRIMARY KEY AUTO_INCREMENT,
    customer_id INT NOT NULL,
    product_id  INT NOT NULL,
    amount      DECIMAL(12,2),
    status      VARCHAR(20),
    order_date  DATE,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Insert sample data (1 juta baris)
-- ... bulk insert ...

-- =============================================
-- EXPLAIN Dasar: Lihat query plan
-- =============================================
EXPLAIN SELECT * FROM orders WHERE customer_id = 1234;

-- Hasil:
-- +----+------+------+------+----------+------+
-- | id | type | key  | rows | filtered | Extra|
-- +----+------+------+------+----------+------+
-- |  1 | ALL  | NULL | 1000000| 10.00  |Using where|
-- +----+------+------+------+----------+------+
-- type=ALL β†’ Full table scan! Sangat lambat pada tabel besar.


-- =============================================
-- EXPLAIN ANALYZE (MySQL 8.0.18+)
-- =============================================
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 1234;
-- Menampilkan actual execution time, bukan hanya estimate


-- =============================================
-- EXPLAIN FORMAT=JSON
-- =============================================
EXPLAIN FORMAT=JSON
SELECT * FROM orders WHERE customer_id = 1234;
-- Output JSON detail β€” bisa di-parse untuk analisis otomatis


-- =============================================
-- EXPLAIN FORMAT=TREE (MySQL 8.0.16+)
-- =============================================
EXPLAIN FORMAT=TREE
SELECT o.*, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'shipped';

Kolom Penting dalam EXPLAIN

Kolom Deskripsi Yang Dicari
typeJenis akses datasystem > const > eq_ref > ref > range > index > ALL
keyIndex yang digunakanNULL berarti tidak pakai index!
rowsEstimasi baris yang dipindaiSemakin kecil semakin baik
filtered% baris setelah filterMendekati 100% lebih baik
ExtraInfo tambahanHindari "Using filesort", "Using temporary"

Tipe Akses Data (type column)

Type Performa Penjelasan
system⭐⭐⭐⭐⭐Tabel hanya punya 1 baris β€” paling cepat
const⭐⭐⭐⭐⭐Mencari PRIMARY KEY atau UNIQUE β€” 1 baris
eq_ref⭐⭐⭐⭐JOIN menggunakan PRIMARY KEY β€” 1 baris per join
ref⭐⭐⭐⭐Menggunakan non-unique index β€” beberapa baris
range⭐⭐⭐Range scan (BETWEEN, >, <, IN) β€” cukup baik
index⭐⭐Full index scan β€” lebih baik dari ALL tapi masih lambat
ALL⭐Full table scan β€” paling lambat, hindari pada tabel besar
⚠️ EXPLAIN pada PostgreSQL

PostgreSQL menggunakan syntax berbeda: EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT). Kolom yang dihasilkan juga berbeda β€” gunakan seq_scan, idx_scan, dan n_live_tup dari pg_stat_user_tables untuk monitoring.

3. Indexes β€” Mempercepat Pencarian Data

Index adalah struktur data tambahan yang membantu database menemukan baris dengan cepat tanpa memindai seluruh tabel. Analoginya: index di buku β€” Anda langsung tahu halaman mana yang harus dibuka tanpa membaca dari halaman pertama.

Tanpa index, database melakukan full table scan β€” memeriksa setiap baris satu per satu. Dengan index, database langsung melompat ke baris yang tepat. Perbedaannya bisa sangat dramatis: dari 5 detik menjadi 5 milidetik.

SQL β€” Membuat dan Menggunakan Index
-- =============================================
-- INDEX DASAR: Single Column Index
-- =============================================

-- Buat index pada kolom customer_id
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

-- Sekarang query ini jauh lebih cepat:
EXPLAIN SELECT * FROM orders WHERE customer_id = 1234;
-- type: ref, key: idx_orders_customer_id β†’ Menggunakan index!

-- =============================================
-- INDEX pada kolom yang sering di-SEARCH
-- =============================================

-- Index untuk pencarian berdasarkan status
CREATE INDEX idx_orders_status ON orders(status);

-- Index untuk pencarian berdasarkan tanggal
CREATE INDEX idx_orders_date ON orders(order_date);

-- Index untuk pencarian berdasarkan product
CREATE INDEX idx_orders_product ON orders(product_id);


-- =============================================
-- UNIQUE INDEX: Menjamin keunikan
-- =============================================
CREATE UNIQUE INDEX idx_customers_email ON customers(email);

-- UNIQUE INDEX berfungsi ganda:
-- 1. Mempercepat pencarian berdasarkan email
-- 2. Mencegah duplikat email


-- =============================================
-- MENGECEK INDEX yang ada pada tabel
-- =============================================
-- MySQL:
SHOW INDEX FROM orders;

-- PostgreSQL:
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'orders';


-- =============================================
-- MENGHAPUS INDEX yang tidak diperlukan
-- =============================================
DROP INDEX idx_orders_status ON orders;

-- Atau dengan aman (MySQL):
DROP INDEX IF EXISTS idx_orders_status ON orders;


-- =============================================
-- INDEX pada kolom yang sering di-ORDER BY
-- =============================================
-- Query ini akan benefit dari index:
SELECT * FROM orders ORDER BY order_date DESC LIMIT 20;

-- Buat index:
CREATE INDEX idx_orders_date_desc ON orders(order_date DESC);
Diagram: B-Tree Index Structure
                    B-TREE INDEX (customer_id)
                    ========================

                          [500]
                         /     \
                    [250]       [750]
                   /     \     /     \
              [125]   [375] [625]   [875]
              / \      / \    / \     / \
           [..] [..] [..][..][..][..][..] [..]

  Pencarian customer_id = 375:
  1. Root [500] β†’ 375 < 500, go LEFT
  2. Node [250] β†’ 375 > 250, go RIGHT
  3. Node [375] β†’ FOUND! Langsung ke data row

  Tanpa index: Pindai 1,000,000 baris (full scan)
  Dengan index: Pindai 3-4 level β†’ ~4 I/O saja!

  B-Tree cocok untuk:
  β”œβ”€β”€ = (equality)
  β”œβ”€β”€ <, >, BETWEEN (range)
  β”œβ”€β”€ LIKE 'abc%' (prefix)
  └── ORDER BY (jika match kolom index)

Jenis Index

Jenis Index Struktur Cocok Untuk Database
B-TreeTree balancePencarian umum, range, sortingSemua RDBMS
HashHash tablePencarian exact (=) sajaPostgreSQL, Memory
GINGeneralized InvertedFull-text, array, JSONPostgreSQL
GiSTGeneralized Search TreeSpatial, range typesPostgreSQL
Full-TextInverted indexPencarian teks penuhMySQL, PostgreSQL
PartialB-Tree (filtered)Subset data tertentuPostgreSQL

4. Composite Indexes & Covering Indexes

Composite Index (multi-column index) adalah index yang mencakup lebih dari satu kolom. Ini sangat penting untuk query yang memfilter berdasarkan beberapa kolom sekaligus. Urutan kolom dalam composite index sangat menentukan efektivitasnya β€” ini disebut Leftmost Prefix Rule.

SQL β€” Composite Index
-- =============================================
-- COMPOSITE INDEX: Multi-kolom
-- =============================================
CREATE INDEX idx_orders_cust_status_date
ON orders(customer_id, status, order_date);

-- Query yang TERBANTU oleh index ini:
-- βœ… WHERE customer_id = 1234
-- βœ… WHERE customer_id = 1234 AND status = 'shipped'
-- βœ… WHERE customer_id = 1234 AND status = 'shipped' AND order_date > '2026-01-01'
-- βœ… ORDER BY customer_id, status, order_date

-- Query yang TIDAK terbantu:
-- ❌ WHERE status = 'shipped'  (tidak pakai prefix customer_id)
-- ❌ WHERE order_date > '2026-01-01' (tidak pakai prefix)


-- =============================================
-- LEFTMOST PREFIX RULE
-- =============================================
-- Index: (A, B, C) bisa dipakai untuk:
-- βœ… (A)         β€” prefix pertama
-- βœ… (A, B)      β€” dua prefix pertama
-- βœ… (A, B, C)   β€” ketiganya
-- ❌ (B, C)      β€” skip A
-- ❌ (C)         β€” skip A dan B


-- =============================================
-- COVERING INDEX: Semua kolom ada di index
-- =============================================
-- Query ini hanya butuh kolom: customer_id, status, amount
SELECT customer_id, status, amount
FROM orders
WHERE customer_id = 1234 AND status = 'shipped';

-- Covering index yang ideal:
CREATE INDEX idx_orders_covering
ON orders(customer_id, status, amount);

-- Dengan covering index, database TIDAK perlu mengakses
-- data rows sama sekali (Index-Only Scan)
-- EXPLAIN akan menampilkan: "Using index"


-- =============================================
-- INDEX MERGE (MySQL)
-- =============================================
-- Kadang MySQL bisa pakai 2 index sekaligus:
-- Index: idx_customer, idx_status
SELECT * FROM orders
WHERE customer_id = 1234 OR status = 'shipped';
-- EXPLAIN mungkin menampilkan: "Using union(idx_customer, idx_status)"

-- Tapi lebih efisien dengan composite index:
CREATE INDEX idx_cust_status ON orders(customer_id, status);
πŸ’‘ Tips Composite Index

Susun kolom composite index berdasarkan: (1) kolom yang paling sering di-WHERE, (2) kolom dengan kardinalitas tinggi (banyak nilai unik), (3) kolom yang di-ORDER BY. Jangan buat terlalu banyak composite index β€” setiap index menambah overhead saat INSERT/UPDATE.

5. Membaca & Menganalisis Query Plans

Query plan (execution plan) adalah roadmap yang dibuat optimizer sebelum mengeksekusi query. Memahami query plan adalah keterampilan krusial untuk debugging performa.

SQL β€” Menganalisis Query Plan
-- =============================================
-- QUERY PLAN: Contoh Analisis
-- =============================================

-- Query yang kompleks:
EXPLAIN ANALYZE
SELECT
    c.name AS customer_name,
    COUNT(o.id) AS total_orders,
    SUM(o.amount) AS total_spent
FROM customers c
JOIN orders o ON c.id = o.customer_id
WHERE o.order_date >= '2026-01-01'
  AND o.status IN ('shipped', 'delivered')
GROUP BY c.id, c.name
HAVING COUNT(o.id) > 5
ORDER BY total_spent DESC
LIMIT 10;

-- Analisis Output EXPLAIN:
-- 1. Join Order: customers β†’ orders (atau sebaliknya)
-- 2. Join Method: Hash Join vs Nested Loop vs Merge Join
-- 3. Access Path: Index Scan vs Seq Scan
-- 4. Filter: WHERE diterapkan sebelum/sesudah join
-- 5. Sort: In-memory sort vs filesort
-- 6. Aggregation: HashAggregate vs GroupAggregate


-- =============================================
-- POSTGRESQL: EXPLAIN ANALYZE VERBOSE
-- =============================================
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders
WHERE customer_id = 1234
ORDER BY order_date DESC
LIMIT 10;

-- Output:
-- Limit  (cost=0.43..8.45 rows=10 width=56) (actual time=0.025..0.032 rows=10)
--   ->  Index Scan using idx_orders_cust_date on orders
--       (cost=0.43..805.43 rows=1000 width=56) (actual time=0.024..0.030 rows=10)
--       Index Cond: (customer_id = 1234)
--       Buffers: shared hit=4
-- Planning Time: 0.150 ms
-- Execution Time: 0.045 ms


-- =============================================
-- JOIN METHODS: Kapan Digunakan
-- =============================================
-- Nested Loop Join:
--   Cocok: Tabel kecil, ada index pada join column
--   Contoh: customers (100 rows) JOIN orders dengan index

-- Hash Join:
--   Cocok: Tabel besar, TANPA index, equality join
--   Contoh: 2 tabel besar JOIN dengan =

-- Merge Join:
--   Cocok: Kedua tabel sudah ter-sort pada join column
--   Contoh: Tabel yang di-ORDER BY kolom join
Diagram: Perbandingan Join Methods
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    JENIS JOIN METHODS                            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                 β”‚
β”‚  1. NESTED LOOP JOIN                                            β”‚
β”‚     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                                 β”‚
β”‚     β”‚ Table A β”‚ (outer)                                         β”‚
β”‚     β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜                                                 β”‚
β”‚          β”‚  untuk setiap baris A:                               β”‚
β”‚          β–Ό  cari di Table B (pakai index)                       β”‚
β”‚     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                                 β”‚
β”‚     β”‚ Table B β”‚ (inner, indexed)                                β”‚
β”‚     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                                 β”‚
β”‚     Time: O(n * log m) β€” sangat cepat kalau ada index           β”‚
β”‚                                                                 β”‚
β”‚  2. HASH JOIN                                                   β”‚
β”‚     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                           β”‚
β”‚     β”‚ Table A │──────▢  β”‚  Hash    β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚
β”‚     β”‚ (build) β”‚         β”‚  Table   │◀───│ Table B β”‚            β”‚
β”‚     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜    β”‚ (probe) β”‚            β”‚
β”‚                              β”‚          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜            β”‚
β”‚                              β–Ό                                  β”‚
β”‚                         Hasil Join                              β”‚
β”‚     Time: O(n + m) β€” efisien untuk tabel besar tanpa index      β”‚
β”‚                                                                 β”‚
β”‚  3. MERGE JOIN                                                  β”‚
β”‚     Table A (sorted)  ──▢  β”Œβ”€β”€β”€β”€β”€β”€β”  ◀──  Table B (sorted)    β”‚
β”‚     1,3,5,7,9...          β”‚Merge β”‚          2,3,5,8,12...      β”‚
β”‚                           β””β”€β”€β”¬β”€β”€β”€β”˜                              β”‚
β”‚                              β–Ό                                  β”‚
β”‚                         3, 5 (matches)                          β”‚
β”‚     Time: O(n log n + m log m) β€” butuh sorted data              β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

6. Teknik Optimasi Query Umum

Berikut teknik-teknik optimasi yang paling sering digunakan untuk mempercepat query SQL.

SQL β€” Teknik Optimasi
-- =============================================
-- 1. HINDARI FUNCTION pada indexed column
-- =============================================

-- ❌ BURUK: Index tidak bisa dipakai karena YEAR()
SELECT * FROM orders WHERE YEAR(order_date) = 2026;

-- βœ… BAIK: Gunakan range pada kolom asli
SELECT * FROM orders
WHERE order_date >= '2026-01-01' AND order_date < '2027-01-01';

-- ❌ BURUK: LOWER() pada indexed column
SELECT * FROM customers WHERE LOWER(email) = 'test@example.com';

-- βœ… BAIK: Simpan data sudah lowercase, atau buat expression index (PG)
CREATE INDEX idx_customers_email_lower
ON customers(LOWER(email));  -- PostgreSQL expression index


-- =============================================
-- 2. HINDARI SELECT * β€” ambil kolom yang diperlukan
-- =============================================

-- ❌ BURUK: Mengambil semua kolom termasuk TEXT/BLOB besar
SELECT * FROM orders WHERE customer_id = 1234;

-- βœ… BAIK: Ambil hanya kolom yang dibutuhkan
SELECT id, amount, status, order_date
FROM orders
WHERE customer_id = 1234;
-- Bisa memanfaatkan covering index!


-- =============================================
-- 3. OPTIMASI LIKE
-- =============================================

-- ❌ BURUK: Leading wildcard β€” index tidak dipakai
SELECT * FROM products WHERE nama_produk LIKE '%laptop%';

-- βœ… BAIK: Trailing wildcard β€” index bisa dipakai
SELECT * FROM products WHERE nama_produk LIKE 'laptop%';

-- βœ… ALTERNATIF: Full-text search untuk pencarian bebas
SELECT * FROM products
WHERE MATCH(nama_produk) AGAINST('laptop gaming' IN BOOLEAN MODE);


-- =============================================
-- 4. OPTIMASI IN dengan banyak nilai
-- =============================================

-- ❌ BURUK: Terlalu banyak nilai dalam IN
SELECT * FROM orders
WHERE customer_id IN (1,2,3,...,10000);

-- βœ… BAIK: Gunakan JOIN dengan temporary table
CREATE TEMPORARY TABLE tmp_ids (id INT PRIMARY KEY);
-- INSERT ke tmp_ids ...
SELECT o.* FROM orders o
JOIN tmp_ids t ON o.customer_id = t.id;


-- =============================================
-- 5. OPTIMASI COUNT
-- =============================================

-- ❌ LAMBAT: COUNT(*) pada tabel besar tanpa filter
SELECT COUNT(*) FROM orders;
-- Scans entire table

-- βœ… LEBIH CEPAT: Hitung estimasi dari metadata (MySQL)
SELECT TABLE_ROWS FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = 'orders';

-- βœ… LEBIH CEPAT: Hitung estimasi (PostgreSQL)
SELECT reltuples::bigint AS estimate
FROM pg_class WHERE relname = 'orders';


-- =============================================
-- 6. EXISTS vs IN untuk subquery
-- =============================================

-- ❌ IN: Memproses semua hasil subquery
SELECT * FROM customers
WHERE id IN (SELECT customer_id FROM orders WHERE amount > 1000000);

-- βœ… EXISTS: Berhenti begitu menemukan match pertama
SELECT * FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.customer_id = c.id AND o.amount > 1000000
);

7. Subquery vs JOIN β€” Mana Lebih Cepat?

Pertanyaan klasik: apakah lebih cepat menggunakan subquery atau JOIN? Jawabannya tergantung pada konteks, ukuran data, dan versi database.

SQL β€” Subquery vs JOIN
-- =============================================
-- CONTOH 1: Cari pelanggan yang pernah beli
-- =============================================

-- Menggunakan Subquery (correlated):
SELECT nama, email FROM pelanggan p
WHERE EXISTS (
    SELECT 1 FROM pesanan ps
    WHERE ps.id_pelanggan = p.id_pelanggan
    AND ps.total > 500000
);
-- βœ… Bagus untuk: mengecek keberadaan (EXISTS)
-- Correlated subquery dijalankan per baris outer

-- Menggunakan JOIN:
SELECT DISTINCT p.nama, p.email
FROM pelanggan p
JOIN pesanan ps ON ps.id_pelanggan = p.id_pelanggan
WHERE ps.total > 500000;
-- βœ… Bagus untuk: mengambil data dari tabel lain
-- DISTINCT diperlukan karena JOIN bisa menghasilkan duplikat


-- =============================================
-- CONTOH 2: Agregasi per grup
-- =============================================

-- Subquery dalam FROM (Derived Table):
SELECT p.nama, sub.total_belanja
FROM pelanggan p
JOIN (
    SELECT id_pelanggan, SUM(total) AS total_belanja
    FROM pesanan
    GROUP BY id_pelanggan
) sub ON p.id_pelanggan = sub.id_pelanggan
WHERE sub.total_belanja > 1000000;

-- JOIN langsung dengan HAVING:
SELECT p.nama, SUM(ps.total) AS total_belanja
FROM pelanggan p
JOIN pesanan ps ON p.id_pelanggan = ps.id_pelanggan
GROUP BY p.id_pelanggan, p.nama
HAVING SUM(ps.total) > 1000000;


-- =============================================
-- CONTOH 3: Window Function (Modern SQL)
-- =============================================
-- Lebih efisien dari subquery untuk ranking:
SELECT nama, total_belanja, ranking
FROM (
    SELECT
        p.nama,
        SUM(ps.total) AS total_belanja,
        RANK() OVER (ORDER BY SUM(ps.total) DESC) AS ranking
    FROM pelanggan p
    JOIN pesanan ps ON p.id_pelanggan = ps.id_pelanggan
    GROUP BY p.id_pelanggan, p.nama
) ranked
WHERE ranking <= 10;
Pendekatan Kelebihan Kekurangan
Correlated SubqueryMudah dibaca untuk EXISTSBisa lambat jika data besar (N+1)
JOINUmumnya lebih cepat untuk data besarPerlu DISTINCT jika one-to-many
Derived TableBisa pre-aggregate sebelum joinTidak bisa di-index
Window FunctionPaling fleksibel, performa baikPerlu MySQL 8+ / PG 9+

8. Slow Query Log & Monitoring

Slow Query Log adalah fitur database yang mencatat query yang berjalan lebih lama dari threshold tertentu. Ini adalah alat utama untuk menemukan query yang perlu dioptimalkan.

SQL β€” Slow Query Log (MySQL)
-- =============================================
-- MYSQL: Mengaktifkan Slow Query Log
-- =============================================

-- Cek status slow query log
SHOW VARIABLES LIKE 'slow_query_log';

-- Aktifkan (sementara, hilang saat restart)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;        -- Query > 1 detik
SET GLOBAL log_queries_not_using_indexes = 'ON';

-- Aktifkan permanen: tambahkan di my.cnf
-- [mysqld]
-- slow_query_log = 1
-- slow_query_log_file = /var/log/mysql/slow.log
-- long_query_time = 1
-- log_queries_not_using_indexes = 1


-- =============================================
-- MYSQL: Performance Schema
-- =============================================
-- Query paling lambat (MySQL 8+)
SELECT
    DIGEST_TEXT AS query_pattern,
    COUNT_STAR AS exec_count,
    ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms,
    ROUND(MAX_TIMER_WAIT / 1000000000, 2) AS max_ms,
    SUM_ROWS_EXAMINED AS rows_scanned,
    SUM_ROWS_SENT AS rows_returned
FROM performance_schema.events_statements_summary_by_digest
ORDER BY AVG_TIMER_WAIT DESC
LIMIT 10;


-- =============================================
-- POSTGRESQL: pg_stat_statements
-- =============================================
-- Pastikan extension aktif:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- Query paling lambat:
SELECT
    query,
    calls,
    ROUND(mean_exec_time::numeric, 2) AS avg_ms,
    ROUND(max_exec_time::numeric, 2) AS max_ms,
    rows
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;


-- =============================================
-- MONITORING: Cek index usage
-- =============================================

-- MySQL: Index yang tidak pernah dipakai
SELECT
    object_schema, object_name, index_name
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NOT NULL
  AND index_name != 'PRIMARY'
  AND count_star = 0
ORDER BY object_schema, object_name;

-- PostgreSQL: Index yang jarang dipakai
SELECT
    schemaname, tablename, indexname,
    idx_scan AS times_used,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan < 10
ORDER BY pg_relation_size(indexrelid) DESC;

9. Best Practices & Anti-Patterns

βœ… Best Practices

# Best Practice Alasan
1Selalu gunakan EXPLAIN sebelum deploy query baruMendeteksi masalah sebelum production
2Buat index sesuai pola query, bukan sebaliknyaIndex yang tidak dipakai hanya buang resource
3Pagination menggunakan keyset, bukan OFFSETOFFSET scan semua baris sebelumnya
4Pisahkan query besar menjadi batch kecilHindari lock panjang dan timeout
5Gunakan connection poolingMengurangi overhead koneksi
6Monitor slow query log secara rutinDeteksi dini query bermasalah
7Update statistik tabel secara berkalaOptimizer butuh data akurat untuk keputusan
8Test dengan data volume productionQuery cepat di dev belum tentu cepat di prod

❌ Anti-Patterns yang Harus Dihindari

SQL β€” Anti-Patterns
-- =============================================
-- ANTI-PATTERN 1: OFFSET besar
-- =============================================

-- ❌ BURUK: Halaman 1000 dengan 10 per halaman
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 10000;
-- Database membaca 10,010 baris, buang 10,000

-- βœ… BAIK: Keyset pagination
SELECT * FROM orders
WHERE id > 10000  -- ID terakhir dari halaman sebelumnya
ORDER BY id
LIMIT 10;
-- Hanya membaca 10 baris!


-- =============================================
-- ANTI-PATTERN 2: N+1 Query Problem
-- =============================================
-- ❌ BURUK (dalam aplikasi):
-- 1 query: SELECT * FROM customers
-- N query: SELECT * FROM orders WHERE customer_id = ?
-- (1000 customers = 1001 query!)

-- βœ… BAIK: Batch dengan JOIN atau IN
-- 1 query: SELECT c.*, o.* FROM customers c
--          JOIN orders o ON c.id = o.customer_id


-- =============================================
-- ANTI-PATTERN 3: Index berlebihan
-- =============================================
-- ❌ BURUK: Terlalu banyak index
-- INSERT/UPDATE jadi lambat karena setiap index di-update
CREATE INDEX idx_a ON orders(customer_id);
CREATE INDEX idx_b ON orders(status);
CREATE INDEX idx_c ON orders(order_date);
CREATE INDEX idx_d ON orders(amount);
CREATE INDEX idx_e ON orders(product_id);
-- 5 index = 5x overhead saat INSERT!

-- βœ… BAIK: Composite index yang terencana
CREATE INDEX idx_main ON orders(customer_id, status, order_date);
-- 1 index yang mendukung berbagai pola query


-- =============================================
-- ANTI-PATTERN 4: Menggunakan ORM tanpa cek query
-- =============================================
-- ORM seperti Hibernate / ActiveRecord bisa generate query buruk
-- Selalu cek query yang di-generate di development!
-- Aktifkan query logging pada framework Anda


-- =============================================
-- ANTI-PATTERN 5: Tidak pakai parameterized query
-- =============================================
-- ❌ BURUK: String concatenation (SQL Injection risk + no plan cache)
-- "SELECT * FROM orders WHERE id = " + userInput

-- βœ… BAIK: Parameterized / prepared statement
-- "SELECT * FROM orders WHERE id = ?"

Checklist Optimasi Sebelum Production

βœ… Optimasi Checklist
  • β˜‘ Sudah jalankan EXPLAIN untuk semua query baru?
  • β˜‘ Tidak ada full table scan pada tabel > 100K baris?
  • β˜‘ Composite index sudah sesuai pola query?
  • β˜‘ Tidak ada SELECT * dalam query production?
  • β˜‘ Pagination menggunakan keyset / cursor?
  • β˜‘ Parameterized query sudah digunakan?
  • β˜‘ Slow query log sudah diaktifkan?
  • β˜‘ Sudah test dengan data volume production?
  • β˜‘ Connection pooling sudah dikonfigurasi?
  • β˜‘ Index yang tidak dipakai sudah dihapus?

10. Quiz Pemahaman

Uji pemahaman Anda tentang SQL Query Optimization!

1. Apa arti type=ALL pada hasil EXPLAIN?

2. Urutan kolom composite index (A, B, C) bisa dipakai untuk query WHERE A=? AND C=? karena leftmost prefix?

3. Mengapa keyset pagination lebih cepat dari OFFSET?

4. Apa yang dimaksud dengan covering index?

5. Mana yang lebih cepat untuk mengecek keberadaan data?

Rangkuman

πŸ“ Poin Penting
  • EXPLAIN adalah alat utama β€” selalu cek query plan sebelum deploy
  • Indexes mempercepat READ tapi menambah overhead pada WRITE
  • Composite index β€” ikuti leftmost prefix rule
  • Covering index β€” hilangkan kebutuhan akses ke data rows
  • Hindari full table scan, SELECT *, function pada indexed column
  • Keyset pagination lebih baik dari OFFSET untuk tabel besar
  • Monitor slow query log dan hapus index yang tidak dipakai