Normalisasi mengajari kita menyimpan setiap fakta sekali; denormalisasi adalah keputusan menyimpan fakta yang sama dua kali — dengan sadar, untuk kecepatan. Motivasinya nyata: laporan dashboard yang butuh “total belanja tiap pelanggan” harus menjumlahkan seluruh tabel pesanan setiap kali dibuka, dan pada jutaan baris penjumlahan itu tidak murah. Menyimpan hasil agregat sebagai kolom di tabel induk membuat laporan tersebut secepat membaca satu baris. Harganya juga nyata: salinan harus dijaga sinkron dengan data aslinya, dan setiap keterlambatan penjagaan menghasilkan data basi. Chapter ini membangun kolom cache jumlah_pesanan dan total_belanja di tabel pelanggan, membuktikan pola pemeliharaannya sekaligus kegagalannya di database latihan belajar_rdb.

Denormalisasi untuk Kecepatan Baca #

Denormalisasi adalah penambahan redundansi terkontrol pada skema yang sudah normal. Bentuknya bermacam-macam:

Bentuk denormalisasiContohQuery yang dipercepat
kolom agregat tersimpantotal_belanja di tabel pelangganlaporan total belanja per pelanggan
counter cachejumlah_pesanan di tabel pelangganbadge jumlah notifikasi/riwayat
snapshot historisharga per baris pesanan_baranglaporan transaksi dengan harga saat kejadian
tabel ringkasan terpisahtabel penjualan_bulanandashboard agregat yang di-refresh berkala

Perhatikan baris snapshot: kolom harga yang kita simpan di pesanan_barang pada chapter First Normal Form ternyata contohnya — dan snapshot itu sah secara desain karena “harga pada saat transaksi” adalah fakta historis yang memang berbeda dari harga katalog saat ini. MySQL juga punya generated column untuk nilai yang bisa dihitung dari kolom sebaris, tetapi generated column tidak bisa menjumlahkan lintas tabel — agregat seperti SUM atas pesanan memang harus disimpan manual. Di sanalah letak kerja denormalisasi.

Praktik: Kolom Cache di Tabel Pelanggan #

Skema kita berbasis desain normal pelanggan-pesanan dari chapter relasi satu-ke-banyak, ditambah dua kolom cache:

sql
DROP DATABASE IF EXISTS belajar_rdb;
CREATE DATABASE belajar_rdb;
USE belajar_rdb;

CREATE TABLE pelanggan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  kota VARCHAR(30) NOT NULL,
  jumlah_pesanan INT NOT NULL DEFAULT 0,
  total_belanja DECIMAL(14,2) NOT NULL DEFAULT 0.00
);

CREATE TABLE pesanan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  pelanggan_id INT NOT NULL,
  tanggal DATE NOT NULL,
  total DECIMAL(12,2) NOT NULL,
  CONSTRAINT fk_pesanan_pelanggan
    FOREIGN KEY (pelanggan_id) REFERENCES pelanggan(id)
);

INSERT INTO pelanggan (nama, kota) VALUES
  ('Budi Santoso', 'Jakarta'),
  ('Siti Rahayu',  'Bandung'),
  ('Andi Wijaya',  'Surabaya'),
  ('Dewi Lestari', 'Bandung');

INSERT INTO pesanan (pelanggan_id, tanggal, total) VALUES
  (1, '2026-01-05', 170000.00),
  (1, '2026-01-10',  62000.00),
  (1, '2026-01-12',  15000.00),
  (2, '2026-01-07',  50000.00),
  (2, '2026-01-18',  85000.00),
  (3, '2026-01-15', 137000.00),
  (4, '2026-01-08', 125000.00);

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Query OK, 4 rows affected (0.00 sec)
Records: 4  Duplicates: 0  Warnings: 0

Query OK, 7 rows affected (0.00 sec)
Records: 7  Duplicates: 0  Warnings: 0

Dua kolom terakhir adalah duplikat fakta milik tabel pesanan — pelanggaran 3NF yang disengaja. Kolom cache bisa diisi dari data yang ada dengan satu UPDATE yang menghitung ulang seluruh baris:

sql
UPDATE pelanggan p
SET jumlah_pesanan = (SELECT COUNT(*) FROM pesanan o WHERE o.pelanggan_id = p.id),
    total_belanja  = (SELECT SUM(o.total) FROM pesanan o WHERE o.pelanggan_id = p.id);

Output yang Dihasilkan:

text
Query OK, 4 rows affected (0.00 sec)
Rows matched: 4  Changed: 4  Warnings: 0

Query pembanding berikut menaruh kolom cache berdampingan dengan hitungan asli dari tabel pesanan — query inilah alat pemeriksa kebenaran kita sepanjang chapter:

sql
SELECT p.nama,
  p.jumlah_pesanan,
  (SELECT COUNT(*) FROM pesanan o WHERE o.pelanggan_id = p.id) AS pesanan_asli,
  p.total_belanja,
  (SELECT SUM(o.total) FROM pesanan o WHERE o.pelanggan_id = p.id) AS total_asli
FROM pelanggan p
ORDER BY p.id;

Output yang Dihasilkan:

text
+--------------+----------------+--------------+---------------+------------+
| nama         | jumlah_pesanan | pesanan_asli | total_belanja | total_asli |
+--------------+----------------+--------------+---------------+------------+
| Budi Santoso |              3 |            3 |     247000.00 |  247000.00 |
| Siti Rahayu  |              2 |            2 |     135000.00 |  135000.00 |
| Andi Wijaya  |              1 |            1 |     137000.00 |  137000.00 |
| Dewi Lestari |              1 |            1 |     125000.00 |  125000.00 |
+--------------+----------------+--------------+---------------+------------+
4 rows in set (0.00 sec)

Keempat pelanggan menunjukkan cache yang persis sama dengan hitungan asli: Budi tiga pesanan senilai 247000, Siti dua pesanan senilai 135000, dan seterusnya. Laporan yang tadinya memindai seluruh pesanan dengan GROUP BY kini selesai dengan membaca empat baris pelanggan — dan pada data nyata berukuran jutaan baris pesanan, itulah selisih antara detik dan milidetik.

Pola Pemeliharaan per Transaksi #

Cache hanya berguna bila diperbarui setiap kali data aslinya berubah. Pola standarnya: setiap INSERT pesanan disusul satu UPDATE inkremental pada pelanggan terkait — Andi membuat pesanan baru senilai 98000:

sql
INSERT INTO pesanan (pelanggan_id, tanggal, total) VALUES (3, '2026-01-25', 98000.00);

UPDATE pelanggan
SET jumlah_pesanan = jumlah_pesanan + 1,
    total_belanja  = total_belanja + 98000.00
WHERE id = 3;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

Pembaruan inkremental murah — tidak ada COUNT ulang, hanya penambahan — dan idempoten asalkan selalu berpasangan dengan insert-nya. Query pembanding membuktikan cache tetap akurat:

sql
SELECT p.nama,
  p.jumlah_pesanan,
  (SELECT COUNT(*) FROM pesanan o WHERE o.pelanggan_id = p.id) AS pesanan_asli,
  p.total_belanja,
  (SELECT SUM(o.total) FROM pesanan o WHERE o.pelanggan_id = p.id) AS total_asli
FROM pelanggan p
ORDER BY p.id;

Output yang Dihasilkan:

text
+--------------+----------------+--------------+---------------+------------+
| nama         | jumlah_pesanan | pesanan_asli | total_belanja | total_asli |
+--------------+----------------+--------------+---------------+------------+
| Budi Santoso |              3 |            3 |     247000.00 |  247000.00 |
| Siti Rahayu  |              2 |            2 |     135000.00 |  135000.00 |
| Andi Wijaya  |              2 |            2 |     235000.00 |  235000.00 |
| Dewi Lestari |              1 |            1 |     125000.00 |  125000.00 |
+--------------+----------------+--------------+---------------+------------+
4 rows in set (0.00 sec)

Risiko Sinkronisasi: Data Basi #

Sekarang kita pura-purakan satu bug klasik: Dewi membuat pesanan kedua, tetapi kode aplikasi lupa menjalankan UPDATE cache-nya:

sql
INSERT INTO pesanan (pelanggan_id, tanggal, total) VALUES (4, '2026-01-27', 99000.00);

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)
sql
SELECT p.nama,
  p.jumlah_pesanan,
  (SELECT COUNT(*) FROM pesanan o WHERE o.pelanggan_id = p.id) AS pesanan_asli,
  p.total_belanja,
  (SELECT SUM(o.total) FROM pesanan o WHERE o.pelanggan_id = p.id) AS total_asli
FROM pelanggan p
ORDER BY p.id;

Output yang Dihasilkan:

text
+--------------+----------------+--------------+---------------+------------+
| nama         | jumlah_pesanan | pesanan_asli | total_belanja | total_asli |
+--------------+----------------+--------------+---------------+------------+
| Budi Santoso |              3 |            3 |     247000.00 |  247000.00 |
| Siti Rahayu  |              2 |            2 |     135000.00 |  135000.00 |
| Andi Wijaya  |              2 |            2 |     235000.00 |  235000.00 |
| Dewi Lestari |              1 |            2 |     125000.00 |  224000.00 |
+--------------+----------------+--------------+---------------+------------+
4 rows in set (0.00 sec)

Baris Dewi memperlihatkan perpecahan: cache menyatakan satu pesanan senilai 125000, sedangkan data asli menyatakan dua pesanan senilai 224000. Tidak ada error, tidak ada peringatan — laporan dashboard akan menampilkan angka yang salah selama bug itu tidak diperbaiki. Inilah risiko yang dibayar setiap denormalisasi: kebenaran data berubah dari dijamin-struktur menjadi dijamin-prosedur, dan prosedur selalu bisa gagal.

Kabar baiknya, query pembanding kita sekaligus resep perbaikannya. Menghitung ulang seluruh cache dari data asli membersihkan selisih apa pun penyebabnya:

sql
UPDATE pelanggan p
SET jumlah_pesanan = (SELECT COUNT(*) FROM pesanan o WHERE o.pelanggan_id = p.id),
    total_belanja  = (SELECT SUM(o.total) FROM pesanan o WHERE o.pelanggan_id = p.id);

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)
Rows matched: 4  Changed: 1  Warnings: 0

Setelah hitung ulang, baris Dewi kembali cocok dengan data aslinya. Pada sistem produksi, query “janitor” seperti ini dijadwalkan berkala sebagai jaring pengaman di belakang pembaruan inkremental yang utama.

Otomatisasi dengan Trigger #

Pemeliharaan manual bisa dilupakan, jadi MySQL menyediakan trigger: kode yang berjalan otomatis setiap kali sebuah kejadian terjadi pada tabel. Konsepnya, trigger AFTER INSERT pada pesanan menaikkan cache pelanggan terkait tanpa campur tangan aplikasi:

sql
-- @notest
CREATE TRIGGER trg_pesanan_after_insert
AFTER INSERT ON pesanan
FOR EACH ROW
  UPDATE pelanggan
  SET jumlah_pesanan = jumlah_pesanan + 1,
      total_belanja  = total_belanja + NEW.total
  WHERE id = NEW.pelanggan_id;

Blok di atas adalah ilustrasi konsep — block tidak kita jalankan di sini; sintaks lengkap trigger, jaminan eksekusinya, dan interaksinya dengan transaksi dibahas pada section khusus triggers-dan-events. Yang perlu dicatat sekarang: trigger memindahkan tanggung jawab pemeliharaan dari disiplin programmer ke server, sehingga aplikasi tidak bisa “lupa” — dengan konsekuensi kompleksitas tersembunyi yang perlu dikelola saat data dimuat massal atau dipulihkan dari backup.

Aturan Main Denormalisasi #

Denormalisasi yang sehat mengikuti urutan kerja yang tetap. Pertama, rancang skema normal sampai tuntas — 3NF sebagai garis dasar — karena konsistensi data adalah fondasi yang tidak bisa dibeli belakangan. Kedua, ukur: identifikasi query yang benar-benar lambat dengan data nyata, bukan dugaan. Ketiga, pilih bentuk denormalisasi yang paling sempit untuk query tersebut — satu kolom cache, satu tabel ringkasan — dan tulis mekanisme pembaruannya pada saat yang sama, bukan nanti. Keempat, siapkan query verifikasi dan jadwal janitor sebagai jaring pengaman. Data basi yang ditoleransi juga harus disadari: counter notifikasi yang tertinggal beberapa menit mungkin bisa diterima; total keuangan yang salah tidak. Dengan urutan ini, denormalisasi menjadi alat optimasi terukur — bukan alasan untuk kembali ke tabel campur aduk yang kita bongkar sepanjang section ini.

Membersihkan Database Latihan #

sql
DROP DATABASE belajar_rdb;

Output yang Dihasilkan:

text
Query OK, 2 rows affected (0.00 sec)

Referensi #

Seluruh perangkat desain kini terkumpul: entitas dan atribut dari deskripsi kebutuhan, tiga bentuk relasi beserta junction table-nya, normalisasi 1NF sampai 3NF sebagai standar kebersihan, dan denormalisasi terukur sebagai pintu darurat performa. Chapter penutup section ini merangkai semuanya dalam satu studi kasus utuh — merancang skema database aplikasi manajemen tugas dari paragraf kebutuhan sampai query verifikasi.