Chapter sebelumnya berakhir dengan peringatan yang jujur: normalisasi membuat laporan harus merangkai banyak join. Pada sistem transaksional, harga itu murah karena setiap query hanya menyentuh beberapa baris. Di data warehouse keadaannya terbalik — jutaan baris dibaca berulang kali untuk dashboard yang sama, dan join empat tabel besar pada setiap refresh adalah pemborosan yang bisa dihindari. Denormalisasi adalah keputusan sadar menimbang kembali struktur yang sudah rapi demi kecepatan baca.
Pola Akses OLTP vs Analitik #
Keputusan normal atau denormal ditentukan oleh pola akses, bukan selera. Dua dunia ini punya kebutuhan yang bertolak belakang:
| Aspek | OLTP (aplikasi transaksi) | Analitik (warehouse) |
|---|---|---|
| Rasio baca-tulis | tulis sering, baca sedikit | tulis jarang (batch), baca berat |
| Cakupan query | beberapa baris per transaksi | jutaan baris per laporan |
| Operasi dominan | INSERT, UPDATE satu baris | SELECT + agregasi besar |
| Struktur ideal | normal (3NF) | denormal (tabel lebar, bintang) |
Karena warehouse diisi lewat pipeline batch — bukan oleh ketukan pengguna — anomali update tidak lagi mengintai di setiap penulisan. Kita bisa menukar keamanan tulis dengan kecepatan baca.
Join yang Sering, Simpan Hasilnya #
Mari kita ukur pola klasiknya. Skema normal toko online dari chapter Normalisasi Database diisi ulang dalam bentuk ringkas, lalu satu query analitik standar dijalankan — pendapatan per kota per kategori:
import sqlite3
koneksi = sqlite3.connect(":memory:")
koneksi.executescript("""
CREATE TABLE pelanggan (id INTEGER PRIMARY KEY, nama TEXT, kota TEXT);
CREATE TABLE produk (id INTEGER PRIMARY KEY, nama TEXT, kategori TEXT, harga INTEGER);
CREATE TABLE pesanan (id INTEGER PRIMARY KEY,
pelanggan_id INTEGER REFERENCES pelanggan(id), tanggal TEXT);
CREATE TABLE detail_pesanan (
pesanan_id INTEGER REFERENCES pesanan(id),
produk_id INTEGER REFERENCES produk(id),
jumlah INTEGER, PRIMARY KEY (pesanan_id, produk_id));
INSERT INTO pelanggan VALUES
(1, 'Budi Santoso', 'Jakarta'),
(2, 'Siti Rahayu', 'Bandung'),
(3, 'Dewi Lestari', 'Surabaya');
INSERT INTO produk VALUES
(1, 'Smartphone X1', 'elektronik', 3500000),
(2, 'TWS Earbuds', 'elektronik', 750000),
(3, 'Kemeja Batik', 'fashion', 250000);
INSERT INTO pesanan VALUES
(1, 1, '2024-06-01'), (2, 2, '2024-06-03'), (3, 3, '2024-07-20');
INSERT INTO detail_pesanan VALUES
(1, 1, 1), (1, 3, 2), (2, 2, 1), (3, 3, 1);
""")
query_laporan = """
SELECT pl.kota, p.kategori, SUM(p.harga * d.jumlah) AS pendapatan
FROM pesanan ps
JOIN pelanggan pl ON pl.id = ps.pelanggan_id
JOIN detail_pesanan d ON d.pesanan_id = ps.id
JOIN produk p ON p.id = d.produk_id
GROUP BY pl.kota, p.kategori
"""
for b in koneksi.execute(query_laporan + " ORDER BY pl.kota, p.kategori"):
print(b)Output yang Dihasilkan:
('Bandung', 'elektronik', 750000)
('Jakarta', 'elektronik', 3500000)
('Jakarta', 'fashion', 500000)
('Surabaya', 'fashion', 250000)Empat tabel di-join hanya untuk empat baris hasil. Pada data produksi, join ini menyusun ulang jutaan baris setiap kali dashboard dimuat. Denormalisasi menjawabnya dengan menyimpan hasil join sekali:
koneksi.execute("CREATE TABLE laporan_penjualan AS " + query_laporan)
for b in koneksi.execute(
"SELECT * FROM laporan_penjualan ORDER BY kota, kategori"
):
print(b)Output yang Dihasilkan:
('Bandung', 'elektronik', 750000)
('Jakarta', 'elektronik', 3500000)
('Jakarta', 'fashion', 500000)
('Surabaya', 'fashion', 250000)Tabel laporan_penjualan adalah denormal buatan: kolom kota dan kategori yang tadinya hidup di tabel berbeda kini berdampingan, dan tidak ada join sama sekali pada query pembacanya. Di warehouse sungguhan, pola ini berkembang menjadi star schema — satu tabel fakta besar dikelilingi tabel dimensi yang sudah digabungkan sesuai kebutuhan analisis.
Harga yang Dibayar: Konsistensi Tertunda #
Hadiah denormalisasi dibayar dengan keterlambatan. Sumber berubah, laporan tidak — sampai kita membangun ulang laporan tersebut:
koneksi.execute("INSERT INTO pesanan VALUES (4, 1, '2024-07-25')")
koneksi.execute("INSERT INTO detail_pesanan VALUES (4, 2, 1)")
print("-- sumber terbaru: Jakarta elektronik naik --")
for b in koneksi.execute(query_laporan + " ORDER BY pl.kota, p.kategori"):
print(b)
print("-- laporan masih menyimpan angka lama --")
for b in koneksi.execute(
"SELECT * FROM laporan_penjualan WHERE kota = 'Jakarta' ORDER BY kategori"
):
print(b)Output yang Dihasilkan:
-- sumber terbaru: Jakarta elektronik naik --
('Bandung', 'elektronik', 750000)
('Jakarta', 'elektronik', 4250000)
('Jakarta', 'fashion', 500000)
('Surabaya', 'fashion', 250000)
-- laporan masih menyimpan angka lama --
('Jakarta', 'elektronik', 3500000)
('Jakarta', 'fashion', 500000)Pesanan baru dari Budi (satu TWS Earbuds, Rp750.000) menaikkan Jakarta elektronik menjadi 4250000 di sumber, tetapi laporan masih menampilkan 3500000. Menyegarkan berarti menjalankan ulang pembangunan tabel — DROP TABLE lalu CREATE TABLE ... AS SELECT lagi, dan angka kembali cocok: ('Jakarta', 'elektronik', 4250000).
Trade-off Denormalisasi #
| Aspek | Tabel normal | Tabel denormal |
|---|---|---|
| Kecepatan baca laporan | banyak join besar | baca langsung, tanpa join |
| Keamanan penulisan | bebas anomali | anomali mengintai bila ditulis manual |
| Ukuran penyimpanan | tanpa duplikasi | kolom dan agregat terduplikasi |
| Kesegaran data | selalu terkini | terkini hanya setelah refresh |
| Kapan dipilih | OLTP, sumber kebenatan | warehouse, dashboard, tabel agregat |
Denormalisasi bukan kebalikan normalisasi, melainkan lapisan keduanya: sumber tetap normal, laporan dibangun denormal darinya. Kedua lapisan sama-sama bergantung pada satu kontrak struktural — pengenal baris yang unik dan rujukan antar tabel yang dijaga database. Kontrak itulah materi chapter Primary Key dan Foreign Key.