“Query lambat? Buat index saja.” Nasihat itu benar separuhnya — dan separuhnya lagi menjadi sumber kekecewaan: banyak yang membuat index lalu bingung melihat EXPLAIN tetap menampilkan Seq Scan, lalu menyimpulkan index-nya gagal. Kenyataannya, sequential scan yang menolak index bukanlah kegagalan planner melainkan keputusan yang sering kali benar: bagi query yang membaca sebagian besar tabel, memindai seluruh halaman secara berurutan memang lebih murah daripada beribu-ribu lompatan acak lewat index. Chapter ini membongkar cara planner menimbang pilihan itu memakai satu konsep kunci — selectivity — pada tabel 50.000 order dengan tiga index yang semuanya siap dipakai.
-- Tabel 50.000 order. Kolom tanggal diisi monoton mengikuti urutan id,
-- kolom total tersebar acak -- dua pola fisik data yang berbeda.
CREATE TABLE order_penjualan (
id bigint PRIMARY KEY,
pelanggan_id integer NOT NULL,
tanggal date NOT NULL,
total numeric(12,2) NOT NULL,
status text NOT NULL
);
INSERT INTO order_penjualan (id, pelanggan_id, tanggal, total, status)
SELECT g,
1 + (g * 37) % 5000,
DATE '2026-01-01' + (g - 1) / 278,
((g % 900) * 1000 + 25000)::numeric(12,2),
(ARRAY['lunas','diproses','menunggu','batal'])[1 + g % 4]
FROM generate_series(1, 50000) AS g;
CREATE INDEX idx_order_pelanggan ON order_penjualan (pelanggan_id);
CREATE INDEX idx_order_total ON order_penjualan (total);
CREATE INDEX idx_order_tanggal ON order_penjualan (tanggal);
ANALYZE order_penjualan;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 50000
CREATE INDEX
CREATE INDEX
CREATE INDEX
ANALYZESelectivity: Fraksi Baris yang Dicari #
Selectivity adalah persentase baris tabel yang diperkirakan lolos kondisi WHERE — angka 1% berarti query mengembalikan 1 dari 100 baris. Inilah variabel terpenting dalam keputusan scan: index efektif ketika selectivity-nya kecil, dan semakin kecil semakin murah. Tiga query uji kita punya selectivity yang berbeda jauh:
-- Selectivity tiga query uji: berapa persen tabel yang dibaca masing-masing?
SELECT 'seminggu Februari (tanggal)' AS kasus,
count(*) AS baris,
round(100.0 * count(*) / (SELECT count(*) FROM order_penjualan), 2) AS persen
FROM order_penjualan
WHERE tanggal BETWEEN DATE '2026-02-10' AND DATE '2026-02-16'
UNION ALL
SELECT 'rentang harga menengah (total)',
count(*),
round(100.0 * count(*) / (SELECT count(*) FROM order_penjualan), 2)
FROM order_penjualan
WHERE total BETWEEN 25000 AND 200000
UNION ALL
SELECT 'harga di bawah 700 ribu (total)',
count(*),
round(100.0 * count(*) / (SELECT count(*) FROM order_penjualan), 2)
FROM order_penjualan
WHERE total < 700000
ORDER BY baris;Output yang Dihasilkan:
kasus | baris | persen
---------------------------------+-------+--------
seminggu Februari (tanggal) | 1946 | 3.89
rentang harga menengah (total) | 9855 | 19.71
harga di bawah 700 ribu (total) | 37625 | 75.25
(3 rows)Satu minggu transaksi memuat 3,89% baris; rentang harga menengah 19,71%; dan filter “harga di bawah 700 ribu” mencakup 75,25% tabel. Perencana memperkirakan angka-angka semacam ini dari statistik — bukan menghitungnya sungguhan — lalu menghitung biaya tiap kandidat scan berdasarkan perkiraan itu. Tiga query di atas akan mendapat tiga rencana berbeda dari planner yang sama.
Baris Sedikit pada Data Terurut: Index Scan #
Query pertama mencari order sepanjang satu minggu Februari — kurang dari 4% tabel. Karena kolom tanggal diisi mengikuti urutan penyimpanan (order pertama tanggal paling awal), baris-baris yang cocok menempel berdekatan di halaman tabel:
-- Query selektif pada data yang terurut: index scan murni menang.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT id, tanggal, total
FROM order_penjualan
WHERE tanggal BETWEEN DATE '2026-02-10' AND DATE '2026-02-16';Output yang Dihasilkan:
QUERY PLAN
----------------------------------------------------------------------------------------------
Index Scan using idx_order_tanggal on order_penjualan (cost=0.29..65.51 rows=1961 width=18)
Index Cond: ((tanggal >= '2026-02-10'::date) AND (tanggal <= '2026-02-16'::date))
(2 rows)Biaya totalnya hanya sekitar 65 — dibanding sequential scan yang selalu membayar penuh ±993 untuk tabel ini. Dua hal membuatnya semurah itu: Index Cond memangkas pencarian ke sekitar 2.000 entri index, dan karena tanggal terurut secara fisik, baris-baris hasilnya tinggal berurutan di beberapa halaman tabel yang bertetangga. planner membaca pola urutan ini dari statistik kolom (nilai correlation) — ketika data terurut, index scan menjadi jauh lebih manis.
Fraksi Menengah pada Data Acak: Bitmap Scan #
Query kedua memfilter rentang harga — juga sekitar seperlima tabel — tetapi kolom total tersebar acak di seluruh halaman. Index scan murni di sini berarti ribuan lompatan halaman yang tidak berurutan. Planner memilih jalan tengah:
-- Rentang menengah pada data tersebar acak: bitmap scan menjadi jalan tengah.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT count(status)
FROM order_penjualan
WHERE total BETWEEN 25000 AND 200000;Output yang Dihasilkan:
QUERY PLAN
------------------------------------------------------------------------------------------
Aggregate (cost=753.57..753.58 rows=1 width=8)
-> Bitmap Heap Scan on order_penjualan (cost=213.23..728.95 rows=9848 width=7)
Recheck Cond: ((total >= '25000'::numeric) AND (total <= '200000'::numeric))
-> Bitmap Index Scan on idx_order_total (cost=0.00..210.77 rows=9848 width=0)
Index Cond: ((total >= '25000'::numeric) AND (total <= '200000'::numeric))
(5 rows)Bitmap Index Scan tidak langsung melompat ke tabel. Ia membaca index lebih dulu dan menyusun peta bit: satu bit untuk setiap halaman tabel, menyala bila halaman itu memuat baris yang cocok. Setelah peta jadi, Bitmap Heap Scan mengunjungi halaman-halaman yang menyala secara berurutan nomornya — lompatan acak berubah menjadi perjalanan teratur. Baris Recheck Cond: menyimpan kondisi untuk diperiksa ulang; pada mode exact (peta bit per baris, seperti output di atas) pemeriksaan ulang biasanya tidak perlu dilakukan. Bila peta bit terlalu besar untuk work_mem, PostgreSQL beralih ke mode lossy: satu bit per halaman penuh, dan Recheck Cond sungguhan menguji ulang setiap baris di halaman yang menyala — inilah makna “lossy” yang sering muncul di EXPLAIN.
Hampir Semua Baris: Seq Scan #
Query ketiga membaca 75% tabel. Semua trik index kehilangan artinya:
-- Tiga perempat tabel: sequential scan menang meski index tersedia.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT count(status)
FROM order_penjualan
WHERE total < 700000;Output yang Dihasilkan:
QUERY PLAN
---------------------------------------------------------------------------
Aggregate (cost=1086.93..1086.94 rows=1 width=8)
-> Seq Scan on order_penjualan (cost=0.00..993.00 rows=37572 width=7)
Filter: (total < '700000'::numeric)
(3 rows)Aritmetikanya lugas: tabel ini berisi 368 halaman, dan membaca semuanya secara berurutan berbiaya 368 × 1 (seq_page_cost) ditambah 50.000 × 0,01 pemrosesan baris dan evaluasi filter — total 993. Untuk mendapat 37.500 baris lewat index, PostgreSQL harus menelan 37.500 entri index dan mengunjungi hampir seluruh halaman tabel juga (karena baris tersebar) — pekerjaan ganda tanpa penghematan. Ketika sebagian besar baris dicari, membaca semuanya sekali jalan memang termurah.
Membuktikan dengan Memaksa Index #
Kita bisa membuktikan penilaian planner itu secara terbalik: larang seq scan dan lihat berapa biaya kandidat berikutnya. Teknik SET enable_seqscan = off ini sudah dipakai di Section Index untuk memaksa index muncul pada tabel latihan kecil — sekarang kita pakai untuk mengukur harga sebuah keputusan yang buruk:
-- Teknik paksaan untuk membuktikan bahwa index tidak selalu menang:
-- matikan seq scan (lalu bitmap scan) pada query 75% baris.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
SET enable_seqscan = off;
-- Seq scan dilarang: planner beralih ke bitmap scan.
EXPLAIN
SELECT count(status)
FROM order_penjualan
WHERE total < 700000;
-- Bitmap scan juga dilarang: tersisa index scan murni.
SET enable_bitmapscan = off;
EXPLAIN
SELECT count(status)
FROM order_penjualan
WHERE total < 700000;Output yang Dihasilkan:
SET
QUERY PLAN
------------------------------------------------------------------------------------------
Aggregate (cost=1643.05..1643.06 rows=1 width=8)
-> Bitmap Heap Scan on order_penjualan (cost=711.47..1549.12 rows=37572 width=7)
Recheck Cond: (total < '700000'::numeric)
-> Bitmap Index Scan on idx_order_total (cost=0.00..702.08 rows=37572 width=0)
Index Cond: (total < '700000'::numeric)
(5 rows)
SET
QUERY PLAN
----------------------------------------------------------------------------------------------------
Aggregate (cost=2643.73..2643.74 rows=1 width=8)
-> Index Scan using idx_order_total on order_penjualan (cost=0.29..2549.80 rows=37572 width=7)
Index Cond: (total < '700000'::numeric)
(3 rows)Untuk query yang sama, tiga rencana kini terbanding lurus:
| Rencana | Biaya scan | Catatan |
|---|---|---|
| Seq Scan (pilihan planner) | ±993 | Membaca 368 halaman berurutan |
| Bitmap Heap Scan (dipaksa) | ±1549 | Membaca index penuh + hampir semua halaman |
| Index Scan (dipaksa) | ±2550 | 37.500 lompatan lewat index |
Biaya rencana paksaan 1,5 sampai 2,5 kali lipat lebih mahal — dan pada tabel sungguhan yang jauh lebih besar, rasionya kerap jauh lebih parah. Planner sudah menimbang ketiganya sebelum memilih; memaksa index pada query yang tidak selektif adalah cara pasti memperlambat query.
Peta Ringkas Empat Jenis Scan #
| Jenis scan | Cara kerja | Menang ketika |
|---|---|---|
Seq Scan | Membaca semua halaman tabel berurutan | Selectivity tinggi (sebagian besar baris dicari) atau tabel kecil |
Index Scan | Menuruni index lalu melompat ke baris tabel | Selectivity rendah; makin untung bila data terurut secara fisik |
Index Only Scan | Membaca index saja tanpa menyentuh tabel | Semua kolom query tersedia di index dan visibility map bersih |
Bitmap Heap Scan | Peta bit dari index, lalu kunjungan halaman terurut | Selectivity menengah pada data yang tersebar acak |
Jenis keempat, Index Only Scan, dibahas tuntas di chapter Covering Index pada Section Index — intinya query yang kolom-kolomnya seluruhnya tersedia di index tidak perlu menyentuh tabel sama sekali, sehingga biayanya turun drastis.
Sampai di sini semua contoh memindai satu tabel. Query nyata hampir selalu menggabungkan beberapa tabel — dan di sana planner menghadapi pilihan yang jauh lebih kaya: tiga strategi join dengan karakter biaya yang sama sekali berbeda. Itulah topik chapter berikutnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- Tabel 50.000 order. Kolom tanggal diisi monoton mengikuti urutan id,
-- kolom total tersebar acak -- dua pola fisik data yang berbeda.
CREATE TABLE order_penjualan (
id bigint PRIMARY KEY,
pelanggan_id integer NOT NULL,
tanggal date NOT NULL,
total numeric(12,2) NOT NULL,
status text NOT NULL
);
INSERT INTO order_penjualan (id, pelanggan_id, tanggal, total, status)
SELECT g,
1 + (g * 37) % 5000,
DATE '2026-01-01' + (g - 1) / 278,
((g % 900) * 1000 + 25000)::numeric(12,2),
(ARRAY['lunas','diproses','menunggu','batal'])[1 + g % 4]
FROM generate_series(1, 50000) AS g;
CREATE INDEX idx_order_pelanggan ON order_penjualan (pelanggan_id);
CREATE INDEX idx_order_total ON order_penjualan (total);
CREATE INDEX idx_order_tanggal ON order_penjualan (tanggal);
ANALYZE order_penjualan;
hitung-selektivitas.sql
Unduh berkas-- Selectivity tiga query uji: berapa persen tabel yang dibaca masing-masing?
SELECT 'seminggu Februari (tanggal)' AS kasus,
count(*) AS baris,
round(100.0 * count(*) / (SELECT count(*) FROM order_penjualan), 2) AS persen
FROM order_penjualan
WHERE tanggal BETWEEN DATE '2026-02-10' AND DATE '2026-02-16'
UNION ALL
SELECT 'rentang harga menengah (total)',
count(*),
round(100.0 * count(*) / (SELECT count(*) FROM order_penjualan), 2)
FROM order_penjualan
WHERE total BETWEEN 25000 AND 200000
UNION ALL
SELECT 'harga di bawah 700 ribu (total)',
count(*),
round(100.0 * count(*) / (SELECT count(*) FROM order_penjualan), 2)
FROM order_penjualan
WHERE total < 700000
ORDER BY baris;
paksa-index.sql
Unduh berkas-- Teknik paksaan untuk membuktikan bahwa index tidak selalu menang:
-- matikan seq scan (lalu bitmap scan) pada query 75% baris.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
SET enable_seqscan = off;
-- Seq scan dilarang: planner beralih ke bitmap scan.
EXPLAIN
SELECT count(status)
FROM order_penjualan
WHERE total < 700000;
-- Bitmap scan juga dilarang: tersisa index scan murni.
SET enable_bitmapscan = off;
EXPLAIN
SELECT count(status)
FROM order_penjualan
WHERE total < 700000;
rencana-hampir-semua.sql
Unduh berkas-- Tiga perempat tabel: sequential scan menang meski index tersedia.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT count(status)
FROM order_penjualan
WHERE total < 700000;
rencana-rentang.sql
Unduh berkas-- Rentang menengah pada data tersebar acak: bitmap scan menjadi jalan tengah.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT count(status)
FROM order_penjualan
WHERE total BETWEEN 25000 AND 200000;
rencana-selektif.sql
Unduh berkas-- Query selektif pada data yang terurut: index scan murni menang.
-- Angka biaya dapat sedikit berbeda di mesin Anda.
EXPLAIN
SELECT id, tanggal, total
FROM order_penjualan
WHERE tanggal BETWEEN DATE '2026-02-10' AND DATE '2026-02-16';