Toko Maju Jaya mencatat setiap transaksi ke tabel log_penjualan yang tumbuh 10 ribu baris per hari. Setelah tiga tahun berjalan, tabel tersebut menampung lebih dari 10 juta baris dalam satu blok penyimpanan fisik, dan hampir semua operasi menjadi lambat bersamaan: query laporan bulanan memindai jutaan baris yang tidak relevan, VACUUM membutuhkan waktu puluhan menit, dan setiap index yang dibangun berukuran ratusan megabyte. PostgreSQL menghadapi masalah ini dengan mekanisme partitioning: satu tabel logika dipecah menjadi beberapa tabel fisik yang lebih kecil, masing-masing menampung potongan data berdasarkan aturan yang kita tentukan. Chapter ini membahas konsep dasarnya — apa yang dipecah, bagaimana baris dialirkan, dan kapan pemecahan justru tidak perlu — sebelum tiga strategi pembagian dibahas tuntas di chapter-chapter berikutnya.

Masalah Tabel Raksasa #

Semua gejala tabel raksasa bermuara pada satu fakta fisik: satu tabel berarti satu tumpukan halaman 8 kB yang dibaca, di-vacuum, dan diindeks sebagai kesatuan. Semakin besar tumpukan itu, semakin mahal setiap operasi yang menyentuhnya sebagian pun. Ringkasan masalahnya tampak seperti ini:

GejalaPenyebab di tabel monolitikDampak operasional
Query laporan bulanan lambatSeq Scan atau index scan menyentuh seluruh tabellaporan harian makin lambat seiring umur tabel
VACUUM lamasatu tabel = satu antrian pembersihandead tuple menumpuk, pembengkakan balik terjadi
Index membengkaksetiap index menyimpan entri seluruh barisbuild index ulang memakan waktu dan ruang
Rotasi data sulitDELETE jutaan baris lama memicu lonjakan dead tupletabel tak pernah benar-benar mengecil
ANALYZE tidak segarstatistik dihitung dari seluruh tabel raksasaestimasi baris meleset, planner salah pilih rencana

Kita buktikan dulu bahwa masalahnya nyata dengan membangun model miniatur: 9.000 baris log penjualan selama tiga bulan pertama 2026 disimpan sebagai satu tabel monolitik biasa.

-- Cara lama: satu tabel monolitik menampung seluruh log penjualan
-- Toko Maju Jaya untuk Januari-Maret 2026 (9000 baris).
CREATE TABLE log_penjualan_monolitik (
    id      bigint NOT NULL,
    tanggal date NOT NULL,
    kota    text NOT NULL,
    total   numeric(12,2) NOT NULL
);

INSERT INTO log_penjualan_monolitik
SELECT g,
       DATE '2026-01-01' + (g % 90),
       'Kota-' || (g % 5),
       ((g * 37) % 1000 + 1) * 1000.00
FROM generate_series(1, 9000) AS g;

SELECT count(*) AS jumlah_baris,
       min(tanggal) AS tanggal_terawal,
       max(tanggal) AS tanggal_termuda,
       pg_size_pretty(pg_relation_size('log_penjualan_monolitik')) AS ukuran_tabel
FROM log_penjualan_monolitik;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 9000
 jumlah_baris | tanggal_terawal | tanggal_termuda | ukuran_tabel 
--------------+-----------------+-----------------+--------------
         9000 | 2026-01-01      | 2026-03-31      | 528 kB
(1 row)

Model ini sengaja kecil agar dapat dijalankan dalam hitungan detik, tetapi polanya identik dengan tabel produksi: satu tabel, satu tumpukan penyimpanan seluas 528 kB untuk 9.000 baris. Pada tabel 10 juta baris, angka yang sama menjadi sekitar 600 MB — dan setiap operasi yang kita bahas di tabel paragraf sebelumnya ikut membesar secara linear.

Satu Tabel Logika, Banyak Tabel Fisik #

Partitioning memutus asumsi “satu tabel = satu penyimpanan”. Kita mendeklarasikan sebuah tabel induk dengan klausa PARTITION BY yang menentukan partition key — kolom yang menjadi dasar pemecahan — lalu membuat partisi-partisi anak yang masing-masing memegang sepotong rentang nilai. Aplikasi tetap membaca dan menulis ke nama tabel induk seolah-olah tabel itu utuh; PostgreSQL yang bekerja di belakang layar mengalirkan setiap baris ke partisi yang tepat.

Diagram di atas memperlihatkan alur baris: INSERT ditujukan ke induk, nilai tanggal dievaluasi terhadap batas tiap partisi, dan baris mendarat di satu partisi saja. Contoh berikut membangun versi terpartisi dari data yang sama persis dengan tabel monolitik sebelumnya:

-- Cara terpartisi: tabel induk log_penjualan hanya menjadi kerangka logika;
-- baris disimpan pada partisi bulanan yang dibuat eksplisit.
CREATE TABLE log_penjualan (
    id      bigint NOT NULL,
    tanggal date NOT NULL,
    kota    text NOT NULL,
    total   numeric(12,2) NOT NULL
) PARTITION BY RANGE (tanggal);

CREATE TABLE log_penjualan_2026_01 PARTITION OF log_penjualan
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE log_penjualan_2026_02 PARTITION OF log_penjualan
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE log_penjualan_2026_03 PARTITION OF log_penjualan
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- INSERT tetap ditujukan ke induk; baris dialirkan ke partisi yang cocok.
INSERT INTO log_penjualan
SELECT g,
       DATE '2026-01-01' + (g % 90),
       'Kota-' || (g % 5),
       ((g * 37) % 1000 + 1) * 1000.00
FROM generate_series(1, 9000) AS g;

SELECT urut, tabel, baris FROM (
    SELECT 1 AS urut, 'induk log_penjualan' AS tabel,
           count(*) AS baris FROM log_penjualan
    UNION ALL
    SELECT 2, 'partisi 2026_01 (Januari)', count(*)
    FROM log_penjualan_2026_01
    UNION ALL
    SELECT 3, 'partisi 2026_02 (Februari)', count(*)
    FROM log_penjualan_2026_02
    UNION ALL
    SELECT 4, 'partisi 2026_03 (Maret)', count(*)
    FROM log_penjualan_2026_03
) AS rekap ORDER BY urut;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 9000
 urut |           tabel            | baris 
------+----------------------------+-------
    1 | induk log_penjualan        |  9000
    2 | partisi 2026_01 (Januari)  |  3100
    3 | partisi 2026_02 (Februari) |  2800
    4 | partisi 2026_03 (Maret)    |  3100
(4 rows)

Empat definisi tabel bekerja sama: satu induk dengan PARTITION BY RANGE (tanggal) dan tiga partisi dengan batas bulanan. Hasil query dari induk tetap 9.000 baris — tidak ada baris yang hilang atau dobel — tetapi penyebarannya 3.100 baris di Januari, 2.800 di Februari, dan 3.100 di Maret, sesuai jumlah hari tiap bulan. Baris INSERT 0 9000 juga membuktikan bahwa kita tidak pernah menyebut nama partisi saat memasukkan data; routing berjalan otomatis.

Routing Insert secara Otomatis #

Mekanisme routing dievaluasi per baris: PostgreSQL menghitung nilai partition key, mencari partisi yang batasnya memuat nilai tersebut, lalu memasukkan baris ke partisi itu. Jika tidak ada partisi yang cocok, INSERT ditolak dengan error — bukan diam-diam dibuang. Kolom sistem tableoid memungkinkan kita memeriksa ke mana baris benar-benar pergi:

-- Tiga transaksi baru dimasukkan lewat tabel induk.
INSERT INTO log_penjualan VALUES
    (9001, DATE '2026-01-31', 'Jakarta',   250000.00),
    (9002, DATE '2026-02-28', 'Bandung',   125000.00),
    (9003, DATE '2026-03-31', 'Surabaya',   99000.00);

-- Kolom sistem tableoid mengungkap tabel fisik tempat tiap baris disimpan.
SELECT id, tanggal, kota, total, tableoid::regclass AS lokasi_fisik
FROM log_penjualan
WHERE id >= 9001
ORDER BY id;

Output yang Dihasilkan:

text
INSERT 0 3
  id  |  tanggal   |   kota   |   total   |     lokasi_fisik      
------+------------+----------+-----------+-----------------------
 9001 | 2026-01-31 | Jakarta  | 250000.00 | log_penjualan_2026_01
 9002 | 2026-02-28 | Bandung  | 125000.00 | log_penjualan_2026_02
 9003 | 2026-03-31 | Surabaya |  99000.00 | log_penjualan_2026_03
(3 rows)

Ketiga baris dimasukkan lewat satu perintah INSERT ke log_penjualan, tanpa menyebut nama partisi. Hasilnya: transaksi 31 Januari tersimpan di log_penjualan_2026_01, transaksi 28 Februari di log_penjualan_2026_02, dan transaksi 31 Maret di log_penjualan_2026_03 — persis seperti arah panah pada diagram sebelumnya. Query pembaca juga tetap ditujukan ke induk; PostgreSQL menggabungkan hasil dari partisi-partisi secara transparan.

Induk Tidak Menyimpan Baris #

Induk pada declarative partitioning bukan tabel dengan data tambahan; induk murni katalog metadata. Bukti paling langsung dilihat dari katalog pg_class: induk bertipe relkind = 'p' (partitioned table) dengan ukuran penyimpanan nol, sementara setiap partisi adalah tabel biasa (relkind = 'r') dengan heap sendiri.

-- Induk punya relkind 'p' (partitioned table), partisi punya 'r' (tabel biasa).
SELECT relname, relkind,
       pg_size_pretty(pg_relation_size(oid)) AS ukuran_penyimpanan
FROM pg_class
WHERE relname LIKE 'log_penjualan%'
ORDER BY relname;

Output yang Dihasilkan:

text
         relname         | relkind | ukuran_penyimpanan 
-------------------------+---------+--------------------
 log_penjualan           | p       | 0 bytes
 log_penjualan_2026_01   | r       | 184 kB
 log_penjualan_2026_02   | r       | 168 kB
 log_penjualan_2026_03   | r       | 184 kB
 log_penjualan_monolitik | r       | 528 kB
(5 rows)

Baris pertama memperlihatkan induk log_penjualan berukuran 0 bytes, dan empat baris lainnya menunjukkan tabel fisik yang benar-benar menyimpan data — termasuk tabel monolitik lama sebagai pembanding. Konsekuensinya penting: memindahkan data lama cukup dengan melepas satu partisi dari induk (DETACH PARTITION) tanpa menyalin satu baris pun, karena baris memang sudah hidup di tabel fisiknya sendiri. Chapter Partition Maintenance membahas operasi ini sampai tuntas.

Kapan Tidak Perlu Partitioning #

Partitioning menambah kompleksitas: lebih banyak objek untuk dipantau, batas partisi yang harus dirawat, dan syarat khusus untuk constraint unik. Untuk tabel kecil, biaya tersebut tidak terbayar keuntungannya. Panduan praktisnya:

Kondisi tabelPartitioning layak?Alasan utama
< 1 juta baris, query cepattidaktuning query dan index lebih murah dan cukup
tabel kecil yang sering di-jointidakplanning overhead per partisi menumpuk
tabel besar tambah-only berbasis waktuyapruning + rotasi data murah
tabel > 100 GB yang lambat di-vacuumyavacuum berjalan per partisi kecil
data lama harus diarsipkan rutinyaDETACH menggantikan DELETE massal

Pola paling klasik dan paling aman dimulai dari sini adalah data deret waktu (time-series): log, telemetri sensor, dan riwayat transaksi yang selalu dikueri per rentang tanggal. Untuk profil seperti itu, pemecahan berdasarkan rentang tanggal adalah strategi bawaan yang paling pas — dan itulah topik chapter berikutnya.

Referensi #

Berkas Sumber

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

induk-tanpa-penyimpanan.sql

Unduh berkas
sql
-- Induk punya relkind 'p' (partitioned table), partisi punya 'r' (tabel biasa).
SELECT relname, relkind,
       pg_size_pretty(pg_relation_size(oid)) AS ukuran_penyimpanan
FROM pg_class
WHERE relname LIKE 'log_penjualan%'
ORDER BY relname;

routing-otomatis.sql

Unduh berkas
sql
-- Tiga transaksi baru dimasukkan lewat tabel induk.
INSERT INTO log_penjualan VALUES
    (9001, DATE '2026-01-31', 'Jakarta',   250000.00),
    (9002, DATE '2026-02-28', 'Bandung',   125000.00),
    (9003, DATE '2026-03-31', 'Surabaya',   99000.00);

-- Kolom sistem tableoid mengungkap tabel fisik tempat tiap baris disimpan.
SELECT id, tanggal, kota, total, tableoid::regclass AS lokasi_fisik
FROM log_penjualan
WHERE id >= 9001
ORDER BY id;

tabel-monolitik.sql

Unduh berkas
sql
-- Cara lama: satu tabel monolitik menampung seluruh log penjualan
-- Toko Maju Jaya untuk Januari-Maret 2026 (9000 baris).
CREATE TABLE log_penjualan_monolitik (
    id      bigint NOT NULL,
    tanggal date NOT NULL,
    kota    text NOT NULL,
    total   numeric(12,2) NOT NULL
);

INSERT INTO log_penjualan_monolitik
SELECT g,
       DATE '2026-01-01' + (g % 90),
       'Kota-' || (g % 5),
       ((g * 37) % 1000 + 1) * 1000.00
FROM generate_series(1, 9000) AS g;

SELECT count(*) AS jumlah_baris,
       min(tanggal) AS tanggal_terawal,
       max(tanggal) AS tanggal_termuda,
       pg_size_pretty(pg_relation_size('log_penjualan_monolitik')) AS ukuran_tabel
FROM log_penjualan_monolitik;

tabel-terpartisi.sql

Unduh berkas
sql
-- Cara terpartisi: tabel induk log_penjualan hanya menjadi kerangka logika;
-- baris disimpan pada partisi bulanan yang dibuat eksplisit.
CREATE TABLE log_penjualan (
    id      bigint NOT NULL,
    tanggal date NOT NULL,
    kota    text NOT NULL,
    total   numeric(12,2) NOT NULL
) PARTITION BY RANGE (tanggal);

CREATE TABLE log_penjualan_2026_01 PARTITION OF log_penjualan
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE log_penjualan_2026_02 PARTITION OF log_penjualan
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE log_penjualan_2026_03 PARTITION OF log_penjualan
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- INSERT tetap ditujukan ke induk; baris dialirkan ke partisi yang cocok.
INSERT INTO log_penjualan
SELECT g,
       DATE '2026-01-01' + (g % 90),
       'Kota-' || (g % 5),
       ((g * 37) % 1000 + 1) * 1000.00
FROM generate_series(1, 9000) AS g;

SELECT urut, tabel, baris FROM (
    SELECT 1 AS urut, 'induk log_penjualan' AS tabel,
           count(*) AS baris FROM log_penjualan
    UNION ALL
    SELECT 2, 'partisi 2026_01 (Januari)', count(*)
    FROM log_penjualan_2026_01
    UNION ALL
    SELECT 3, 'partisi 2026_02 (Februari)', count(*)
    FROM log_penjualan_2026_02
    UNION ALL
    SELECT 4, 'partisi 2026_03 (Maret)', count(*)
    FROM log_penjualan_2026_03
) AS rekap ORDER BY urut;