B-tree mengurutkan entri-entrinya dengan susah payah — membelah halaman, menyeimbangkan pohon, menyambungkan daun — demi melayani rentang dan ORDER BY. Namun banyak query di dunia nyata tidak membutuhkan semua itu: WHERE email = '...', WHERE token = '...', WHERE kode_produk = '...' — kesetaraan murni, satu nilai dicari persis. Untuk pekerjaan sempit ini PostgreSQL menyediakan index hash: struktur tanpa urutan yang menukar kemampuan rentang dengan ukuran lebih ramping dan pencarian satu langkah. Chapter ini menguji index hash pada tabel pelanggan 1000 baris, membuktikan apa yang dilayaninya, apa yang sama sekali tidak disentuhnya, dan kapan ia layak dipilih. Tabel contohnya dibangun ulang di sini agar chapter ini berdiri sendiri:

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

text
CREATE TABLE
INSERT 0 1000
ANALYZE

Giling Nilai, Dapat Ember #

Mekanisme hash index dapat diringkas dalam tiga langkah. Pertama, PostgreSQL menghitung hash code dari nilai yang akan dicari — angka 4 byte hasil fungsi hash yang selalu sama untuk nilai yang sama. Kedua, angka itu menentukan satu bucket (ember) tujuan, dan pencarian langsung menuju ember itu — tanpa menuruni pohon berlapis seperti B-tree. Ketiga, di dalam ember, entri-entri dibandingkan berdasarkan hash code-nya, lalu baris heap diambil lewat ctid untuk memastikan nilainya benar-benar sama (dua nilai berbeda bisa saja berhash sama, peristiwa collision).

Konsekuensi desain ini lurus dan tegas. Karena entri tidak diurutkan, tidak ada konsep “lebih besar dari” atau “kurang dari”; tidak ada titik mendarat untuk rentang; tidak ada urutan keluaran untuk ORDER BY; dan pola LIKE 'awalan%' pun tidak terlayani. Yang dilayani satu-satunya adalah operator =.

Membuat dan Menggunakannya #

Sintaksnya sama dengan CREATE INDEX biasa, hanya bagian USING diisi hash. Dua index berikut menempel pada kolom email dan saldo:

-- Index hash pertama: kesetaraan email.
CREATE INDEX idx_pelanggan_email_hash ON pelanggan USING hash (email);

-- Index hash kedua pada saldo, untuk menguji query rentang di bawah.
CREATE INDEX idx_pelanggan_saldo_hash ON pelanggan USING hash (saldo);

-- pg_indexes mencatat tipe hash secara eksplisit pada indexdef.
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'pelanggan'
ORDER BY indexname;

Output yang Dihasilkan:

text
CREATE INDEX
CREATE INDEX
        indexname         |                                   indexdef                                   
--------------------------+------------------------------------------------------------------------------
 idx_pelanggan_email_hash | CREATE INDEX idx_pelanggan_email_hash ON public.pelanggan USING hash (email)
 idx_pelanggan_saldo_hash | CREATE INDEX idx_pelanggan_saldo_hash ON public.pelanggan USING hash (saldo)
 pelanggan_pkey           | CREATE UNIQUE INDEX pelanggan_pkey ON public.pelanggan USING btree (id)
(3 rows)

Output pg_indexes memperlihatkan USING hash (email) dan USING hash (saldo) — bandingkan dengan index pelanggan_pkey yang memakai USING btree. Query pencarian satu email kemudian berjalan seperti biasa:

sql query-sama.sql
-- Kesetaraan murni: satu email menunjuk satu pelanggan.
SELECT id, nama, kota
FROM pelanggan
WHERE email = '[email protected]';

Output yang Dihasilkan:

text
 id |      nama      |  kota   
----+----------------+---------
 50 | Pelanggan 0050 | Jakarta
(1 row)

Satu baris kembali. Rencana di baliknya membuktikan index hash benar-benar terpakai:

-- Rencana kesetaraan lewat index hash.
EXPLAIN
SELECT id, nama, kota
FROM pelanggan
WHERE email = '[email protected]';

Output yang Dihasilkan:

text
                                        QUERY PLAN                                         
-------------------------------------------------------------------------------------------
 Index Scan using idx_pelanggan_email_hash on pelanggan  (cost=0.00..8.02 rows=1 width=27)
   Index Cond: (email = '[email protected]'::text)
(2 rows)

Baris Index Scan using idx_pelanggan_email_hash menegaskan tipe index yang dipilih planner, dan Index Cond: (email = '[email protected]') menunjukkan kondisi kesetaraan menjadi kondisi pencarian. Perhatikan satu detail kecil yang khas hash: biaya awalnya 0.00 — tidak seperti B-tree yang memulai dari 0.28 karena harus menuruni pohon. Pencarian hash adalah lompatan tunggal ke satu bucket, tidak ada lapisan yang dilewati.

Kesetaraan Saja — Sisanya Seq Scan #

Sekarang sisi gelapnya. Index hash pada kolom saldo sudah dibuat; mari ajukan query rentang padanya:

-- Rentang tidak bisa dilayani hash: tidak ada urutan untuk dituruni.
-- Index hash pada saldo ada, tetapi planner memilih sequential scan.
EXPLAIN
SELECT id, nama
FROM pelanggan
WHERE saldo BETWEEN 420000 AND 440000;

Output yang Dihasilkan:

text
                                QUERY PLAN                                 
---------------------------------------------------------------------------
 Seq Scan on pelanggan  (cost=0.00..28.00 rows=21 width=19)
   Filter: ((saldo >= '420000'::numeric) AND (saldo <= '440000'::numeric))
(2 rows)

Rencananya Seq Scan on pelanggan dengan Filter — planner sama sekali mengabaikan idx_pelanggan_saldo_hash yang ada di catalog. Ini bukan kelalaian planner melainkan kemustahilan struktural: hash dari 420000 dan hash dari 440000 tidak mengatakan apa-apa tentang hash dari nilai-nilai di antara keduanya. Jangkauan layanan kedua jenis index itu dapat dirangkum sebaris:

Pola queryB-treeHash
= (kesetaraan)YaYa
<, <=, >, >=, BETWEENYaTidak
ORDER BY kolom indeksYaTidak
LIKE 'awalan%'YaTidak

Ukuran: Alasan Hash Masih Ada #

Jika layanannya se sempit itu, mengapa index hash masih dipertahankan? Jawabannya ada di penyimpanan. Entri B-tree menyimpan kunci utuh ([email protected], 25 karakter) plus ctid; entri hash hanya menyimpan kode hash 4 byte plus ctid. Semakin panjang nilainya, semakin lebar selisihnya. Bandingkan index hash dan B-tree kembar pada kolom yang sama:

-- Kembar B-tree pada kolom email yang sama, sebagai pembanding ukuran.
CREATE INDEX idx_pelanggan_email_btree ON pelanggan (email);

-- Entri hash hanya menyimpan kode hash 4 byte + ctid,
-- bukan kunci utuh + ctid seperti entri B-tree.
SELECT pg_size_pretty(pg_relation_size('idx_pelanggan_email_hash'))  AS index_hash,
       pg_size_pretty(pg_relation_size('idx_pelanggan_email_btree')) AS index_btree;

Output yang Dihasilkan:

text
CREATE INDEX
 index_hash | index_btree 
------------+-------------
 48 kB      | 72 kB
(1 row)

Index hash memakan 48 kB, B-tree pada kolom yang sama memakan 72 kB — selisih 50% untuk kunci sepanjang 25 karakter. Pada kolom bertipe UUID, URL panjang, atau token sesi, selisih relatifnya serupa dan selisih absolutnya membesar. Untuk tabel ratusan juta baris, struktur yang lebih kecil juga berarti lebih banyak entri muat di memori — dan halaman index yang tinggal di memori adalah halaman yang tidak perlu dibaca dari disk.

Aman Sejak PostgreSQL 10 #

Satu catatan sejarah menentukan reputasi index hash. Sebelum PostgreSQL 10, index hash tidak dicatat ke WAL — dan index yang tidak tercatat di WAL lenyap tidak keruan setelah crash server serta tidak ikut direplikasi ke standby. Karena itu nasihat lama di mana-mana mengatakan: hindari hash. Sejak PostgreSQL 10, index hash dicatat ke WAL seperti struktur lainnya — aman terhadap crash, ikut direplikasi, dan ikut di-backup. Dalam PostgreSQL 17 yang dipakai course ini, kekhawatiran lama itu sudah tidak berlaku.

Kapan Memilih Hash #

Keputusannya dapat diringkas dalam tabel kecil:

Kondisi AndaPilihan
Query hanya kesetaraan =, kunci panjang (UUID, URL, token), volume data besarHash layak dipertimbangkan
Ada kemungkinan butuh rentang, ORDER BY, atau LIKE 'awalan%' — sekarang atau nantiB-tree
Sedang raguB-tree — default yang hampir selalu cukup baik

Kebenaran praktisnya sederhana: hash jarang menjadi kebutuhan. Selisih ukuran baru terasa pada kunci panjang dan tabel besar, dan B-tree hampir selalu “cukup cepat” untuk kesetaraan juga. Hash adalah alat khusus untuk ceruk khusus — mengetahuinya memperluas kotak peralatan, bukan mengubah kebiasaan default.

Hingga kini semua index kita menempel pada satu kolom. Padahal kondisi pencarian nyata sering berbunyi ganda: WHERE kota = 'Bandung' AND kategori = 'gold'. Dapatkah satu index melayani dua kolom sekaligus — dan apa aturan mainnya? Chapter Composite Index menjawabnya dengan satu konsep kunci bernama aturan prefiks terkiri.

Referensi #

Berkas Sumber

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

buat-index-hash.sql

Unduh berkas
sql
-- Index hash pertama: kesetaraan email.
CREATE INDEX idx_pelanggan_email_hash ON pelanggan USING hash (email);

-- Index hash kedua pada saldo, untuk menguji query rentang di bawah.
CREATE INDEX idx_pelanggan_saldo_hash ON pelanggan USING hash (saldo);

-- pg_indexes mencatat tipe hash secara eksplisit pada indexdef.
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'pelanggan'
ORDER BY indexname;

buat-tabel.sql

Unduh berkas
sql
-- 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-rentang.sql

Unduh berkas
sql
-- Rentang tidak bisa dilayani hash: tidak ada urutan untuk dituruni.
-- Index hash pada saldo ada, tetapi planner memilih sequential scan.
EXPLAIN
SELECT id, nama
FROM pelanggan
WHERE saldo BETWEEN 420000 AND 440000;

explain-sama.sql

Unduh berkas
sql
-- Rencana kesetaraan lewat index hash.
EXPLAIN
SELECT id, nama, kota
FROM pelanggan
WHERE email = '[email protected]';

perbandingan-ukuran.sql

Unduh berkas
sql
-- Kembar B-tree pada kolom email yang sama, sebagai pembanding ukuran.
CREATE INDEX idx_pelanggan_email_btree ON pelanggan (email);

-- Entri hash hanya menyimpan kode hash 4 byte + ctid,
-- bukan kunci utuh + ctid seperti entri B-tree.
SELECT pg_size_pretty(pg_relation_size('idx_pelanggan_email_hash'))  AS index_hash,
       pg_size_pretty(pg_relation_size('idx_pelanggan_email_btree')) AS index_btree;

query-sama.sql

Unduh berkas
sql
-- Kesetaraan murni: satu email menunjuk satu pelanggan.
SELECT id, nama, kota
FROM pelanggan
WHERE email = '[email protected]';