🔬 BACOT — Sesi 15:00 Technical Exploration
📚 Topik: Membaca EXPLAIN ANALYZE dan Memperbaiki Query Lambat — PostgreSQL 18
Semua angka di bawah hasil eksekusi nyata di lab hari ini: postgres:18-alpine → PostgreSQL 18.6, 200.000 customers + 2.000.000 orders, container, shared_buffers default 128MB. Bukan kutipan blog.
🎯 Tujuan Belajar
Bisa membuka query lambat, menemukan satu node yang jadi biang, dan memilih perbaikan yang benar (index / statistik / rewrite) tanpa menebak. Plus tahu apa yang berubah di PG18 yang mengubah cara baca plan.
🏗️ Anatomi Satu Baris Plan
Parallel Seq Scan on orders o (cost=0.00..34119.67 rows=52364 width=16)
(actual time=0.905..685.901 rows=40988.67 loops=3)
Filter: ((status = 'paid') AND (created_at >= (now() - '90 days'::interval)))
Rows Removed by Filter: 625678
Buffers: shared hit=13087 read=4366
Urutan baca yang benar — bukan dari atas, tapi dari node termahal:
| Field | Arti | Jebakan |
|---|---|---|
cost=startup..total |
unit abstrak (seq_page_cost=1.0) |
jangan dibandingkan antar-server |
rows= (estimasi) |
output node, bukan rows yang dipindai | sering jauh dari actual |
actual time=startup..total |
ms, per loop | bukan total! |
loops=N |
berapa kali node dieksekusi | total waktu = actual × loops |
Rows Removed by Filter |
rows dibuang setelah dibaca | kalau besar → index salah/kurang |
Buffers: hit/read |
8KB pages dari cache / dari disk | read tinggi + waktu tinggi = I/O bound |
Heap Fetches (index-only scan) |
berapa kali tetap balik ke heap | >0 = visibility map belum ter-VACUUM |
Aturan diagnosis:
- Cari node dengan
actual time × loopsterbesar → itu target. - Bandingkan
rowsestimasi vsactualper node. Rasio >10× = statistik bohong, bukan index kurang. Rows Removed by Filterbesar → predikat tidak sargable / index tidak menutup.readbesar tapiExecution Timekecil → data sudah di cache, jangan optimasi I/O.
💻 Lab Reproducible (yang benar-benar saya jalankan)
docker run -d --name pg18lab -e POSTGRES_PASSWORD=lab -e POSTGRES_DB=lab \
-p 55432:5432 postgres:18-alpine
# PostgreSQL 18.6 on x86_64-pc-linux-musl
CREATE TABLE customers (
id bigserial PRIMARY KEY, email text NOT NULL,
country text NOT NULL, created_at timestamptz NOT NULL);
CREATE TABLE orders (
id bigserial PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL, total_cents bigint NOT NULL,
created_at timestamptz NOT NULL);
INSERT INTO customers (email, country, created_at)
SELECT 'user'||i||'@example.com',
(ARRAY['ID','SG','MY','US','DE'])[1 + (i % 5)],
now() - (i || ' minutes')::interval
FROM generate_series(1, 200000) i;
INSERT INTO orders (customer_id, status, total_cents, created_at)
SELECT 1 + (random()*199999)::int,
(ARRAY['pending','paid','shipped','cancelled','refunded'])[1 + (random()*4)::int],
(random()*500000)::bigint,
now() - (random()*525600 || ' minutes')::interval
FROM generate_series(1, 2000000);
ANALYZE customers; ANALYZE orders;
Query korban:
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT c.country, count(*), sum(o.total_cents)/100 AS revenue
FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid' AND o.created_at >= now() - interval '90 days'
GROUP BY c.country ORDER BY revenue DESC;
📊 Eksperimen: 857 ms → 289 ms (3×)
Baseline
Parallel Hash Join (actual time=40.819..761.607 rows=40988.67 loops=3)
-> Parallel Seq Scan on orders o (actual time=0.905..685.901 rows=40988.67 loops=3)
Filter: ((status = 'paid') AND (created_at >= ...))
Rows Removed by Filter: 625678
Buffers: shared hit=13087 read=4366
Buffers: shared hit=14966 read=4366
Execution Time: 857.430 ms
Baca: 3 worker × 666.667 rows dipindai = 2 juta rows untuk dapat 123 ribu. Rows Removed by Filter 625.678/worker → CPU filter yang bayar, bukan disk. hit 13.087 >> read 4.366 → I/O bukan bottleneck, CPU filter yang bottleneck.
Fix: composite index + INCLUDE
CREATE INDEX CONCURRENTLY idx_orders_status_created
ON orders (status, created_at) INCLUDE (customer_id, total_cents);
ANALYZE orders;
Parallel Bitmap Heap Scan on orders o (actual time=15.034..130.694 rows=40988.67 loops=3)
Recheck Cond: ((status = 'paid') AND (created_at >= ...))
Heap Blocks: exact=6490
-> Bitmap Index Scan on idx_orders_status_created (actual time=12.146..12.147 rows=122966.00)
Index Searches: 1
Buffers: shared hit=2603 read=17446 written=3268
Execution Time: 288.840 ms
Kenapa 3× lebih cepat padahal Buffers total naik (19.332 → 20.049)? Karena CPU filter hilang: index hanya mengembalikan 122.966 entry, bukan memindai 2 juta tuple. INCLUDE (customer_id, total_cents) membuat index covering → heap hanya disentuh 6.490 blok (bukan 17.453).
Ini pelajaran inti: buffers bukan KPI. Waktu eksekusi yang KPI.
Ukuran index (kenapa INCLUDE mahal)
idx_orders_status_created | 101 MB
idx_orders_customer | 60 MB
orders_pkey | 43 MB
idx_orders_paid_recent | 11 MB <- partial
idx_orders_created_brin | 32 kB <- BRIN
orders (heap total) | 351 MB
📊 Eksperimen 2: Predikat Tidak Sargable — 1.900×
CREATE INDEX idx_customers_email ON customers (email);
-- A: sargable
EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM customers WHERE email = 'user42@example.com';
-- Index Scan using idx_customers_email Execution Time: 0.089 ms
-- B: fungsi di kolom → index mati
EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM customers WHERE lower(email) = 'user42@example.com';
-- Parallel Seq Scan, Rows Removed by Filter: 66666, Buffers: shared hit=3912
-- Execution Time: 104.585 ms (1.175× lebih banyak buffer)
-- C: expression index
CREATE INDEX idx_customers_lower_email ON customers (lower(email));
-- Index Scan using idx_customers_lower_email Execution Time: 0.055 ms
0.089 ms vs 104.585 ms vs 0.055 ms. Pola WHERE lower(col) = ..., WHERE date_trunc('day', ts) = ..., WHERE col::text = ... adalah penyebab paling umum query "index-nya ada tapi tidak kepakai".
📊 Eksperimen 3: Statistik Bohong — CREATE STATISTICS
ALTER TABLE customers ADD COLUMN region text;
UPDATE customers SET region = CASE country
WHEN 'ID' THEN 'SEA' WHEN 'SG' THEN 'SEA' WHEN 'MY' THEN 'SEA'
WHEN 'US' THEN 'AMER' ELSE 'EMEA' END;
ANALYZE customers;
-- planner asumsikan independen: P(country='ID') × P(region='SEA')
EXPLAIN (ANALYZE) SELECT count(*) FROM customers WHERE country='ID' AND region='SEA';
-- rows=9931 (estimasi) actual rows=13333.33 loops=3 → 40.000
-- Execution Time: 127.192 ms
Planner mengira 9.931 rows, kenyataannya 40.000. Underestimate 4× → planner salah pilih nested loop di query join nyata.
CREATE STATISTICS cust_country_region (dependencies, ndistinct)
ON country, region FROM customers;
ANALYZE customers;
EXPLAIN (ANALYZE) SELECT count(*) FROM customers WHERE country='ID' AND region='SEA';
-- rows=16375 (estimasi, naik dari 9931) actual 40.000
-- Execution Time: 31.752 ms
Jujur: 16.375 masih jauh dari 40.000 — functional dependency-nya parsial (ID ⇒ SEA selalu, tapi SEA ⇐ juga SG,MY). dependencies menangkap arah ID→SEA, jadi estimasi naik 65%, tapi tidak exact. DIKETAHUI dari hasil ukur; DIINFERENSI bahwa ndistinct menambah sedikit. Untuk dependency penuh, statistik extended tetap alat yang benar — hanya bukan silver bullet.
Efek samping nyata: Execution Time 127 → 32 ms. Bukan karena plan berubah (tetap Parallel Seq Scan), tapi karena estimasi row yang lebih tinggi mengubah cost model agregat paralel → distribusi kerja lebih baik. Ini contoh plan sama, waktu beda 4×.
Cek statistik extended aktif:
SELECT stxname, stxkind FROM pg_statistic_ext WHERE stxname='cust_country_region';
-- cust_country_region | {d,f} (d=dependencies, f=ndistinct)
📊 Eksperimen 4: Index-Only Scan & Heap Fetches
VACUUM (ANALYZE) orders;
EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders WHERE customer_id = 77;
Index Only Scan using idx_orders_customer on orders
(actual time=0.350..0.353 rows=13.00 loops=1)
Index Cond: (customer_id = 77)
Heap Fetches: 0
Index Searches: 1
Buffers: shared hit=6 read=1
Execution Time: 0.416 ms
Heap Fetches: 0 hanya mungkin setelah VACUUM mengisi visibility map. Tanpa VACUUM, index-only scan tetap balik ke heap per row → bisa lebih lambat dari seq scan. Kalau lihat Heap Fetches ≈ actual rows, artinya tabel belum di-VACUUM, bukan index-nya salah.
Index Searches: 1 — kolom baru PG18. Berguna untuk nested loop: kalau Index Searches = loops, index dipakai berulang (wajar); kalau jauh lebih besar, ada skip scan.
📊 Eksperimen 5: Skip Scan PG18 (fitur baru, terbukti nyata)
Index (status, created_at), query hanya filter created_at — tanpa = di kolom leading:
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE created_at > now() - interval '1 day';
Index Only Scan using idx_orders_status_created on orders
(cost=0.43..242.86 rows=5503 width=0) (actual time=3.628..13.337 rows=5537.00)
Index Cond: (created_at > (now() - '1 day'::interval))
Heap Fetches: 0
Index Searches: 6
Buffers: shared hit=14 read=54
Execution Time: 13.804 ms
Index Searches: 6 untuk 5 distinct value status → bukti skip scan: PG18 melompati tiap prefix, bukan seq scan. Di PG17 query ini = full seq scan. DIKETAHUI (angka dari pg_settings/plan); tidak ada GUC enable_indexskipscan untuk mematikan (saya cek: ERROR: unrecognized configuration parameter).
Konsekuensi: index (a,b) kini berguna walau a tidak difilter — tapi hanya kalau a ber-cardinality rendah. Kalau a punya 10.000 distinct value, skip scan = 10.000 descents = lebih lambat dari seq scan. Planner yang putuskan via cost; jangan paksa.
📊 Eksperimen 6: N+1 — Angka Sebenarnya Menyesatkan
DO $$
DECLARE i int; n bigint;
BEGIN
FOR i IN 1..100 LOOP
SELECT count(*) INTO n FROM orders WHERE customer_id = i;
END LOOP;
END $$;
-- Time: 18.252 ms → 0,18 ms/query (server-side, warm cache)
Bandingkan satu query gabungan:
Nested Loop Left Join (actual time=0.390..417.368 rows=972.00)
-> Index Only Scan using customers_pkey (loops=1, Index Searches: 1)
-> Index Scan using idx_orders_customer (actual time=0.634..4.166 rows=9.72 loops=100)
Index Searches: 100
Execution Time: 418.238 ms
Ini penting dan sering salah dipahami: N+1 versi loop 18 ms, versi join 418 ms — join lebih lambat di sini karena pages masih cold dan join menyentuh 100 customer + 972 order rows dalam satu plan. N+1 bukan masalah CPU database. Biayanya di round-trip: 100 query × RTT. Di localhost RTT ~0,05 ms → 18 ms. Lewat jaringan 0,5–2 ms RTT → 50–200 ms murni latency, ditambah parse/plan overhead per query. Di production dengan 50 query per request, itu 25–100 ms per request yang tidak muncul di pg_stat_statements sebagai query lambat.
Kesimpulan operasional: N+1 harus dideteksi di layer aplikasi (query count per request), bukan di EXPLAIN. Database hanya menunjukkan 100 query identik dengan waktu kecil.
📊 Index Type — Kapan Pakai Apa
| Tipe | Ukuran di lab | Pakai kalau | Jangan pakai kalau |
|---|---|---|---|
| B-tree | 43–101 MB | =, <, >, BETWEEN, ORDER BY, prefix LIKE 'x%' |
kolom low-cardinality sendirian |
| BRIN | 32 kB (created_at) | tabel append-only, data terurut fisik, range query luas | data acak, point lookup |
| Hash | — | hanya =, kolom lebar |
butuh range/sort; tidak WAL-logged pre-PG10 |
| GIN | — | jsonb, array, full-text, pg_trgm |
write-heavy (mahal update) |
| GiST | — | geometri, range types, tsvector ranking |
exact match sederhana |
| Partial | 11 MB vs 101 MB | query selalu filter subset tetap (status='paid') |
predikat bervariasi runtime |
| Covering (INCLUDE) | +30% ukuran | index-only scan, hindari heap fetch | tabel sering UPDATE (INCLUDE ikut di-update) |
BRIN created_at cuma 32 kB vs 101 MB B-tree — 3.200× lebih kecil. Tapi di query saya planner tetap pilih B-tree karena 16.500 rows dari 2 juta masih selectivity 0,8%, dan B-tree lebih presisi. BRIN menang saat query menyapu 5–20% tabel berurutan secara fisik.
Partial index — perhatikan idx_scan:
SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS size, idx_scan
FROM pg_stat_user_indexes WHERE relname='orders' ORDER BY pg_relation_size(indexrelid) DESC;
idx_orders_status_created | 101 MB | 8
idx_orders_customer | 60 MB | 8
orders_pkey | 43 MB | 1
idx_orders_paid_recent | 11 MB | 0 <- dibuat tapi belum kepakai
idx_scan = 0 pada index 11 MB = index mati. Audit index mati secara berkala — setiap index menambah biaya write (setiap INSERT/UPDATE menulis semua index).
🆕 Yang Berubah di PG18 (mengubah cara baca plan)
DIKETAHUI — diverifikasi langsung di 18.6:
-- 1. BUFFERS kini default saat ANALYZE (PG17 harus eksplisit)
EXPLAIN (ANALYZE) SELECT count(*) FROM orders WHERE status='paid';
-- → "Buffers: shared hit=10697 read=6756" ← muncul tanpa keyword BUFFERS
-- 2. Opsi EXPLAIN baru di 18 (\h EXPLAIN):
-- ANALYZE VERBOSE COSTS SETTINGS GENERIC_PLAN BUFFERS
-- SERIALIZE {NONE|TEXT|BINARY} WAL TIMING SUMMARY MEMORY
-- FORMAT {TEXT|XML|JSON|YAML}
EXPLAIN (ANALYZE, MEMORY) SELECT * FROM orders ORDER BY total_cents DESC LIMIT 10;
-- Planning: Memory: used=18kB allocated=32kB
-- Sort Method: top-N heapsort Memory: 26kB
EXPLAIN (ANALYZE, VERBOSE, WAL) SELECT count(*) FROM orders WHERE status='refunded';
-- VERBOSE: Output, Worker 0/1 breakdown per node
-- WAL: full WAL buffer count
-- 3. Async I/O: io_method default = worker
SHOW io_method; -- worker
SHOW io_workers; -- 3
SHOW effective_io_concurrency; -- 16
SHOW io_combine_limit; -- 128kB
-- 4. View baru: I/O in flight
\d pg_aios
-- pid, io_id, io_generation, state, operation, off, length, target,
-- handle_data_len, raw_result, result, target_desc, f_sync, f_localmem, f_buffered
SELECT count(*) FROM pg_aios; -- 0 (idle)
-- 5. pg_stat_io berubah nama kolom: count → reads/writes, + read_bytes/write_bytes/extend_time
SELECT backend_type, object, context, reads, writes, read_time, write_time, extend_time
FROM pg_stat_io WHERE backend_type='client backend' ORDER BY reads DESC LIMIT 5;
client backend | relation | normal | 25326 | 21712 | 0 | 0 | 0
client backend | relation | bulkread | 1821 | 0 | 0 | 0 |
client backend | relation | vacuum | 1244 | 3868 | 0 | 0 | 0
client backend | wal | init | | 32 | | 0 |
-- 6. pg_overexplain: dump internal planner (debug only)
LOAD 'pg_overexplain';
EXPLAIN (ANALYZE, DEBUG, COSTS OFF) SELECT count(*) FROM customers WHERE country='ID';
-- Disabled Nodes: 0
-- Parallel Safe: false
-- Plan Node ID: 0
-- extParam: 0 / allParam: 0
Peringatan penting dari benchmark pihak ketiga: pganalyze (Lukas Fittl, 2025-05-07, AWS c7i.8xlarge, EBS io2 100GB @20.000 IOPS, tabel 3.5GB, cold cache, parallel off):
| Konfigurasi | Waktu SELECT count(*) |
|---|---|
| PG17 sync | 15.831 ms |
PG18 io_method=sync |
15.071 ms |
PG18 io_method=worker (3 worker) |
10.052 ms |
PG18 io_method=io_uring |
5.723 ms |
Klaim resmi "up to 3× read performance" konsisten dengan angka ini. Tapi: io_method=io_uring butuh Linux kernel ≥5.1 + filesystem kompatibel; default-nya worker. Dan I/O timing di EXPLAIN jadi menyesatkan — I/O Timings melaporkan waktu menunggu, bukan waktu I/O sebenarnya, karena paralelisme worker tidak terlihat. Di benchmark yang sama: I/O Timings: shared read=7218ms untuk query yang selesai dalam 10.480 ms — angkanya tidak bisa dijumlahkan.
Implikasi praktis: jangan pakai I/O Timings sebagai KPI. Pakai Buffers: shared read + pg_stat_io.read_time (yang akurat per-backend).
🆕 PostgreSQL 19 — RC1, GA 19 Nov 2026
DIKETAHUI dari pengumuman resmi 2026-10-15: RC1 rilis, RC2 12 Nov, GA 19 November 2026. Bukan production-ready sekarang.
Relevan untuk topik ini:
| Fitur | Dampak ke tuning query |
|---|---|
pg_plan_advice extension |
Bekukan plan pilihan lewat mini-language: JOIN_ORDER(f d), HASH_JOIN(d), SEQ_SCAN(f d), NO_GATHER(f d) |
pg_stash_advice |
Terapkan advice otomatis via query identifier |
EXPLAIN ANALYZE I/O per node |
Tutup blind spot AIO |
io_min_workers / io_max_workers |
Worker AIO auto-scale (PG18 hardcode 3) |
| Parallel autovacuum | autovacuum_max_parallel_workers |
REPACK [CONCURRENTLY] |
Rebuild tabel tanpa downtime (pengganti VACUUM FULL/pg_repack) |
| JIT off by default | JIT sering bikin query OLTP pendek lebih lambat |
| FK check ~2× lebih cepat saat insert | — |
GROUP BY ALL, WAIT FOR LSN |
Ergonomi |
Cara pakai pg_plan_advice (dari docs 19):
LOAD 'pg_plan_advice'; -- atau shared_preload_libraries
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM join_fact f JOIN join_dim d ON f.dim_id = d.id;
-- Generated Plan Advice:
-- JOIN_ORDER(f d)
-- HASH_JOIN(d)
-- SEQ_SCAN(f d)
-- NO_GATHER(f d)
SET pg_plan_advice.advice = 'JOIN_ORDER(f d)'; -- bekukan hanya join order
EXPLAIN (COSTS OFF) SELECT ...;
-- Supplied Plan Advice:
-- JOIN_ORDER(f d) /* matched */
Batasan yang wajib dibaca sebelum pakai: advice hanya membatasi pilihan planner, tidak memaksa plan yang tidak dipertimbangkan planner. Kalau advice mustahil, node ditandai Disabled: true dan plan bisa jauh lebih buruk dari tanpa advice. Tidak bisa mengontrol agregasi atau UNION/INTERSECT. Berfungsi probabilistik dengan GEQO. Ini alat untuk mengunci plan yang sudah terbukti bagus, bukan untuk menambal statistik buruk.
⚖️ Trade-off
| Keputusan | Menang | Kalah |
|---|---|---|
| Tambah index | baca cepat | write lebih lambat (tiap index = 1 write path), disk, VACUUM lebih lama |
INCLUDE kolom |
index-only scan, hindari heap | index membengkak ~30%, UPDATE ikut tulis kolom INCLUDE |
| Partial index | kecil (11MB vs 101MB) | hanya untuk predikat literal; parameterized query tidak cocok |
CREATE STATISTICS |
estimasi lebih baik, 0 biaya write | ANALYZE lebih lama; hanya menolong korelasi antar kolom di tabel sama |
pg_plan_advice (PG19) |
plan stabil | mematikan adaptasi planner → bom waktu saat data berubah |
io_method=io_uring |
2–3× read | butuh kernel/FS support; worker lebih portabel |
work_mem besar |
hindari sort spill | per-operasi, bukan per-server → OOM saat konkurensi tinggi |
enable_seqscan=off |
paksa index untuk tes | JANGAN di production; menyesatkan cost model global |
🏭 Failure Mode Produksi
EXPLAINdi dev, eksekusi di prod. Tabel 1.000 rows di dev → seq scan selalu menang. Plan berbeda total di 10 juta rows. SelaluEXPLAINdengan volume dan distribusi mirip prod.- Statistik basi setelah bulk load.
pg_upgradePG18 menyimpan statistik (fitur baru), tapi bulkCOPYtetap butuhANALYZE. Tanpa itu planner pakai estimasi lama. Heap Fetchestinggi di index-only scan. Lupa VACUUM. Autovacuum tidak jalan karena tabel besar /autovacuum_vacuum_scale_factordefault 0.2 = 20% tabel harus mati dulu.- Parameterized query + plan cache. Saya tes:
PREPARE p(text) AS SELECT count(*) FROM orders WHERE status=$1— eksekusi pertama pakai custom plan (rows=104472 untuk 'refunded'), eksekusi ke-6 baru generic plan (Index Cond: (status = $1), rows=166667). Kalau distribusi data miring (skewed), generic plan bisa 10× lebih lambat untuk nilai langka. Solusi:plan_cache_mode = force_custom_planuntuk query skewed. Rows Removed by Filterbesar tapi index sudah ada. Berarti index tidak menutup predikat (urutan kolom salah, atau ada fungsi di kolom). CekIndex CondvsFilter— kalau predikat utama ada diFilter, index tidak dipakai untuk itu.- N+1 tidak terlihat di
pg_stat_statements. 100 query @0,18 ms = tidak masuk slow log, tapi 50–200 ms latency per request. Deteksi di aplikasi.
🛠️ Latihan Praktis (10 menit, laptop sendiri)
docker run -d --name pglab -e POSTGRES_PASSWORD=lab -p 55432:5432 postgres:18-alpine
Lalu jalankan seed di atas, dan buat query sendiri yang lambat, lalu:
-- Detektor salah estimasi: jalankan setelah EXPLAIN (ANALYZE, FORMAT JSON)
-- Simpan output JSON ke file, lalu:
-- Versi SQL murni: cari node dengan misestimate > 10x
WITH plan AS (
SELECT (EXPLAIN_JSON_PLACEHOLDER)::json AS j
)
SELECT * FROM plan; -- lihat script python di bawah
Praktisnya pakai psql + jq:
psql "postgresql://postgres:lab@localhost:55432/lab" -Atc \
"EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT ..." > plan.json
jq -r '
.. | objects | select(has("Node Type")) |
"\(."Node Type") est=\(."Plan Rows") act=\(."Actual Rows") loops=\(."Actual Loops") time=\(."Actual Total Time")"
' plan.json
Target latihan:
1. Buat query dengan Rows Removed by Filter > 100.000 → perbaiki dengan composite index. Ukur sebelum/sesudah.
2. Buat dua kolom berkorelasi → tambah CREATE STATISTICS (dependencies) → bandingkan rows= estimasi.
3. Buat index yang tidak pernah dipakai → temukan via pg_stat_user_indexes WHERE idx_scan = 0 → drop, ukur waktu INSERT.
✅ Cek Pemahaman
- Sebuah node punya
actual time=2.5..3.0 rows=100 loops=1000. Berapa total waktu kontribusi node itu, dan apa artirows=100di sini — 100 rows total atau 100.000? - Query jadi 3× lebih cepat setelah tambah index, tapi
Buffers: readnaik dari 19.332 ke 20.049. Apa yang sebenarnya dihemat, dan mengapaBuffersbukan metrik yang benar untuk kasus ini? Index Only ScanmenunjukkanHeap Fetches: 13denganactual rows=13. Apa diagnosisnya, dan apa satu perintah untuk memperbaikinya?
Jawaban
1. Total ≈ 3,0 × 1000 = **3.000 ms**. `rows=100` adalah **per loop** → 100.000 rows total. Ini jebakan #1 saat baca plan: `actual time` dan `actual rows` selalu per-loop, harus dikali `loops`. 2. Yang dihemat adalah **CPU filter**: seq scan memindai 2 juta tuple dan mengevaluasi `status='paid' AND created_at>=...` per tuple; bitmap hanya mengambil 122.966 index entry lalu 6.490 heap blok. `Buffers` mirip karena keduanya menyentuh heap yang sama — yang hilang adalah pekerjaan CPU, bukan I/O. `Buffers` berguna untuk memisahkan I/O-bound vs CPU-bound, bukan untuk mengukur perbaikan CPU. 3. Visibility map belum terisi → setiap row tetap harus dicek ke heap untuk visibility. Perbaikan: `VACUUM (ANALYZE)🔑 Poin Kunci
- Baca plan dari node termahal (
actual time × loops), bukan dari atas. actual rowsdanactual time= per loop. Kalikanloops.Rows Removed by Filterbesar = CPU terbakar, index tidak menutup predikat.Buffers hit >> read= CPU-bound. Optimasi index tidak menolong; rewrite/agregasi yang menolong.estimasi rows ÷ actual rows > 10= statistik salah →ANALYZEdulu, baruCREATE STATISTICS, jangan langsung tambah index.- Fungsi di kolom (
lower(col)) mematikan B-tree biasa → expression index. Terukur 1.900× di lab. - PG18:
BUFFERSdefault,Index Searchesper node,pg_aios,io_method, skip scan. Skip scan hanya menolong prefix low-cardinality. - PG18:
I/O Timingstidak bisa dipercaya dengan AIO aktif — laporan waktu tunggu, bukan waktu I/O. - Index mati (
idx_scan=0) = pajak write tanpa manfaat. Audit rutin. - N+1 = masalah latency round-trip, bukan masalah database. Tidak akan muncul di slow query log.
- PG19 GA 19 Nov 2026:
pg_plan_adviceuntuk bekukan plan,REPACK CONCURRENTLY, parallel autovacuum, JIT off by default. Jangan production sebelum GA.
➡️ Eksplorasi Lanjut
pg_stat_statements— agregasi query bytotal_exec_time,mean_exec_time,shared_blks_read. Ini pintu masuk sebelumEXPLAIN.auto_explain— log plan otomatis untuk query > N ms, termasukauto_explain.log_nested_statements.- Isolation level &
EXPLAINuntuk locking:SELECT ... FOR UPDATEplan +pg_locks. pg_stat_iountuk menemukan tabel yangread-nya tinggi → kandidatpg_prewarmatau partisi.- Partitioning +
enable_partition_pruning— planAppenddenganSubplans Removed. pg_hint_plan(ekstensi pihak ketiga) sebagai alternatifpg_plan_advicedi PG18 ke bawah.
🔗 Sumber
- PostgreSQL Global Development Group. "PostgreSQL 18.0 Release Notes" — postgresql.org, rilis 2025-09-25. https://www.postgresql.org/docs/release/18.0/
- PostgreSQL GDG. "PostgreSQL 18 Released!" — 2025-09-25. https://www.postgresql.org/about/news/postgresql-18-released-3142/
- PostgreSQL GDG. "14.1. Using EXPLAIN" — docs 18.6, diakses 2026-10-19. https://www.postgresql.org/docs/18/using-explain.html
- PostgreSQL GDG. "Versioning Policy" — 18.6 current, 19 belum GA, diakses 2026-10-19. https://www.postgresql.org/support/versioning/
- PostgreSQL GDG. "PostgreSQL 19 RC 1 Released!" — 2026-10-15 (GA target 2026-11-19). https://www.postgresql.org/about/news/postgresql-19-rc-1-released-3409/
- PostgreSQL GDG. "PostgreSQL 19 Beta 1 Released!" — 2026-06-04. https://www.postgresql.org/about/news/postgresql-19-beta-1-released-3313/
- PostgreSQL GDG. "F.30. pg_plan_advice" — docs 19rc1. https://www.postgresql.org/docs/19/pgplanadvice.html
- PostgreSQL GDG. "F.29. pg_overexplain" — docs 19rc1. https://www.postgresql.org/docs/19/pgoverexplain.html
- Fittl, Lukas. "Waiting for Postgres 18: Accelerating Disk Reads with Asynchronous I/O" — pganalyze, 2025-05-07 (benchmark AWS c7i.8xlarge, EBS io2). https://pganalyze.com/blog/postgres-18-async-io
- Bytebase. "What's New in PostgreSQL 18: A DBA's Perspective" — 2025. https://www.bytebase.com/blog/what-is-new-in-postgres-18/
⚠️ Catatan limitasi: Semua angka benchmark di materi ini dari lab container single-node, shared_buffers default, tanpa konkurensi, storage lokal (bukan network-attached). Angka absolut tidak akan sama di production — yang bisa ditransfer adalah pola diagnosis dan rasio relatif. Benchmark AIO 2–3× adalah angka pihak ketiga (pganalyze), bukan pengukuran saya; saya tidak punya akses EBS io2 untuk mereproduksi. Angka pg_stat_io di lab menunjukkan read_time=0 karena storage lokal terlalu cepat untuk pengukuran resolusi itu — bukti bahwa metrik timing butuh storage realistis.