Dua bentuk normal pertama mengajarkan satu prinsip yang sama dengan sudut berbeda: kolom non-kunci hanya boleh bersandar pada kunci. Tabel pelanggan Toko Maju Jaya yang akan kita bedah kali ini lolos kedua pemeriksaan itu tanpa susah payah — kuncinya satu kolom id, isinya atomic, tidak ada ketergantungan parsial yang mungkin. Tetapi tabel itu tetap menyimpan pemetaan yang sama berkali-kali: 40115 selalu berarti Bandung, level gold selalu berarti diskon 15%. Fakta-fakta itu bergantung pada kolom non-kunci lainnya, bukan pada kunci — ketergantungan transitif, pelanggaran terakhir yang dikejar Third Normal Form (3NF). Setelah jenjang ini, skema kita sampai di langit-langit praktis normalisasi.
Definisi: Tidak Ada Lompatan Dua Tingkat #
3NF didefinisikan berjenjang: tabel sudah 2NF, dan tidak ada kolom non-kunci yang bergantung pada kolom non-kunci lain. Rumus pendeknya: kunci menentukan baris, baris menentukan segalanya — atribut non-kunci tidak boleh saling menentukan. Pada tabel pelanggan kita, dua rantai lompatan itu sudah terlihat:
| Rantai ketergantungan | Lompatannya | Fakta yang sebenarnya |
|---|---|---|
id → kode_pos → kota | kota bergantung pada kode_pos, bukan pada id | pemetaan wilayah: satu kode pos, satu kota |
id → level_member → diskon_persen | diskon_persen bergantung pada level_member | kebijakan toko: satu level, satu diskon |
Kedua rantai terasa wajar karena kolomnya memang sering diisi bersamaan: mendaftarkan pelanggan berarti mengisi kode pos, kota, dan level sekaligus. Justru rasa wajar itulah yang membuat ketergantungan transitif bertahan bertahun-tahun di tabel produksi. Ujian cepatnya: tanyakan “kalau nilai kolom A berubah, apakah kolom B wajib ikut berubah?” Bila ya — kode pos berubah, kota ikut berubah — maka B bergantung pada A, dan keduanya tidak boleh berbagi tabel berkunci id.
Meja Peraga: Data Pelanggan yang Rapi di Permukaan #
Tabel berikut adalah buku besar pelanggan hasil rapat inventaris toko. Struktur tampak disiplin: primary key tunggal, semua kolom NOT NULL, nilai atomic di setiap sel. Dua query diagnosis di akhir file membongkar apa yang disembunyikan tampilan rapi itu:
-- Buku besar pelanggan lama Toko Maju Jaya.
-- Kuncinya satu kolom (id) sehingga 1NF dan 2NF lolos otomatis --
-- tetapi dua pasang kolom di dalamnya saling bergantung tanpa kunci.
CREATE TABLE pelanggan_lama (
id integer PRIMARY KEY,
nama text NOT NULL,
kode_pos text NOT NULL,
kota text NOT NULL,
level_member text NOT NULL,
diskon_persen numeric(5, 2) NOT NULL
);
INSERT INTO pelanggan_lama (id, nama, kode_pos, kota, level_member, diskon_persen) VALUES
(1, 'Budi Santoso', '40115', 'Bandung', 'reguler', 5.00),
(2, 'Siti Rahayu', '40115', 'Bandung', 'gold', 15.00),
(3, 'Andi Wijaya', '10110', 'Jakarta', 'gold', 15.00),
(4, 'Dewi Lestari', '60115', 'Surabaya', 'silver', 10.00);
SELECT id, nama, kode_pos, kota, level_member, diskon_persen
FROM pelanggan_lama
ORDER BY id;
-- Diagnosis ketergantungan transitif pertama:
-- pemetaan kode_pos -> kota tersimpan ulang setiap ada penduduk baru.
SELECT kode_pos, kota, count(*) AS penduduk
FROM pelanggan_lama
GROUP BY kode_pos, kota
ORDER BY kode_pos;
-- Diagnosis ketergantungan transitif kedua:
-- pemetaan level_member -> diskon_persen juga tersimpan ulang.
SELECT level_member, diskon_persen, count(*) AS anggota
FROM pelanggan_lama
GROUP BY level_member, diskon_persen
ORDER BY level_member;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 4
id | nama | kode_pos | kota | level_member | diskon_persen
----+--------------+----------+----------+--------------+---------------
1 | Budi Santoso | 40115 | Bandung | reguler | 5.00
2 | Siti Rahayu | 40115 | Bandung | gold | 15.00
3 | Andi Wijaya | 10110 | Jakarta | gold | 15.00
4 | Dewi Lestari | 60115 | Surabaya | silver | 10.00
(4 rows)
kode_pos | kota | penduduk
----------+----------+----------
10110 | Jakarta | 1
40115 | Bandung | 2
60115 | Surabaya | 1
(3 rows)
level_member | diskon_persen | anggota
--------------+---------------+---------
gold | 15.00 | 2
reguler | 5.00 | 1
silver | 10.00 | 1
(3 rows)Kedua query diagnosis menunjukkan gejala yang sama dari dua sisi. Kolom penduduk bernilai 2 untuk pemetaan 40115-Bandung: fakta wilayah itu hidup dua kali karena Budi dan Siti kebetulan serumah wilayahnya. Kolom anggota bernilai 2 untuk level gold: fakta “gold berarti 15%” hidup dua kali karena Siti dan Andi kebetulan sama-sama gold. Perhatikan bahwa jumlah salinan mengikuti kebetulan data — tambahkan sepuluh pelanggan Bandung yang gold, dan kedua pemetaan itu masing-masing hidup sepuluh kali lebih banyak. Redundansi transitif tumbuh mengikuti data, bukan mengikuti jumlah fakta.
Anomali Transitif dalam Praktik #
Ketika pemetaan menumpang di tabel pelanggan, operasi pada pelanggan bisa merusak pemetaan. Pelanggan 1 pindah rumah ke Cimahi; petugas yang teliti mengubah kolom kota, tetapi lupa bahwa kode_pos harus ikut bergerak bersamanya:
-- Pelanggan 1 pindah rumah ke Cimahi. Petugas mengubah kolom kota,
-- tetapi kode_pos lupa ikut diganti -- dua kolom yang seharusnya
-- bergerak bersama kini berbeda nasib.
UPDATE pelanggan_lama SET kota = 'Cimahi'
WHERE id = 1;
-- Satu kode pos, dua kota: pemetaan 40115 -> Bandung dan
-- 40115 -> Cimahi hidup berdampingan dalam tabel yang sama.
SELECT id, nama, kode_pos, kota
FROM pelanggan_lama
WHERE kode_pos = '40115'
ORDER BY id;
-- Diterjemahkan menjadi query pemeriksa: satu kode pos, dua versi kota.
SELECT kode_pos, count(DISTINCT kota) AS versi_kota
FROM pelanggan_lama
GROUP BY kode_pos
HAVING count(DISTINCT kota) > 1
ORDER BY kode_pos;Output yang Dihasilkan:
UPDATE 1
id | nama | kode_pos | kota
----+--------------+----------+---------
1 | Budi Santoso | 40115 | Cimahi
2 | Siti Rahayu | 40115 | Bandung
(2 rows)
kode_pos | versi_kota
----------+------------
40115 | 2
(1 row)Query pertama memperlihatkan kerusakannya langsung: kode pos 40115 kini menunjuk dua kota sekaligus — Cimahi pada baris Budi dan Bandung pada baris Siti. Query kedua merangkumnya: versi_kota bernilai 2, padahal pemetaan kode pos ke kota bersifat satu-satu. Tidak ada error yang berbunyi; laporan “pelanggan per wilayah” akan menghitung Bandung dan Cimahi sebagai dua wilayah berbeda dari kode pos yang sama.
Dua anomali saudaranya mengikuti pola keluarga yang sama dengan chapter Normalisasi Database , cukup diceritakan tanpa perlu dibuktikan ulang. Anomali insert: pemetaan kode pos baru milik kawasan yang baru dibangun tidak bisa didaftarkan sebelum ada pelanggan yang tinggal di sana — tabel pelanggan tidak punya baris untuk fakta wilayah. Anomali delete: bila satu-satunya pelanggan di sebuah wilayah berhenti berbelanja dan barisnya dihapus, pemetaan wilayah itu ikut lenyap, padahal geografi tidak ikut hilang. Ketiganya berakar pada satu sebab: pemetaan dipaksa hidup sebagai kolom, bukan sebagai tabel.
Ekstraksi Tabel Rujukan #
Perbaikan 3NF adalah mengangkat setiap pemetaan menjadi tabel rujukan (lookup table) dengan kuncinya sendiri, lalu menyisakan foreign key di tabel pelanggan. kode_pos-ke-kota pindah ke tabel wilayah; level-ke-diskon_persen pindah ke tabel level_member:
-- Langkah 0: rekonsiliasi data. Pelanggan 1 memang pindah ke Cimahi,
-- jadi pembaruan yang benar mengganti kode_pos DAN kota sekaligus.
UPDATE pelanggan_lama SET kode_pos = '40525', kota = 'Cimahi'
WHERE id = 1;
-- Langkah 1: ekstraksi tabel rujukan. Pemetaan yang tadinya menumpang
-- di kolom pelanggan kini menjadi tabel dengan kuncinya sendiri.
CREATE TABLE wilayah (
kode_pos text PRIMARY KEY,
kota text NOT NULL
);
CREATE TABLE level_member (
level text PRIMARY KEY,
diskon_persen numeric(5, 2) NOT NULL
);
INSERT INTO wilayah (kode_pos, kota)
SELECT DISTINCT kode_pos, kota
FROM pelanggan_lama
ORDER BY kode_pos;
INSERT INTO level_member (level, diskon_persen)
SELECT DISTINCT level_member, diskon_persen
FROM pelanggan_lama
ORDER BY level_member;
-- Anomali insert pulih total: wilayah baru bisa didaftarkan
-- SEBELUM ada satu pun pelanggan yang tinggal di sana.
INSERT INTO wilayah (kode_pos, kota)
VALUES ('12930', 'Jakarta Selatan');
SELECT kode_pos, kota
FROM wilayah
ORDER BY kode_pos;
-- Langkah 2: tabel pelanggan yang ramping. Kolom kota dan diskon_persen
-- hilang, diganti dua foreign key ke tabel rujukan.
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kode_pos text NOT NULL REFERENCES wilayah (kode_pos),
level text NOT NULL REFERENCES level_member (level)
);
INSERT INTO pelanggan (id, nama, kode_pos, level)
SELECT id, nama, kode_pos, level_member
FROM pelanggan_lama
ORDER BY id;
-- Buku besar lama pensiun.
DROP TABLE pelanggan_lama;
-- Laporan lengkap disusun ulang lewat join: tampilan sama,
-- setiap pemetaan hanya hidup sekali.
SELECT p.id, p.nama, p.kode_pos, w.kota, p.level, lm.diskon_persen
FROM pelanggan p
JOIN wilayah w ON w.kode_pos = p.kode_pos
JOIN level_member lm ON lm.level = p.level
ORDER BY p.id;
-- Uji pulih: kebijakan diskon gold naik menjadi 20% --
-- diubah di SATU baris, terbaca oleh semua anggota gold.
UPDATE level_member SET diskon_persen = 20.00
WHERE level = 'gold';
SELECT p.nama, p.level, lm.diskon_persen
FROM pelanggan p
JOIN level_member lm ON lm.level = p.level
ORDER BY p.id;Output yang Dihasilkan:
UPDATE 1
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 3
INSERT 0 1
kode_pos | kota
----------+-----------------
10110 | Jakarta
12930 | Jakarta Selatan
40115 | Bandung
40525 | Cimahi
60115 | Surabaya
(5 rows)
CREATE TABLE
INSERT 0 4
DROP TABLE
id | nama | kode_pos | kota | level | diskon_persen
----+--------------+----------+----------+---------+---------------
1 | Budi Santoso | 40525 | Cimahi | reguler | 5.00
2 | Siti Rahayu | 40115 | Bandung | gold | 15.00
3 | Andi Wijaya | 10110 | Jakarta | gold | 15.00
4 | Dewi Lestari | 60115 | Surabaya | silver | 10.00
(4 rows)
UPDATE 1
nama | level | diskon_persen
--------------+---------+---------------
Budi Santoso | reguler | 5.00
Siti Rahayu | gold | 20.00
Andi Wijaya | gold | 20.00
Dewi Lestari | silver | 10.00
(4 rows)Empat momen di file itu adalah inti perbaikan 3NF:
- Rekonsiliasi sebelum ekstraksi — langkah nol memperbaiki data Budi terlebih dahulu: pindah rumah yang benar mengganti
kode_posmenjadi40525sekaliguskotamenjadi Cimahi. Tanpa langkah ini,SELECT DISTINCT kode_pos, kotaakan menghasilkan dua baris untuk40115dan ekstraksi kewilayahakan ditolak primary key tabel rujukan itu sendiri. - Anomali insert pulih — daftar
wilayahhasil query menampilkan12930Jakarta Selatan tanpa satu pun penduduk: pemetaan wilayah kini punya rumah sendiri dan tidak lagi disandera keberadaan pelanggan. Foreign keyREFERENCES wilayah (kode_pos)juga menjaga arah sebaliknya — pelanggan tidak bisa mendaftar ke kode pos yang tidak terdaftar, mekanisme yang dibahas tuntas di chapter Foreign Key . - Tabel pelanggan ramping — empat kolom lama (
kota,level_member,diskon_persenredundan) tergantikan dua kolom rujukan; laporan join menunjukkan tampilan lama tetap tersusun utuh. - Kebijakan berubah di satu tempat — kenaikan diskon
goldmenjadi20.00menyentuh satu barislevel_member, dan query penutup membuktikan Siti dan Andi langsung terbaca20.00tanpa satu pun baris pelanggan disentuh.
BCNF: Satu Anak Tangga Lagi #
Boyce-Codd Normal Form (BCNF) memperketat 3NF dengan satu klausul tambahan: setiap penentu (determinant) ketergantungan non-trivial harus merupakan superkey — bukan hanya “kolom non-kunci tidak bergantung pada kolom non-kunci”. Perbedaannya hanya terasa pada tabel dengan beberapa composite candidate key yang saling tumpang tindih, kasus yang jarang muncul di skema bisnis. Praktik umum merujuk 3NF sebagai “langit-langit praktis”: normalisasi lebih lanjut (4NF, 5NF) menangani kasus khusus seperti multivalued dependency, dan biaya membuktikannya jarang sebanding dengan masalah yang diselesaikannya. Skema toko kita berhenti di 3NF — dan itu keputusan yang sadar, bukan kelelahan.
Dengan 1NF, 2NF, dan 3NF berlapis lengkap, skema Toko Maju Jaya kini menyimpan setiap fakta tepat satu kali: entitas dipisah, kunci utuh, pemetaan berumah sendiri. Pertanyaan berikutnya bukan lagi “bagaimana menormalkan lebih jauh”, melainkan “kapan normalisasi sengaja dibatalkan” — sebab ada dunia di mana join yang bertambah justru menjadi masalah, dan redundansi yang dikendalikan menjadi obat. Dunia itu adalah materi chapter Denormalisasi .
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
anomali-transitif.sql
Unduh berkas-- Pelanggan 1 pindah rumah ke Cimahi. Petugas mengubah kolom kota,
-- tetapi kode_pos lupa ikut diganti -- dua kolom yang seharusnya
-- bergerak bersama kini berbeda nasib.
UPDATE pelanggan_lama SET kota = 'Cimahi'
WHERE id = 1;
-- Satu kode pos, dua kota: pemetaan 40115 -> Bandung dan
-- 40115 -> Cimahi hidup berdampingan dalam tabel yang sama.
SELECT id, nama, kode_pos, kota
FROM pelanggan_lama
WHERE kode_pos = '40115'
ORDER BY id;
-- Diterjemahkan menjadi query pemeriksa: satu kode pos, dua versi kota.
SELECT kode_pos, count(DISTINCT kota) AS versi_kota
FROM pelanggan_lama
GROUP BY kode_pos
HAVING count(DISTINCT kota) > 1
ORDER BY kode_pos;
buat-pelanggan.sql
Unduh berkas-- Buku besar pelanggan lama Toko Maju Jaya.
-- Kuncinya satu kolom (id) sehingga 1NF dan 2NF lolos otomatis --
-- tetapi dua pasang kolom di dalamnya saling bergantung tanpa kunci.
CREATE TABLE pelanggan_lama (
id integer PRIMARY KEY,
nama text NOT NULL,
kode_pos text NOT NULL,
kota text NOT NULL,
level_member text NOT NULL,
diskon_persen numeric(5, 2) NOT NULL
);
INSERT INTO pelanggan_lama (id, nama, kode_pos, kota, level_member, diskon_persen) VALUES
(1, 'Budi Santoso', '40115', 'Bandung', 'reguler', 5.00),
(2, 'Siti Rahayu', '40115', 'Bandung', 'gold', 15.00),
(3, 'Andi Wijaya', '10110', 'Jakarta', 'gold', 15.00),
(4, 'Dewi Lestari', '60115', 'Surabaya', 'silver', 10.00);
SELECT id, nama, kode_pos, kota, level_member, diskon_persen
FROM pelanggan_lama
ORDER BY id;
-- Diagnosis ketergantungan transitif pertama:
-- pemetaan kode_pos -> kota tersimpan ulang setiap ada penduduk baru.
SELECT kode_pos, kota, count(*) AS penduduk
FROM pelanggan_lama
GROUP BY kode_pos, kota
ORDER BY kode_pos;
-- Diagnosis ketergantungan transitif kedua:
-- pemetaan level_member -> diskon_persen juga tersimpan ulang.
SELECT level_member, diskon_persen, count(*) AS anggota
FROM pelanggan_lama
GROUP BY level_member, diskon_persen
ORDER BY level_member;
perbaikan-3nf.sql
Unduh berkas-- Langkah 0: rekonsiliasi data. Pelanggan 1 memang pindah ke Cimahi,
-- jadi pembaruan yang benar mengganti kode_pos DAN kota sekaligus.
UPDATE pelanggan_lama SET kode_pos = '40525', kota = 'Cimahi'
WHERE id = 1;
-- Langkah 1: ekstraksi tabel rujukan. Pemetaan yang tadinya menumpang
-- di kolom pelanggan kini menjadi tabel dengan kuncinya sendiri.
CREATE TABLE wilayah (
kode_pos text PRIMARY KEY,
kota text NOT NULL
);
CREATE TABLE level_member (
level text PRIMARY KEY,
diskon_persen numeric(5, 2) NOT NULL
);
INSERT INTO wilayah (kode_pos, kota)
SELECT DISTINCT kode_pos, kota
FROM pelanggan_lama
ORDER BY kode_pos;
INSERT INTO level_member (level, diskon_persen)
SELECT DISTINCT level_member, diskon_persen
FROM pelanggan_lama
ORDER BY level_member;
-- Anomali insert pulih total: wilayah baru bisa didaftarkan
-- SEBELUM ada satu pun pelanggan yang tinggal di sana.
INSERT INTO wilayah (kode_pos, kota)
VALUES ('12930', 'Jakarta Selatan');
SELECT kode_pos, kota
FROM wilayah
ORDER BY kode_pos;
-- Langkah 2: tabel pelanggan yang ramping. Kolom kota dan diskon_persen
-- hilang, diganti dua foreign key ke tabel rujukan.
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kode_pos text NOT NULL REFERENCES wilayah (kode_pos),
level text NOT NULL REFERENCES level_member (level)
);
INSERT INTO pelanggan (id, nama, kode_pos, level)
SELECT id, nama, kode_pos, level_member
FROM pelanggan_lama
ORDER BY id;
-- Buku besar lama pensiun.
DROP TABLE pelanggan_lama;
-- Laporan lengkap disusun ulang lewat join: tampilan sama,
-- setiap pemetaan hanya hidup sekali.
SELECT p.id, p.nama, p.kode_pos, w.kota, p.level, lm.diskon_persen
FROM pelanggan p
JOIN wilayah w ON w.kode_pos = p.kode_pos
JOIN level_member lm ON lm.level = p.level
ORDER BY p.id;
-- Uji pulih: kebijakan diskon gold naik menjadi 20% --
-- diubah di SATU baris, terbaca oleh semua anggota gold.
UPDATE level_member SET diskon_persen = 20.00
WHERE level = 'gold';
SELECT p.nama, p.level, lm.diskon_persen
FROM pelanggan p
JOIN level_member lm ON lm.level = p.level
ORDER BY p.id;