資料庫正規化入門(下):第三正規化與正規化的取捨
接續上篇,拆解更隱蔽的遞移依賴與第三正規化,並聊聊正規化該做到什麼程度,以及什麼時候該反過來做反正規化。
上篇《資料庫正規化入門(上):從資料異常到第一、第二正規化》處理了兩個問題:1NF 讓每個欄位都是不可分割的原子值,2NF 則要求複合主鍵底下的非主鍵欄位,必須完全依賴整個主鍵,而不是只依賴其中一部分。這篇接著處理更隱蔽的第三正規化,以及一個更實際的問題:正規化是不是做越多越好?
第三正規化 (3NF):消除「遞移依賴」
規則:必須先符合 2NF,而且非主鍵欄位之間不能互相依賴 —— 每個非主鍵欄位都必須「直接」依賴主鍵,不能拐個彎透過另一個非主鍵欄位。
回到上篇拆出來的 orders 表,這次多存了客戶的所在城市,以及那個城市的運費:
| order_id | customer_name | customer_city | shipping_fee |
|---|---|---|---|
| 1 | 小明 | 台北 | 60 |
| 2 | 小美 | 台北 | 60 |
| 3 | 小華 | 高雄 | 100 |
主鍵是 order_id,已經符合 2NF(主鍵只有一欄,不存在「部分依賴」的問題)。但仔細看依賴關係:
order_id → customer_city → shipping_fee
shipping_fee 並不是直接由 order_id 決定的,而是先由 order_id 決定 customer_city,再由 customer_city 決定 shipping_fee。這種「透過另一個非主鍵欄位才能決定」的關係,叫做遞移依賴 (Transitive Dependency),違反了 3NF。
後果一樣是重複和異常:台北的運費要是調漲,你得把所有台北訂單的 shipping_fee 全部改過一輪,漏一筆就資料不一致 —— 跟上篇 product_price 重複存的問題,本質上是同一件事,只是這次重複的觸發點,不是複合主鍵,而是欄位跟欄位之間的隱藏關係。
拆法是把遞移依賴的部分獨立出去:
-- 城市運費表:shipping_fee 直接依賴 city
CREATE TABLE cities (
city VARCHAR(50) PRIMARY KEY,
shipping_fee INT
);
-- 訂單表:customer_city 直接依賴 order_id
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_name VARCHAR(50),
customer_city VARCHAR(50),
FOREIGN KEY (customer_city) REFERENCES cities(city)
);
現在 shipping_fee 只存在 cities 一個地方,調運費只要改一列,所有台北訂單查詢時用 JOIN 就能拿到正確的值。
一個判斷口訣
英文世界流傳一句話,很適合當三個正規形式的總結:
The key, the whole key, and nothing but the key. (依賴主鍵,依賴整個主鍵,除了主鍵什麼都不依賴)
| 正規形式 | 核心要求 | 解決的問題 |
|---|---|---|
| 1NF | 每個欄位都是不可分割的原子值 | 一個欄位塞多筆資訊 |
| 2NF | 非主鍵欄位依賴 整個 主鍵,而不是一部分 | 部分依賴 |
| 3NF | 非主鍵欄位 只 依賴主鍵,不依賴其他非主鍵欄位 | 遞移依賴 |
實務上再往下還有 BCNF (Boyce-Codd Normal Form)、4NF 等更嚴格的形式,但多數業務系統做到 3NF,就已經能消除掉絕大部分的更新異常,是一個很實用的停損點。
正規化不是越高越好
正規化的代價是:表拆得越細,查詢時要 JOIN 的表就越多,對讀取效能不一定友善。像是資料分析用的報表資料庫、或是讀取遠多於寫入的場景,常常會刻意反正規化 (Denormalization)—— 把資料重新合併或做冗餘,用空間換查詢速度。
一個實務上常見的判斷準則:
- 系統以寫入 / 更新為主,資料一致性要求高 → 優先正規化到 3NF。
- 系統以讀取 / 統計為主,資料相對固定不太更新 → 可以考慮反正規化,甚至直接做寬表。
正規化的意義從來不是「規則要求所以要做」,而是先看懂資料之間真正的依賴關係,再決定要不要為了效能付出「資料可能不一致」的代價。
面試常見問題
Q1. 什麼是遞移依賴 (Transitive Dependency)?舉例說明。
非主鍵欄位不是直接依賴主鍵,而是透過另一個非主鍵欄位間接依賴主鍵,形成 主鍵 → 欄位A → 欄位B 這種鏈式關係。例如 order_id → customer_city → shipping_fee:shipping_fee 其實是由 customer_city 決定的,只是剛好也能透過 order_id 查到,並非直接依賴主鍵。
Q2. 3NF 和 2NF 的差異是什麼?
2NF 處理的是非主鍵欄位「有沒有依賴到整個主鍵」(部分依賴),3NF 處理的是非主鍵欄位之間「會不會互相依賴」(遞移依賴)。可以理解成 2NF 檢查欄位跟主鍵的關係,3NF 檢查非主鍵欄位彼此的關係。
Q3. 3NF 和 BCNF 差在哪裡?為什麼還需要 BCNF?
3NF 允許一種例外:如果決定某欄位的來源本身是候選鍵 (Candidate Key) 的一部分,就不算違反 3NF。BCNF 把這個例外收緊,要求「每一個決定其他欄位的欄位組合,都必須是候選鍵」,是比 3NF 更嚴格的版本。差異通常只在一張表裡有多個重疊的候選鍵時才會出現,實務上多數表做到 3NF 就已經同時符合 BCNF。
Q4. 什麼情況下你會選擇反正規化 (Denormalization)?
當讀取遠多於寫入、資料相對穩定不太更新、而且 JOIN 已經成為效能瓶頸時,例如報表資料庫、資料倉儲、或高流量的唯讀查詢頁面,會刻意合併表或存冗餘欄位,用資料一致性風險換取查詢速度。前提通常是這些冗餘資料有明確的更新機制(例如定期同步、事件觸發更新),而不是放著不管。
Q5. 正規化程度越高就一定越好嗎?有什麼副作用?
不是。表拆得越細,查詢時需要 JOIN 的表就越多,對讀取效能、查詢複雜度都是負擔,團隊理解資料模型的成本也會提高。正規化的目標是消除資料異常,不是「拆到不能再拆」,實務上多數系統做到 3NF 就是一個合理的停損點,再往上追求 BCNF/4NF 通常只在特定場景才有必要。
Q6. 正規化主要解決寫入端的問題,那讀取效能怎麼辦?
常見做法不是不做正規化,而是「先正規化建模,再視情況局部反正規化」:例如底層維持 3NF 的正規表,另外建立 View、Materialized View,或是額外的彙總表/快取層來服務高頻讀取查詢,兩者可以並存,不必二選一。