Chapter penutup Section Index memperkenalkan sebuah rutinitas kerja: siapkan data uji, jalankan ANALYZE, lalu pastikan nama index muncul di hasil EXPLAIN. Rutinitas itu hanya berguna bila kita bisa membaca apa yang EXPLAIN tampilkan. Execution plan — rencana eksekusi yang dicetak EXPLAIN — adalah bahasa diagnosa utama di dunia PostgreSQL: setiap keluhan “query ini lambat” pada akhirnya bermuara pada satu dokumen berbentuk pohon yang memuat langkah-langkah pilihan server. Chapter ini melatih cara membaca dokumen tersebut, mulai dari struktur paling murni sampai angka-angka estimasinya. Domain latihan kita tetap penjualan Toko Maju Jaya, kali ini dengan 5.000 pelanggan dan 30.000 order.

sql buat-tabel.sql
-- Domain latihan section ini: penjualan Toko Maju Jaya.
-- 5.000 pelanggan dan 30.000 order, dibangun dengan generate_series
-- agar datanya identik di setiap eksekusi.
CREATE TABLE pelanggan (
    id   integer PRIMARY KEY,
    nama text NOT NULL,
    kota text NOT NULL
);

INSERT INTO pelanggan (id, nama, kota)
SELECT g,
       'Pelanggan ' || lpad(g::text, 4, '0'),
       (ARRAY['Jakarta','Bandung','Surabaya','Medan','Denpasar'])[1 + (g - 1) / 1000]
FROM generate_series(1, 5000) AS g;

CREATE TABLE order_penjualan (
    id           bigint PRIMARY KEY,
    pelanggan_id integer NOT NULL REFERENCES pelanggan(id),
    tanggal      date NOT NULL,
    total        numeric(12,2) NOT NULL
);

INSERT INTO order_penjualan (id, pelanggan_id, tanggal, total)
SELECT g,
       1 + (g * 37) % 5000,
       DATE '2026-01-01' + (g % 180),
       ((g % 900) * 1000 + 25000)::numeric(12,2)
FROM generate_series(1, 30000) AS g;

ANALYZE pelanggan;
ANALYZE order_penjualan;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 5000
CREATE TABLE
INSERT 0 30000
ANALYZE
ANALYZE

Rencana Eksekusi adalah Pohon Langkah #

SQL adalah bahasa deklaratif: kita menuliskan hasil yang diinginkan, bukan urutan langkah untuk mencapainya. Server PostgreSQL yang menentukan urutan langkah itu. Saat sebuah query masuk, PostgreSQL mem-parse teksnya, membangun kemungkinan-kemungkinan rencana, lalu mengeksekusi satu rencana terpilih. Rencana terpilih inilah execution plan: sebuah pohon yang setiap simpulnya (disebut node) mewakili satu algoritma — memindai tabel, menggabungkan dua sumber baris, mengurutkan, atau mengagregasi.

Mekanisme kerjanya berjalan seperti garis produksi. Node anak menghasilkan baris; node induk mengonsumsi baris dari anaknya, memproses, lalu mengeluarkan baris ke induknya lagi. Executor memproses query dengan menarik baris dari node paling atas, node paling atas menarik dari anak-anaknya, dan begitu seterusnya sampai node daun yang membaca tabel fisik. Satu query kompleks bisa berisi belasan node yang bekerja berlapis.

Query uji kita adalah laporan penjualan per kota — join dua tabel, agregasi GROUP BY, lalu pengurutan. Berikut hasilnya, lima baris yang kelihatan sederhana:

sql laporan.sql
-- Laporan penjualan per kota: query yang akan kita bedah rencananya.
SELECT p.kota,
       count(*) AS jumlah_order,
       sum(o.total) AS nilai_penjualan
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota
ORDER BY jumlah_order DESC, p.kota;

Output yang Dihasilkan:

text
   kota   | jumlah_order | nilai_penjualan 
----------+--------------+-----------------
 Bandung  |         6000 |   2808900000.00
 Denpasar |         6000 |   2817300000.00
 Jakarta  |         6000 |   2839800000.00
 Medan    |         6000 |   2827500000.00
 Surabaya |         6000 |   2851800000.00
(5 rows)

Membaca dari Dalam ke Luar #

Aturan pertama membaca execution plan: node paling menjorok ke dalam dieksekusi paling awal, dan baris mengalir dari dalam ke luar. Baris teratas dari output — hasil akhir query — keluar dari node paling luar. Indentasi pada output EXPLAIN memetakan relasi induk-anak: setiap baris yang lebih menjorok adalah anak (masukan) bagi baris di atasnya.

Untuk membaca rencana query laporan di atas, mulai dari baris paling bawah lalu naik: dua Seq Scan membaca order_penjualan dan pelanggan, node Hash membangun tabel hash dari pelanggan, Hash Join menggabungkan keduanya, HashAggregate menghitung agregat per kota, dan Sort mengurutkan lima baris hasil akhirnya. EXPLAIN menampilkan pohon dari atas ke bawah, tetapi alur pengerjaannya dibaca dari bawah ke atas.

Menampilkan Hanya Pohonnya dengan COSTS OFF #

Opsi COSTS OFF menyembunyikan seluruh angka estimasi (biaya, jumlah baris, lebar baris) sehingga yang tersisa murni struktur pohon. Bentuk termurni ini cocok untuk latihan membaca alur:

-- EXPLAIN (COSTS OFF): hanya pohon langkah, tanpa angka biaya.
-- Berguna untuk melihat bentuk rencana secara murni.
EXPLAIN (COSTS OFF)
SELECT p.kota,
       count(*) AS jumlah_order,
       sum(o.total) AS nilai_penjualan
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota
ORDER BY jumlah_order DESC, p.kota;

Output yang Dihasilkan:

text
                    QUERY PLAN                    
--------------------------------------------------
 Sort
   Sort Key: (count(*)) DESC, p.kota
   ->  HashAggregate
         Group Key: p.kota
         ->  Hash Join
               Hash Cond: (o.pelanggan_id = p.id)
               ->  Seq Scan on order_penjualan o
               ->  Hash
                     ->  Seq Scan on pelanggan p
(9 rows)

Perhatikan dua baris Seq Scan berada pada kedalaman berbeda walaupun keduanya membaca tabel: Seq Scan on order_penjualan adalah anak langsung dari Hash Join, sedangkan Seq Scan on pelanggan dibungkus node Hash terlebih dahulu. Node Hash membangun struktur hash dari hasil scan pelanggan — itulah sebabnya ia menjadi pasangan build side pada Hash Join. Struktur seperti ini akan berulang di hampir setiap rencana yang Anda temui.

Empat Angka di Setiap Node #

Tanpa opsi apa pun, EXPLAIN mencetak empat angka dalam tanda kurung pada setiap node. Bentuk lengkapnya seperti ini:

-- EXPLAIN lengkap dengan estimasi biaya, baris, dan lebar baris.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT p.kota,
       count(*) AS jumlah_order,
       sum(o.total) AS nilai_penjualan
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota
ORDER BY jumlah_order DESC, p.kota;

Output yang Dihasilkan:

text
                                        QUERY PLAN                                        
------------------------------------------------------------------------------------------
 Sort  (cost=945.44..945.45 rows=5 width=48)
   Sort Key: (count(*)) DESC, p.kota
   ->  HashAggregate  (cost=945.32..945.38 rows=5 width=48)
         Group Key: p.kota
         ->  Hash Join  (cost=149.50..720.32 rows=30000 width=14)
               Hash Cond: (o.pelanggan_id = p.id)
               ->  Seq Scan on order_penjualan o  (cost=0.00..492.00 rows=30000 width=10)
               ->  Hash  (cost=87.00..87.00 rows=5000 width=12)
                     ->  Seq Scan on pelanggan p  (cost=0.00..87.00 rows=5000 width=12)
(9 rows)

Mari kita bedah baris Sort (cost=945.44..945.45 rows=5 width=48) satu per satu:

  1. cost=945.44..945.45: sepasang angka biaya — startup cost (945.44) dan total cost (945.45). Startup cost adalah perkiraan biaya sebelum baris pertama dari node ini bisa keluar; total cost adalah perkiraan biaya sampai baris terakhir selesai. Node Sort harus menelan seluruh masukan sebelum bisa mengeluarkan satu baris pun, sehingga startup cost-nya hampir sama besar dengan total cost-nya.
  2. rows=5: perkiraan jumlah baris yang dihasilkan node ini — bukan jumlah baris yang dibacanya. Sort membaca ±5 baris dari HashAggregate dan mengeluarkan 5 baris.
  3. width=48: perkiraan rata-rata ukuran satu baris keluaran, dalam byte.
  4. cost=0.00..492.00 pada Seq Scan: startup cost nol — sequential scan bisa mengeluarkan baris pertama seketika tanpa persiapan. Kontras ini penting: node dengan startup cost besar (Sort, Hash Join, HashAggregate) tidak cocok untuk pola LIMIT yang berharap baris pertama cepat keluar.

Satuan biaya sengaja tidak berbentuk milidetik. PostgreSQL memakai satuan arbitrer di mana satu unit biaya kira-kira sama dengan biaya membaca satu halaman disk secara sekuensial. Bab-bab berikutnya membedah satuan ini sampai ke parameter seq_page_cost dan kawan-kawannya.

Galeri Node yang Paling Sering Muncul #

Sepanjang section ini kita akan bertemu node yang sama berulang kali. Tabel berikut merangkum keluarganya:

KeluargaNode yang sering munculTugas
ScanSeq Scan, Index Scan, Index Only Scan, Bitmap Heap ScanMengambil baris dari satu tabel
JoinNested Loop, Hash Join, Merge JoinMenggabungkan dua sumber baris
SortSortMengurutkan baris (melayani ORDER BY, Merge Join, DISTINCT)
AggregateAggregate, HashAggregate, GroupAggregateMenghitung agregat, dengan atau tanpa GROUP BY
Lain-lainLimit, Unique, Result, WindowAgg, ModifyTable, Subquery ScanOperasi pelengkap seperti LIMIT, window function, dan INSERT/UPDATE/DELETE

Node scan dan node join mendapat chapter khusus masing-masing karena keduanya sumber biaya terbesar pada mayoritas query. Node Sort dan Aggregate sering bisa dihilangkan lewat index yang tepat atau lewat penulisan ulang query — topik yang juga akan kita sambung di akhir section.

Perkiraan Bukan Kenyataan #

Satu hal terakhir sebelum melangkah: EXPLAIN tidak menjalankan query. Seluruh angka rows= adalah perkiraan yang dihitung planner dari statistik tabel — bila statistiknya usang, perkiraannya meleset, dan rencana yang tadinya bagus bisa berubah buruk. Untuk melihat kenyataannya, PostgreSQL menyediakan EXPLAIN ANALYZE yang benar-benar mengeksekusi query lalu mencantumkan angka aktual: waktu nyata per node, jumlah baris sebenarnya, dan jumlah perulangan. Ada pula opsi BUFFERS yang menghitung halaman data yang disentuh — hit vs read — untuk melihat efek cache. Kedua alat itu dibedah tuntas pada dua chapter berikutnya.

Kita mulai dari perintahnya sendiri: sintaks lengkap EXPLAIN, arti satuan biaya, dan perbedaan mendasar antara baris Filter: dan Index Cond: yang sering membuat pemula keliru menyimpulkan index-nya tidak terpakai.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

buat-tabel.sql

Unduh berkas
sql
-- Domain latihan section ini: penjualan Toko Maju Jaya.
-- 5.000 pelanggan dan 30.000 order, dibangun dengan generate_series
-- agar datanya identik di setiap eksekusi.
CREATE TABLE pelanggan (
    id   integer PRIMARY KEY,
    nama text NOT NULL,
    kota text NOT NULL
);

INSERT INTO pelanggan (id, nama, kota)
SELECT g,
       'Pelanggan ' || lpad(g::text, 4, '0'),
       (ARRAY['Jakarta','Bandung','Surabaya','Medan','Denpasar'])[1 + (g - 1) / 1000]
FROM generate_series(1, 5000) AS g;

CREATE TABLE order_penjualan (
    id           bigint PRIMARY KEY,
    pelanggan_id integer NOT NULL REFERENCES pelanggan(id),
    tanggal      date NOT NULL,
    total        numeric(12,2) NOT NULL
);

INSERT INTO order_penjualan (id, pelanggan_id, tanggal, total)
SELECT g,
       1 + (g * 37) % 5000,
       DATE '2026-01-01' + (g % 180),
       ((g % 900) * 1000 + 25000)::numeric(12,2)
FROM generate_series(1, 30000) AS g;

ANALYZE pelanggan;
ANALYZE order_penjualan;

laporan.sql

Unduh berkas
sql
-- Laporan penjualan per kota: query yang akan kita bedah rencananya.
SELECT p.kota,
       count(*) AS jumlah_order,
       sum(o.total) AS nilai_penjualan
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota
ORDER BY jumlah_order DESC, p.kota;

rencana-lengkap.sql

Unduh berkas
sql
-- EXPLAIN lengkap dengan estimasi biaya, baris, dan lebar baris.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT p.kota,
       count(*) AS jumlah_order,
       sum(o.total) AS nilai_penjualan
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota
ORDER BY jumlah_order DESC, p.kota;

rencana-minimal.sql

Unduh berkas
sql
-- EXPLAIN (COSTS OFF): hanya pohon langkah, tanpa angka biaya.
-- Berguna untuk melihat bentuk rencana secara murni.
EXPLAIN (COSTS OFF)
SELECT p.kota,
       count(*) AS jumlah_order,
       sum(o.total) AS nilai_penjualan
FROM order_penjualan o
JOIN pelanggan p ON p.id = o.pelanggan_id
GROUP BY p.kota
ORDER BY jumlah_order DESC, p.kota;