“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.

sql buat-tabel.sql
-- 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:

text
CREATE TABLE
INSERT 0 50000
CREATE INDEX
CREATE INDEX
CREATE INDEX
ANALYZE

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

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

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

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

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

sql paksa-index.sql
-- 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:

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

RencanaBiaya scanCatatan
Seq Scan (pilihan planner)±993Membaca 368 halaman berurutan
Bitmap Heap Scan (dipaksa)±1549Membaca index penuh + hampir semua halaman
Index Scan (dipaksa)±255037.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 scanCara kerjaMenang ketika
Seq ScanMembaca semua halaman tabel berurutanSelectivity tinggi (sebagian besar baris dicari) atau tabel kecil
Index ScanMenuruni index lalu melompat ke baris tabelSelectivity rendah; makin untung bila data terurut secara fisik
Index Only ScanMembaca index saja tanpa menyentuh tabelSemua kolom query tersedia di index dan visibility map bersih
Bitmap Heap ScanPeta bit dari index, lalu kunjungan halaman terurutSelectivity 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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';