Range membagi data berdasarkan urutan, list membagi berdasarkan daftar nama — keduanya sama-sama mengandaikan kita tahu struktur nilainya. Bagaimana dengan tabel yang partition key-nya tidak bermakna secara urutan maupun kategori, misalnya id peristiwa mutasi stok di gudang Toko Maju Jaya yang nilainya acak dan tak beraturan? Untuk itu PostgreSQL menyediakan hash partitioning: nilai partition key di-hash dengan fungsi hash internal, lalu sisa pembagiannya menentukan partisi tujuan. Tujuannya bukan mengelompokkan baris yang mirip, melainkan menyebarkan baris merata sehingga beban tulis dan ukuran tabel terbagi ke beberapa tabel fisik. Chapter ini membahas mekanisme MODULUS/REMAINDER, membuktikan penyebaran merata dengan data nyata, batasiannya untuk query rentang, hingga pola memecah partisi hash yang sudah penuh.

Sintaks PARTITION BY HASH #

Partisi hash dideklarasikan dengan PARTITION BY HASH (kolom), dan setiap partisi menentukan dua angka: MODULUS (jumlah total keranjang pembagi) dan REMAINDER (sisa bagi yang ditangani partisi itu). Satu baris diarahkan ke partisi yang REMAINDER-nya sama dengan hasil hash(nilai_kunci) MOD modulus:

sql buat-tabel.sql
-- Tabel induk dengan partition key id (tipe bigint), strategi hash.
CREATE TABLE log_inventori (
    id     bigint NOT NULL,
    barang text   NOT NULL,
    jumlah integer NOT NULL
) PARTITION BY HASH (id);

-- MODULUS 4 = jumlah keranjang; REMAINDER = sisa pembagian hash.
CREATE TABLE log_inventori_0 PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE log_inventori_1 PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE log_inventori_2 PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE log_inventori_3 PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

-- INSERT lewat induk; hash id menentukan partisi tujuan.
INSERT INTO log_inventori VALUES
    (1, 'Kopi Hitam 200g', 120),
    (2, 'Teh Melati 100g',  80),
    (3, 'Gula Pasir 1kg',  45),
    (4, 'Beras Premium 5kg', 30);

SELECT id, barang, jumlah, tableoid::regclass AS lokasi_fisik
FROM log_inventori
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 4
 id |      barang       | jumlah |  lokasi_fisik   
----+-------------------+--------+-----------------
  1 | Kopi Hitam 200g   |    120 | log_inventori_0
  2 | Teh Melati 100g   |     80 | log_inventori_2
  3 | Gula Pasir 1kg    |     45 | log_inventori_1
  4 | Beras Premium 5kg |     30 | log_inventori_3
(4 rows)

Mari kita bedah bagian-bagiannya:

  1. Baris PARTITION BY HASH (id): nilai id tiap baris di-hash lebih dulu; hasil hash — bukan nilai aslinya — yang dibagi untuk menentukan partisi.
  2. Baris FOR VALUES WITH (MODULUS 4, REMAINDER 0): partisi log_inventori_0 menampung semua baris yang hash id-nya habis dibagi 4 sisanya 0. Tiga partisi lainnya masing-masing mengambil sisa 1, 2, dan 3.
  3. Kolom lokasi_fisik: keempat baris uji mendarat di empat partisi yang berbeda — id 1 ke partisi 0, id 2 ke partisi 2, id 3 ke partisi 1, id 4 ke partisi 3. Tidak ada pola urutan yang bisa kita pilih sendiri; hash yang memutuskan.

Kita tidak bisa memprediksi partisi sebuah nilai tanpa menghitung hashnya — dan justru itulah intinya. Fungsi hash yang baik mengubah nilai yang berdekatan menjadi hasil yang tersebar, sehingga data acak apa pun terbagi rata tanpa campur tangan kita.

Penyebaran Merata dalam Praktik #

JANJI utama hash partitioning adalah keseimbangan. Kita buktikan dengan 1.000 peristiwa mutasi stok tambahan bernomor urut 5 sampai 1004:

sql distribusi.sql
-- Seribu peristiwa mutasi stok untuk mengukur penyebaran hash.
INSERT INTO log_inventori
SELECT g,
       'barang-' || (g % 10),
       (g % 7) - 3
FROM generate_series(5, 1004) AS g;

SELECT tableoid::regclass AS partisi, count(*) AS baris
FROM log_inventori
GROUP BY 1
ORDER BY 1;

SELECT count(*) AS total_baris FROM log_inventori;

-- Statistik diisi sekarang agar rencana eksekusi berikutnya stabil.
ANALYZE log_inventori;

Output yang Dihasilkan:

text
INSERT 0 1000
     partisi     | baris 
-----------------+-------
 log_inventori_0 |   260
 log_inventori_1 |   235
 log_inventori_2 |   277
 log_inventori_3 |   232
(4 rows)

 total_baris 
-------------
        1004
(1 row)

ANALYZE

Dari 1.004 baris total, keempat partisi menampung 260, 235, 277, dan 232 baris — penyimpangan terbesar hanya sekitar 9% dari rata-rata 251. Nomor urut yang berturut-turut tidak menggerombol di satu partisi karena hash mengacaknya lebih dulu. Bandingkan dengan range partitioning pada id: partisi id 1–250 akan menampung hampir seperempat baris secara berurutan, dan partisi terakhir kosong jika data belum sampai ke sana. Hash juga tidak pernah “habis rentang” — semua nilai masuk ke salah satu keranjang yang ada. ANALYZE di akhir file mengisi statistik keempat partisi agar rencana eksekusi pada contoh berikutnya stabil.

Kesamaan Bisa Dipangkas, Rentang Tidak #

Karena pembagian dilakukan atas hasil hash, posisi partisi tidak menginformasikan urutan nilai asli. Konsekuensinya terlihat langsung pada rencana eksekusi:

-- Pencarian kesamaan: hanya satu partisi yang disentuh planner.
EXPLAIN SELECT id, barang, jumlah
FROM log_inventori
WHERE id = 500;

-- Pencarian rentang: hash tidak punya makna urutan,
-- semua partisi harus diperiksa.
EXPLAIN SELECT count(*)
FROM log_inventori
WHERE id > 500;

Output yang Dihasilkan:

text
                                  QUERY PLAN                                  
------------------------------------------------------------------------------
 Seq Scan on log_inventori_3 log_inventori  (cost=0.00..4.90 rows=1 width=21)
   Filter: (id = 500)
(2 rows)

                                         QUERY PLAN                                          
---------------------------------------------------------------------------------------------
 Aggregate  (cost=24.32..24.33 rows=1 width=8)
   ->  Append  (cost=0.00..23.06 rows=502 width=0)
         ->  Seq Scan on log_inventori_0 log_inventori_1  (cost=0.00..5.25 rows=120 width=0)
               Filter: (id > 500)
         ->  Seq Scan on log_inventori_1 log_inventori_2  (cost=0.00..4.94 rows=114 width=0)
               Filter: (id > 500)
         ->  Seq Scan on log_inventori_2 log_inventori_3  (cost=0.00..5.46 rows=147 width=0)
               Filter: (id > 500)
         ->  Seq Scan on log_inventori_3 log_inventori_4  (cost=0.00..4.90 rows=121 width=0)
               Filter: (id > 500)
(10 rows)

Rencana pertama, query WHERE id = 500, hanya menyentuh satu partisi: planner menghitung hash nilai 500, menemukan sisa baginya, lalu membuang tiga partisi lainnya dari rencana — mekanisme partition pruning yang sama dengan range dan list. Rencana kedua, query WHERE id > 500, terpaksa memeriksa keempat partisi lewat node Append: tidak ada partisi yang bisa dibuang karena nilai besar maupun kecil tersebar di semua keranjang. Inilah batasan mendasar hash partitioning — hanya kondisi kesamaan (=) dan daftar nilai (IN) yang memanfaatkan pemangkasan; rentang (BETWEEN, <, >) selalu menyapu semua partisi.

Memecah Partisi Hash yang Membengkak #

Jumlah partisi hash tidak bisa ditambah seenaknya di tengah jalan: semua REMAINDER harus menghabiskan MODULUS yang sama agar tidak ada celah. Pola yang benar untuk memperhalus pembagian adalah mengganti satu partisi MODULUS 4 dengan dua partisi MODULUS 8 yang sisa baginya mencakup baris lama secara persis:

-- Pola produksi memecah satu partisi hash menjadi dua yang lebih halus:
-- lepas partisi lama (MODULUS 4, REMAINDER 0)...
ALTER TABLE log_inventori DETACH PARTITION log_inventori_0;

-- ...lalu ganti dengan dua partisi MODULUS 8 yang sisa bagiannya
-- bersama-sama mencakup tepat baris-baris partisi lama.
CREATE TABLE log_inventori_0a PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE log_inventori_0b PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 8, REMAINDER 4);

-- Baris dari tabel lepasan dialirkan ulang lewat induk.
INSERT INTO log_inventori
SELECT id, barang, jumlah FROM log_inventori_0;

SELECT tableoid::regclass AS partisi, count(*) AS baris
FROM log_inventori
GROUP BY 1
ORDER BY 1;

SELECT count(*) AS total_baris FROM log_inventori;

Output yang Dihasilkan:

text
ALTER TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 260
     partisi      | baris 
------------------+-------
 log_inventori_1  |   235
 log_inventori_2  |   277
 log_inventori_3  |   232
 log_inventori_0a |   124
 log_inventori_0b |   136
(5 rows)

 total_baris 
-------------
        1004
(1 row)

Urutan kerjanya penting. DETACH PARTITION melepas log_inventori_0 menjadi tabel biasa berisi 260 baris — barisnya tidak hilang, tetapi sementara tidak terbaca lewat induk. Dua partisi pengganti dibuat dengan MODULUS 8, REMAINDER 0 dan MODULUS 8, REMAINDER 4; secara aritmetika, kedua sisa itu bersama-sama mencakup tepat himpunan baris yang sebelumnya jatuh ke sisa 0 (hash habis dibagi 4). INSERT ... SELECT dari tabel lepasan mengalirkan kembali 260 baris lewat induk, dan rekap akhir menunjukkan 124 + 136 = 260 baris berpindah ke dua partisi baru dengan total tabel tetap 1.004. Tabel lepasan log_inventori_0 boleh dijatuhkan setelah barisnya terverifikasi pindah.

Kapan Hash Tepat — dan Kapan Tidak #

Hash partitioning dipilih untuk alasan distribusi beban, bukan untuk pengelompokan logis. Panduan ringkasnya:

SituasiHash cocok?Alasan
tabel transaksi bernilai acak, tulis sangat padatyabeban tulis terbagi merata ke beberapa tabel fisik
tidak ada kolom yang bermakna sebagai rentang/daftaryahash tidak butuh struktur nilai
tabel yang harus dibuat VACUUM-nya lebih ringanyavacuum berjalan per partisi kecil
query dominan per rentang waktutidaksemua partisi tersapu tiap query
butuh memindahkan data lama secara fisiktidaktidak ada partisi “lama” vs “baru”
nilai kunci berjumlah sangat sedikit (mis. status)tidakgunakan list; hash hanya menyebar, tidak mengelompokkan

Tiga strategi pembagian kini lengkap kita kuasai. Pertanyaan berikutnya bukan lagi cara membagi, melainkan cara memastikan query tetap cepat setelah tabel dipecah — dan di situlah partition pruning menjadi penentu, 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 induk dengan partition key id (tipe bigint), strategi hash.
CREATE TABLE log_inventori (
    id     bigint NOT NULL,
    barang text   NOT NULL,
    jumlah integer NOT NULL
) PARTITION BY HASH (id);

-- MODULUS 4 = jumlah keranjang; REMAINDER = sisa pembagian hash.
CREATE TABLE log_inventori_0 PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE log_inventori_1 PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE log_inventori_2 PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE log_inventori_3 PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

-- INSERT lewat induk; hash id menentukan partisi tujuan.
INSERT INTO log_inventori VALUES
    (1, 'Kopi Hitam 200g', 120),
    (2, 'Teh Melati 100g',  80),
    (3, 'Gula Pasir 1kg',  45),
    (4, 'Beras Premium 5kg', 30);

SELECT id, barang, jumlah, tableoid::regclass AS lokasi_fisik
FROM log_inventori
ORDER BY id;

distribusi.sql

Unduh berkas
sql
-- Seribu peristiwa mutasi stok untuk mengukur penyebaran hash.
INSERT INTO log_inventori
SELECT g,
       'barang-' || (g % 10),
       (g % 7) - 3
FROM generate_series(5, 1004) AS g;

SELECT tableoid::regclass AS partisi, count(*) AS baris
FROM log_inventori
GROUP BY 1
ORDER BY 1;

SELECT count(*) AS total_baris FROM log_inventori;

-- Statistik diisi sekarang agar rencana eksekusi berikutnya stabil.
ANALYZE log_inventori;

pecah-partisi.sql

Unduh berkas
sql
-- Pola produksi memecah satu partisi hash menjadi dua yang lebih halus:
-- lepas partisi lama (MODULUS 4, REMAINDER 0)...
ALTER TABLE log_inventori DETACH PARTITION log_inventori_0;

-- ...lalu ganti dengan dua partisi MODULUS 8 yang sisa bagiannya
-- bersama-sama mencakup tepat baris-baris partisi lama.
CREATE TABLE log_inventori_0a PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE log_inventori_0b PARTITION OF log_inventori
    FOR VALUES WITH (MODULUS 8, REMAINDER 4);

-- Baris dari tabel lepasan dialirkan ulang lewat induk.
INSERT INTO log_inventori
SELECT id, barang, jumlah FROM log_inventori_0;

SELECT tableoid::regclass AS partisi, count(*) AS baris
FROM log_inventori
GROUP BY 1
ORDER BY 1;

SELECT count(*) AS total_baris FROM log_inventori;

semantik-query.sql

Unduh berkas
sql
-- Pencarian kesamaan: hanya satu partisi yang disentuh planner.
EXPLAIN SELECT id, barang, jumlah
FROM log_inventori
WHERE id = 500;

-- Pencarian rentang: hash tidak punya makna urutan,
-- semua partisi harus diperiksa.
EXPLAIN SELECT count(*)
FROM log_inventori
WHERE id > 500;