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 ketergantunganLompatannyaFakta yang sebenarnya
id → kode_pos → kotakota bergantung pada kode_pos, bukan pada idpemetaan wilayah: satu kode pos, satu kota
id → level_member → diskon_persendiskon_persen bergantung pada level_memberkebijakan 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:

text
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:

text
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:

text
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:

  1. Rekonsiliasi sebelum ekstraksi — langkah nol memperbaiki data Budi terlebih dahulu: pindah rumah yang benar mengganti kode_pos menjadi 40525 sekaligus kota menjadi Cimahi. Tanpa langkah ini, SELECT DISTINCT kode_pos, kota akan menghasilkan dua baris untuk 40115 dan ekstraksi ke wilayah akan ditolak primary key tabel rujukan itu sendiri.
  2. Anomali insert pulih — daftar wilayah hasil query menampilkan 12930 Jakarta Selatan tanpa satu pun penduduk: pemetaan wilayah kini punya rumah sendiri dan tidak lagi disandera keberadaan pelanggan. Foreign key REFERENCES 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 .
  3. Tabel pelanggan ramping — empat kolom lama (kota, level_member, diskon_persen redundan) tergantikan dua kolom rujukan; laporan join menunjukkan tampilan lama tetap tersusun utuh.
  4. Kebijakan berubah di satu tempat — kenaikan diskon gold menjadi 20.00 menyentuh satu baris level_member, dan query penutup membuktikan Siti dan Andi langsung terbaca 20.00 tanpa 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
sql
-- 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
sql
-- 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
sql
-- 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;