← semua materi
15:00 Teknis · 19 Oct 2026 · 15:13

🔬 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:

  1. Cari node dengan actual time × loops terbesar → itu target.
  2. Bandingkan rows estimasi vs actual per node. Rasio >10× = statistik bohong, bukan index kurang.
  3. Rows Removed by Filter besar → predikat tidak sargable / index tidak menutup.
  4. read besar tapi Execution Time kecil → 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

  1. EXPLAIN di dev, eksekusi di prod. Tabel 1.000 rows di dev → seq scan selalu menang. Plan berbeda total di 10 juta rows. Selalu EXPLAIN dengan volume dan distribusi mirip prod.
  2. Statistik basi setelah bulk load. pg_upgrade PG18 menyimpan statistik (fitur baru), tapi bulk COPY tetap butuh ANALYZE. Tanpa itu planner pakai estimasi lama.
  3. Heap Fetches tinggi di index-only scan. Lupa VACUUM. Autovacuum tidak jalan karena tabel besar / autovacuum_vacuum_scale_factor default 0.2 = 20% tabel harus mati dulu.
  4. 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_plan untuk query skewed.
  5. Rows Removed by Filter besar tapi index sudah ada. Berarti index tidak menutup predikat (urutan kolom salah, atau ada fungsi di kolom). Cek Index Cond vs Filter — kalau predikat utama ada di Filter, index tidak dipakai untuk itu.
  6. 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

  1. Sebuah node punya actual time=2.5..3.0 rows=100 loops=1000. Berapa total waktu kontribusi node itu, dan apa arti rows=100 di sini — 100 rows total atau 100.000?
  2. Query jadi 3× lebih cepat setelah tambah index, tapi Buffers: read naik dari 19.332 ke 20.049. Apa yang sebenarnya dihemat, dan mengapa Buffers bukan metrik yang benar untuk kasus ini?
  3. Index Only Scan menunjukkan Heap Fetches: 13 dengan actual 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) `. Setelah itu `Heap Fetches: 0` dan index-only scan benar-benar hanya baca index.

🔑 Poin Kunci


➡️ Eksplorasi Lanjut


🔗 Sumber

  1. PostgreSQL Global Development Group. "PostgreSQL 18.0 Release Notes" — postgresql.org, rilis 2025-09-25. https://www.postgresql.org/docs/release/18.0/
  2. PostgreSQL GDG. "PostgreSQL 18 Released!" — 2025-09-25. https://www.postgresql.org/about/news/postgresql-18-released-3142/
  3. PostgreSQL GDG. "14.1. Using EXPLAIN" — docs 18.6, diakses 2026-10-19. https://www.postgresql.org/docs/18/using-explain.html
  4. PostgreSQL GDG. "Versioning Policy" — 18.6 current, 19 belum GA, diakses 2026-10-19. https://www.postgresql.org/support/versioning/
  5. 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/
  6. PostgreSQL GDG. "PostgreSQL 19 Beta 1 Released!" — 2026-06-04. https://www.postgresql.org/about/news/postgresql-19-beta-1-released-3313/
  7. PostgreSQL GDG. "F.30. pg_plan_advice" — docs 19rc1. https://www.postgresql.org/docs/19/pgplanadvice.html
  8. PostgreSQL GDG. "F.29. pg_overexplain" — docs 19rc1. https://www.postgresql.org/docs/19/pgoverexplain.html
  9. 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
  10. 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.