Query dunia nyata jarang menyaring satu kolom. Laporan penjualan paling sering berbunyi ganda: WHERE kota = 'Bandung' AND kategori = 'gold', WHERE status = 'aktif' AND tanggal BETWEEN .... Untuk pola seperti ini PostgreSQL menyediakan composite index — satu index yang menampung beberapa kolom sekaligus. Membuatnya gampang; yang menentukan index itu berguna atau sia-sia adalah satu keputusan kecil: urutan kolom. Chapter ini membedah aturan prefiks terkiri — konsep paling penting di seluruh topik index — beserta aturan penempatan kesetaraan sebelum rentang, semuanya pada tabel pelanggan 1000 baris yang dibangun ulang di sini:
-- Tabel contoh chapter ini (domain yang sama dengan chapter Konsep Index).
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
email text NOT NULL,
kota text NOT NULL,
kategori text NOT NULL,
saldo numeric(12,2) NOT NULL,
aktif boolean NOT NULL
);
INSERT INTO pelanggan (id, nama, email, kota, kategori, saldo, aktif)
SELECT
g,
'Pelanggan ' || lpad(g::text, 4, '0'),
'pelanggan' || lpad(g::text, 4, '0') || '@example.com',
(ARRAY['Jakarta','Bandung','Surabaya','Medan','Denpasar'])[1 + (g - 1) / 200],
(ARRAY['reguler','silver','gold'])[1 + g % 3],
((g - 1) * 1000 + 500)::numeric(12,2),
(g % 10 <> 0)
FROM generate_series(1, 1000) AS g;
ANALYZE pelanggan;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 1000
ANALYZESatu Index untuk Dua Kolom #
Composite index dibuat dengan menuliskan dua kolom atau lebih di dalam tanda kurung, dan urutannya tercatat resmi di catalog:
-- Index komposit: satu index B-tree atas dua kolom sekaligus,
-- urutan kolom menentukan urutan penyimpanan entri.
CREATE INDEX idx_pelanggan_kota_kategori ON pelanggan (kota, kategori);
-- pg_indexes mencatat daftar kolom beserta urutannya.
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'pelanggan'
ORDER BY indexname;Output yang Dihasilkan:
CREATE INDEX
indexname | indexdef
-----------------------------+-------------------------------------------------------------------------------------------
idx_pelanggan_kota_kategori | CREATE INDEX idx_pelanggan_kota_kategori ON public.pelanggan USING btree (kota, kategori)
pelanggan_pkey | CREATE UNIQUE INDEX pelanggan_pkey ON public.pelanggan USING btree (id)
(2 rows)Definisi USING btree (kota, kategori) menyatakan bahwa entri-entri index adalah pasangan nilai (kota, kategori) yang diurutkan kota lebih dulu, kategori menyusul. Cara mengurutkan ini persis seperti buku telepon yang mengurutkan berdasarkan nama belakang lalu nama depan: semua entri “Sanjaya” menggerombol di satu tempat, dan di dalam gerombolan itu nama depannya terurut. Ilustrasi urutan entri pada index kita — dengan data 1000 baris, lima kota, tiga kategori:
| Posisi entri | kota | kategori |
|---|---|---|
| 1-66 | Bandung | gold |
| 67-133 | Bandung | reguler |
| 134-200 | Bandung | silver |
| 201-267 | Jakarta | gold |
| … | … | … |
| 935-1000 | Surabaya | silver |
Perhatikan polanya: kolom pertama membentuk gerombolan besar yang berurutan, dan kolom kedua hanya terurut di dalam gerombolan kolom pertama. Seluruh perilaku index komposit — kegunaan maupun batasnya — mengalir dari bentuk urutan ini.
Aturan Prefiks Terkiri #
Karena pengurutan mengikuti kolom pertama, index komposit hanya dapat dicari dari kirinya. Tiga kombinasi kondisi pada tabel pelanggan menghasilkan tiga nasib yang berbeda — hitung dulu berapa baris yang disaring masing-masing:
-- Seberapa banyak baris disaring oleh tiap kombinasi kondisi?
SELECT count(*) AS kota_dan_kategori
FROM pelanggan
WHERE kota = 'Bandung' AND kategori = 'gold';
SELECT count(*) AS kota_saja
FROM pelanggan
WHERE kota = 'Bandung';
SELECT count(*) AS kategori_saja
FROM pelanggan
WHERE kategori = 'gold';Output yang Dihasilkan:
kota_dan_kategori
-------------------
66
(1 row)
kota_saja
-----------
200
(1 row)
kategori_saja
---------------
333
(1 row)Kombinasi lengkap menyaring 66 baris, kota sendirian menyaring 200 baris, dan kategori sendirian menyaring 333 baris. Sekarang kita ajukan ketiga query itu di bawah EXPLAIN:
-- Prefiks terkiri terpenuhi penuh: kedua kolom masuk kondisi index.
EXPLAIN
SELECT id, nama
FROM pelanggan
WHERE kota = 'Bandung' AND kategori = 'gold';
-- Prefiks terkiri terpenuhi sebagian: hanya kolom pertama yang terpakai.
EXPLAIN
SELECT id, nama
FROM pelanggan
WHERE kota = 'Bandung';
-- Prefiks terkiri tidak terpenuhi: index komposit tidak bisa dipakai.
EXPLAIN
SELECT id, nama
FROM pelanggan
WHERE kategori = 'gold';Output yang Dihasilkan:
QUERY PLAN
-------------------------------------------------------------------------------------------
Bitmap Heap Scan on pelanggan (cost=4.84..18.84 rows=67 width=19)
Recheck Cond: ((kota = 'Bandung'::text) AND (kategori = 'gold'::text))
-> Bitmap Index Scan on idx_pelanggan_kota_kategori (cost=0.00..4.82 rows=67 width=0)
Index Cond: ((kota = 'Bandung'::text) AND (kategori = 'gold'::text))
(4 rows)
QUERY PLAN
--------------------------------------------------------------------------------------------
Bitmap Heap Scan on pelanggan (cost=5.70..21.20 rows=200 width=19)
Recheck Cond: (kota = 'Bandung'::text)
-> Bitmap Index Scan on idx_pelanggan_kota_kategori (cost=0.00..5.65 rows=200 width=0)
Index Cond: (kota = 'Bandung'::text)
(4 rows)
QUERY PLAN
-------------------------------------------------------------
Seq Scan on pelanggan (cost=0.00..25.50 rows=333 width=19)
Filter: (kategori = 'gold'::text)
(2 rows)Mari kita baca ketiga rencana itu satu per satu:
- Rencana pertama —
Bitmap Index Scan on idx_pelanggan_kota_kategoridenganIndex Condyang memuat kedua kondisi: prefiks terkiri terpenuhi penuh, planner mendarat di gerombolanBandunglalu melompat ke bagiangolddi dalamnya. Dua kondisi menjadi dua penyaring di dalam index. - Rencana kedua — index yang sama terpakai, tetapi
Index Condhanya memuatkota = 'Bandung': prefiks terkiri terpenuhi sebagian. Index tetap berharga karena gerombolanBandungyang berurutan ditemukan sekali jalan, lalu 200 barisnya diambil; kondisikategoritidak ada sehingga tidak ada penyaringan kedua. - Rencana ketiga —
Seq Scan on pelanggan: prefiks terkiri tidak terpenuhi sama sekali. Mencarikategori = 'gold'pada index yang terurut(kota, kategori)sama mustahilnya dengan mencari semua orang bernama depan “Ahmad” di buku telepon yang diurutkan berdasarkan nama belakang — entri gold terpencar di lima gerombolan kota, dan satu-satunya cara menemukannya adalah memeriksa seluruh index. Planner memilih langsung memindai tabelnya.
Urutan Kolom: Kesetaraan Dulu, Rentang Belakangan #
Aturan kedua composite index menjawab pertanyaan: bila satu kolom dicari dengan kesetaraan dan kolom lain dengan rentang, mana yang ditaruh depan? Jawabnya: kolom kesetaraan di depan, kolom rentang di belakang. Alasannya kembali ke bentuk pengurutan: kesetaraan pada kolom pertama mengunci satu gerombolan, dan rentang pada kolom kedua menjadi jalan berjalan di dalam gerombolan itu. Kalau urutannya dibalik, rentang pada kolom pertama memaksa planner membuka banyak gerombolan, dan kondisi kesetaraan kolom kedua hanya menjadi penyaring tambahan.
Index (kota, saldo) dibuat mengikuti aturan itu:
-- Index komposit kedua, mengikuti aturan "kesetaraan di depan,
-- rentang di belakang": kota dicari dengan =, saldo dengan rentang.
CREATE INDEX idx_pelanggan_kota_saldo ON pelanggan (kota, saldo);Output yang Dihasilkan:
CREATE INDEXQuery berikut mencari pelanggan Bandung dengan saldo di jendela tertentu:
-- Kesetaraan pada kolom pertama, rentang pada kolom kedua:
-- keduanya masuk Index Cond karena urutan kolom mengikuti aturan
-- "kesetaraan di depan, rentang di belakang".
EXPLAIN
SELECT id, nama, saldo
FROM pelanggan
WHERE kota = 'Bandung' AND saldo BETWEEN 250000 AND 260000;Output yang Dihasilkan:
QUERY PLAN
------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on pelanggan (cost=4.30..9.98 rows=2 width=25)
Recheck Cond: ((kota = 'Bandung'::text) AND (saldo >= '250000'::numeric) AND (saldo <= '260000'::numeric))
-> Bitmap Index Scan on idx_pelanggan_kota_saldo (cost=0.00..4.30 rows=2 width=0)
Index Cond: ((kota = 'Bandung'::text) AND (saldo >= '250000'::numeric) AND (saldo <= '260000'::numeric))
(4 rows)Index Cond pada rencana itu memuat ketiga kondisi sekaligus: kota = 'Bandung', saldo >= 250000, dan saldo <= 260000 — bukti bahwa kedua kolom benar-benar bekerja sebagai kondisi pencarian, bukan sekadar filter. Sebagai catatan kecil, taksiran rows=2 di rencana itu lebih kecil daripada jumlah baris sebenarnya (10 baris): planner menebak sebaran dua kolom seolah-olah saling bebas, padahal di data kita saldo naik teratur mengikuti id sehingga jendela saldo tertentu hanya menyentuh satu kota. Selisih taksiran seperti ini dibahas lebih jauh di section Query Optimization.
Panduan memilih urutan dapat dirangkum dalam tabel:
| Pola query paling sibuk | Urutan kolom yang tepat |
|---|---|
WHERE a = ? AND b = ? | (a, b) atau (b, a) — keduanya setara |
WHERE a = ? AND b BETWEEN ? | (a, b) — kesetaraan dulu |
WHERE a = ? sendirian, b jarang dicari sendiri | (a, ...) — kolom pencarian mandiri di depan |
WHERE b = ? sendirian paling sibuk | butuh index terpisah pada b |
Biaya Kolom Kedua #
Setiap kolom tambahan pada index menambah lebar entri — tetapi tidak selalu menambah ukuran index. Bandingkan empat index pada tabel kita:
-- Dua index pembanding tambahan.
CREATE INDEX idx_pelanggan_kota ON pelanggan (kota);
-- Pembanding: index satu kolom yang sama dengan deduplication dimatikan.
CREATE INDEX idx_pelanggan_kota_nodedup ON pelanggan (kota) WITH (deduplicate_items = off);
SELECT pg_size_pretty(pg_relation_size('idx_pelanggan_kota')) AS satu_kolom,
pg_size_pretty(pg_relation_size('idx_pelanggan_kota_kategori')) AS kota_kategori,
pg_size_pretty(pg_relation_size('idx_pelanggan_kota_saldo')) AS kota_saldo,
pg_size_pretty(pg_relation_size('idx_pelanggan_kota_nodedup')) AS tanpa_dedup;Output yang Dihasilkan:
CREATE INDEX
CREATE INDEX
satu_kolom | kota_kategori | kota_saldo | tanpa_dedup
------------+---------------+------------+-------------
16 kB | 16 kB | 48 kB | 48 kB
(1 row)Dua temuan menarik dari kolom ukuran itu. Pertama, index (kota, kategori) berukuran sama dengan index (kota) sendirian: 16 kB. Sejak PostgreSQL 13, B-tree melakukan deduplication — nilai-nilai kota yang sama berulang kali dikompresi menjadi satu entri dengan daftar ctid, sehingga duplikasi berat pada kolom pertama nyaris tidak memakan ruang ekstra. Pembanding tanpa_dedup membuktikannya: index satu kolom yang sama dengan deduplication dimatikan membengkak dari 16 kB ke 48 kB. Kedua, menambahkan kolom saldo yang bernilai unik di setiap baris memang menaikkan ukuran menjadi 48 kB — pasangan (kota, saldo) hampir selalu berbeda sehingga tidak ada duplikasi untuk dikompresi. Jadi “kolom kedua gratis saat nilainya banyak duplikat, mahal saat nilainya unik”.
Index komposit memberi kita index yang melayani banyak pola sekaligus. Masih ada satu wajah index yang belum dibahas — wajah yang mengubahnya dari alat percepatan menjadi penjaga integritas data. CREATE UNIQUE INDEX dan seluruh keluarganya menanti di chapter Unique Index.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-index-composite.sql
Unduh berkas-- Index komposit: satu index B-tree atas dua kolom sekaligus,
-- urutan kolom menentukan urutan penyimpanan entri.
CREATE INDEX idx_pelanggan_kota_kategori ON pelanggan (kota, kategori);
-- pg_indexes mencatat daftar kolom beserta urutannya.
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'pelanggan'
ORDER BY indexname;
buat-index-urutan.sql
Unduh berkas-- Index komposit kedua, mengikuti aturan "kesetaraan di depan,
-- rentang di belakang": kota dicari dengan =, saldo dengan rentang.
CREATE INDEX idx_pelanggan_kota_saldo ON pelanggan (kota, saldo);
buat-tabel.sql
Unduh berkas-- Tabel contoh chapter ini (domain yang sama dengan chapter Konsep Index).
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
email text NOT NULL,
kota text NOT NULL,
kategori text NOT NULL,
saldo numeric(12,2) NOT NULL,
aktif boolean NOT NULL
);
INSERT INTO pelanggan (id, nama, email, kota, kategori, saldo, aktif)
SELECT
g,
'Pelanggan ' || lpad(g::text, 4, '0'),
'pelanggan' || lpad(g::text, 4, '0') || '@example.com',
(ARRAY['Jakarta','Bandung','Surabaya','Medan','Denpasar'])[1 + (g - 1) / 200],
(ARRAY['reguler','silver','gold'])[1 + g % 3],
((g - 1) * 1000 + 500)::numeric(12,2),
(g % 10 <> 0)
FROM generate_series(1, 1000) AS g;
ANALYZE pelanggan;
explain-prefix.sql
Unduh berkas-- Prefiks terkiri terpenuhi penuh: kedua kolom masuk kondisi index.
EXPLAIN
SELECT id, nama
FROM pelanggan
WHERE kota = 'Bandung' AND kategori = 'gold';
-- Prefiks terkiri terpenuhi sebagian: hanya kolom pertama yang terpakai.
EXPLAIN
SELECT id, nama
FROM pelanggan
WHERE kota = 'Bandung';
-- Prefiks terkiri tidak terpenuhi: index komposit tidak bisa dipakai.
EXPLAIN
SELECT id, nama
FROM pelanggan
WHERE kategori = 'gold';
explain-urutan.sql
Unduh berkas-- Kesetaraan pada kolom pertama, rentang pada kolom kedua:
-- keduanya masuk Index Cond karena urutan kolom mengikuti aturan
-- "kesetaraan di depan, rentang di belakang".
EXPLAIN
SELECT id, nama, saldo
FROM pelanggan
WHERE kota = 'Bandung' AND saldo BETWEEN 250000 AND 260000;
hitung-baris.sql
Unduh berkas-- Seberapa banyak baris disaring oleh tiap kombinasi kondisi?
SELECT count(*) AS kota_dan_kategori
FROM pelanggan
WHERE kota = 'Bandung' AND kategori = 'gold';
SELECT count(*) AS kota_saja
FROM pelanggan
WHERE kota = 'Bandung';
SELECT count(*) AS kategori_saja
FROM pelanggan
WHERE kategori = 'gold';
ukuran-index.sql
Unduh berkas-- Dua index pembanding tambahan.
CREATE INDEX idx_pelanggan_kota ON pelanggan (kota);
-- Pembanding: index satu kolom yang sama dengan deduplication dimatikan.
CREATE INDEX idx_pelanggan_kota_nodedup ON pelanggan (kota) WITH (deduplicate_items = off);
SELECT pg_size_pretty(pg_relation_size('idx_pelanggan_kota')) AS satu_kolom,
pg_size_pretty(pg_relation_size('idx_pelanggan_kota_kategori')) AS kota_kategori,
pg_size_pretty(pg_relation_size('idx_pelanggan_kota_saldo')) AS kota_saldo,
pg_size_pretty(pg_relation_size('idx_pelanggan_kota_nodedup')) AS tanpa_dedup;