資料模型與正規化
從一個學生地址裂成三個版本的災難談起,用 ER 模型、函數相依與 1NF/2NF/3NF,讓每個事實只住在一個地方。
同一個學生地址,為什麼系統裡有三個版本?
某間學校的選課系統出了狀況:行政人員想寄紙本通知給一位學生,卻發現資料庫裡同一個學生竟有三筆不同的住址,三筆裡兩筆是舊地址、一筆才是搬家後的新地址。追查之後才明白——這位學生選了三門課,每次選課時系統都把「學生住址」連同選課紀錄一起存了一份。後來他搬家,只更新了其中一處,於是同一個事實在資料庫裡分裂成互相矛盾的三個版本。
這不是程式有 bug,而是資料模型設計失當。當「同一個事實被儲存在多個地方」時,更新就注定會遺漏,矛盾只是時間問題。資料模型與正規化(normalization)這門學問,核心要回答的就是:該如何安排資料的結構,讓每個事實只存在一個地方,使矛盾在設計上就無法發生。

從現實世界到 ER 模型
在動手建表之前,我們需要一張藍圖,描述「這個世界裡有哪些東西、它們之間如何關聯」。這張藍圖最常用的工具是實體關係模型(Entity-Relationship Model,ER Model),由 Peter Chen 於 1976 年提出。
ER 模型有三個基本構件:
- 實體(Entity):現實世界中可以被獨立辨識的事物。例如「學生」、「課程」、「教師」。實體的集合稱為實體集(entity set),落地到資料庫通常就是一張表。
- 屬性(Attribute):描述實體的特徵。例如學生有「學號」、「姓名」、「住址」;課程有「課號」、「課名」、「學分」。其中能唯一辨識一個實體的屬性(或屬性組合)稱為鍵(key),例如學號。
- 關係(Relationship):實體之間的關聯。例如「學生 選修 課程」、「教師 教授 課程」。
關係還有基數(cardinality)之分,描述兩邊各能對應多少個對象:
| 基數 | 意義 | 範例 |
|---|---|---|
| 一對一(1:1) | 一邊最多對應另一邊一個 | 一個人對應一張身分證 |
| 一對多(1:N) | 一邊可對應另一邊多個 | 一位導師帶多位學生 |
| 多對多(M:N) | 兩邊都可對應多個 | 學生選課、課程被多人選 |
多對多關係無法直接塞進兩張表,必須拆出一張關聯表(junction table)來承載。以「選課」為例:
學生(student) 選課(enrollment) 課程(course)
+--------+----------+ +--------+---------+--------+ +--------+--------+
| 學號 | 姓名 | | 學號 | 課號 | 成績 | | 課號 | 課名 |
+--------+----------+ +--------+---------+--------+ +--------+--------+
| S001 | 王小明 | | S001 | C101 | 88 | | C101 | 微積分 |
| S002 | 李小華 | | S001 | C102 | 92 | | C102 | 線性代數|
+--------+----------+ | S002 | C101 | 75 | +--------+--------+
+--------+---------+--------+
注意「成績」這個屬性既不屬於學生、也不屬於課程,而是屬於「某位學生修某門課」這件事——它天生就該住在關聯表裡。這正是 ER 模型的價值:它逼我們在畫表之前,先想清楚每個屬性「在語意上到底屬於誰」。
為什麼要避免重複:異常(anomaly)的三種面貌
回到開場的故事。如果我們把所有東西塞進一張大表,會長成這樣:
| 學號 | 姓名 | 住址 | 課號 | 課名 | 教師 | 成績 |
|---|---|---|---|---|---|---|
| S001 | 王小明 | 桃園市A路 | C101 | 微積分 | 張老師 | 88 |
| S001 | 王小明 | 桃園市A路 | C102 | 線性代數 | 陳老師 | 92 |
| S002 | 李小華 | 新竹市B街 | C101 | 微積分 | 張老師 | 75 |
這張「萬用表」看似方便,實則埋了三顆地雷,統稱更新異常(update anomalies):
- 更新異常(update anomaly):王小明搬家,「住址」在兩列重複出現,若只改一列就產生矛盾——這正是開場的災難。
- 插入異常(insertion anomaly):新開一門尚無人選的課,因為沒有對應的學號,這門課就無處可放(除非塞入一堆 NULL)。
- 刪除異常(deletion anomaly):若李小華退選 C101,而他是唯一選 C101 的人,刪掉這列的同時,「C101 是微積分、由張老師教」這個事實也跟著消失了。
三種異常的根源都是同一件事:一張表混進了多個不同的「主題」。住址屬於學生,課名與教師屬於課程,成績屬於選課,硬擠在一起,事實就被迫重複。正規化的任務,就是有系統地把它們拆開。
函數相依:正規化的語意基石
要嚴謹地拆表,我們需要一個比直覺更精確的工具——函數相依(Functional Dependency,FD)。
給定一張表的兩組屬性 $X$ 與 $Y$,若「$X$ 的值一旦確定,$Y$ 的值就被唯一決定」,我們說 $Y$ 函數相依於 $X$,記作:
$$X \rightarrow Y$$
讀作「$X$ 決定 $Y$」。在上面的萬用表中,可以辨識出這些相依:
$$\text{學號} \rightarrow \text{姓名, 住址}$$ $$\text{課號} \rightarrow \text{課名, 教師}$$ $$\text{學號, 課號} \rightarrow \text{成績}$$
最後一條的左側是兩個屬性的組合,意思是「要同時知道學號和課號,才能決定成績」。整張表真正的鍵(key)正是 $\{\text{學號}, \text{課號}\}$——能唯一決定整列的最小屬性組合。
函數相依不是憑空規定的,而是來自對現實世界語意的觀察:一個學號就只屬於一個人,所以必然決定唯一的姓名。設計者的責任,是把所有「應當成立」的相依正確地寫出來。一旦相依寫對了,正規化就從一門藝術變成可機械操作的程序。
三大正規化形式:1NF、2NF、3NF
正規化是一階一階收緊的過程,每一階都針對一類特定的問題。
第一正規化(1NF):原子性
1NF 要求每個欄位只存放單一不可分割的值,且不得有重複的欄位群。 違反 1NF 的典型寫法是把多個值塞進一格:
| 學號 | 選修課程 |
|---|---|
| S001 | 微積分, 線性代數 |
「微積分, 線性代數」是一格塞兩值,無法直接用 SQL 查詢「誰選了線性代數」。修正方式是拆成多列,每列一個值。1NF 是後續一切的前提。
第二正規化(2NF):消除部分相依
2NF 要求在 1NF 的基礎上,每個非鍵屬性都必須相依於整個鍵,不能只相依於鍵的一部分。 這只在鍵由多個屬性組成時才有意義。
看萬用表中的「課名」:
$$\text{課號} \rightarrow \text{課名}$$
課名只需要課號就能決定,根本用不到學號。也就是說,課名只部分相依(partial dependency)於鍵 $\{\text{學號}, \text{課號}\}$ 的一部分。這就是課名在每一列重複出現的數學根源。修正方式是把「課程」獨立成一張表:
選課(enrollment) 課程(course)
+------+------+------+ +------+--------+--------+
| 學號 | 課號 | 成績 | | 課號 | 課名 | 教師 |
+------+------+------+ +------+--------+--------+
| S001 | C101 | 88 | | C101 | 微積分 | 張老師 |
| S001 | C102 | 92 | | C102 | 線性代數| 陳老師 |
| S002 | C101 | 75 | +------+--------+--------+
+------+------+------+
現在「成績」完整相依於 $\{\text{學號}, \text{課號}\}$,課程資訊只存一份。
第三正規化(3NF):消除遞移相依
3NF 要求在 2NF 的基礎上,非鍵屬性不能相依於另一個非鍵屬性。 這種「鍵 → 非鍵 → 非鍵」的鏈式相依稱為遞移相依(transitive dependency)。
假設課程表多了「教師」與「教師研究室」:
$$\text{課號} \rightarrow \text{教師} \rightarrow \text{研究室}$$
研究室其實是由教師決定的,與課號只是間接相關。後果是:同一位老師教兩門課,他的研究室就在課程表裡重複兩次,老師換研究室時又會出現更新異常。修正方式是再拆一張「教師」表:
課程(course) 教師(teacher)
+------+--------+--------+ +--------+----------+
| 課號 | 課名 | 教師 | | 教師 | 研究室 |
+------+--------+--------+ +--------+----------+
| C101 | 微積分 | 張老師 | | 張老師 | 理學院301|
| C102 | 線性代數| 陳老師 | | 陳老師 | 理學院305|
+------+--------+--------+ +--------+----------+
一個常被引用的口訣總結了 3NF 的精神:每個非鍵屬性都必須相依於「鍵、整個鍵、且只相依於鍵」(the key, the whole key, and nothing but the key)——這三段分別正好對應 1NF(要有鍵與原子值)、2NF(整個鍵)、3NF(只有鍵)。
動手看一個例子:把一張壞表正規化到 3NF
讓我們完整走一遍。原始的扁平表:
| 訂單號 | 客戶ID | 客戶名 | 客戶城市 | 商品ID | 商品名 | 單價 | 數量 |
辨識函數相依:
$$\text{訂單號} \rightarrow \text{客戶ID}$$ $$\text{客戶ID} \rightarrow \text{客戶名, 客戶城市}$$ $$\text{商品ID} \rightarrow \text{商品名, 單價}$$ $$\text{訂單號, 商品ID} \rightarrow \text{數量}$$
逐步拆解:
- 鍵是 $\{\text{訂單號}, \text{商品ID}\}$。商品名、單價只靠商品 ID 決定 → 部分相依,違反 2NF。
- 客戶名、客戶城市透過「訂單號 → 客戶 ID → …」而來 → 遞移相依,違反 3NF。
拆成四張表:
訂單(order) 訂單明細(order_item)
+--------+--------+ +--------+--------+------+
| 訂單號 | 客戶ID | | 訂單號 | 商品ID | 數量 |
+--------+--------+ +--------+--------+------+
客戶(customer) 商品(product)
+--------+------+--------+ +--------+--------+------+
| 客戶ID | 客戶名| 客戶城市| | 商品ID | 商品名 | 單價 |
+--------+------+--------+ +--------+--------+------+
現在每個事實只住在一個地方:改客戶城市只動 customer 一列,改商品單價只動 product 一列,再也沒有矛盾的縫隙。
反正規化:當「正確」要為「快」讓步
既然正規化這麼好,是否永遠拆得越細越好?並非如此。正規化把資料分散到多張表,查詢時往往需要多表合併(JOIN)才能組回完整資訊。在高流量的讀取場景下,一個查詢動輒 JOIN 五六張表,會成為效能瓶頸。
反正規化(denormalization) 是刻意在正規化的設計上「加回」一些冗餘,用空間與一致性的代價,換取讀取速度。常見手法包括:
- 冗餘欄位:在訂單表直接存一份「客戶名」快照,省去每次 JOIN 客戶表。電商常這麼做,因為下單當下的客戶名本來就該被「凍結」。
- 預先計算的彙總值(materialized aggregate):在文章表存一個
comment_count欄位,而非每次都COUNT(*)整張留言表。 - 物化視圖(materialized view):把複雜 JOIN 的結果預先算好存成實體表,定期刷新。
反正規化的核心是一筆清醒的權衡,而非偷懶:
| 面向 | 正規化 | 反正規化 |
|---|---|---|
| 寫入 | 快、單點更新 | 慢、需同步多份冗餘 |
| 讀取 | 慢、需 JOIN | 快、資料就近 |
| 一致性 | 結構上保證 | 靠應用程式維護 |
| 儲存空間 | 省 | 耗 |
關鍵原則是:先正規化,確認效能真的是瓶頸後,再針對性地、有意識地反正規化,並明確記錄「這份冗餘由誰負責同步」。 一旦反正規化,原本由資料庫結構自動保證的一致性,就轉嫁成程式設計師的責任——這份債務必須被看見,而不是不小心欠下的。
重點回顧
- ER 模型用實體、屬性、關係描述世界,把多對多關係落地成關聯表;它逼我們在建表前先想清楚每個屬性語意上屬於誰。
- 把多個主題塞進一張表會引發更新、插入、刪除三種異常,根源都是「同一事實被重複儲存」。
- 函數相依 $X \rightarrow Y$ 是正規化的語意基石,讓拆表從直覺變成可機械操作的程序。
- 1NF/2NF/3NF 逐階收緊:原子值 → 消除部分相依 → 消除遞移相依,口訣是「相依於鍵、整個鍵、且只相依於鍵」。
- 反正規化用冗餘換讀取效能,是清醒的權衡,代價是一致性責任從資料庫轉移到應用程式。
深入探討(研究所視角)
正規化之所以能成為一門嚴謹的理論,是因為函數相依背後有一套完整的公理系統支撐,使我們能從一組已知相依,推導出所有邏輯上必然成立的相依。這套規則由 William Armstrong 於 1974 年提出,稱為 Armstrong 公理(Armstrong's axioms),包含三條基本規則($X, Y, Z$ 為屬性集合):
$$\text{自反律(Reflexivity):若 } Y \subseteq X \text{,則 } X \rightarrow Y$$ $$\text{增廣律(Augmentation):若 } X \rightarrow Y \text{,則 } XZ \rightarrow YZ$$ $$\text{遞移律(Transitivity):若 } X \rightarrow Y \text{ 且 } Y \rightarrow Z \text{,則 } X \rightarrow Z$$
這三條已被證明同時是健全的(sound)——只推得出真相依,與完備的(complete)——能推得出所有成立的相依。由它們可導出聯合律、分解律、偽遞移律等實用衍生規則。
公理系統的核心應用是計算屬性閉包(attribute closure) $X^{+}$,即「從 $X$ 出發,沿著所有函數相依能決定的全部屬性集合」。其演算法是反覆套用相依直到不再增長:
def closure(X, fds):
"""計算屬性集合 X 在函數相依集 fds 下的閉包 X+。
fds 為 [(左側集合, 右側集合), ...]"""
result = set(X)
changed = True
while changed:
changed = False
for left, right in fds:
# 若某相依的左側已被涵蓋,則右側也必然成立
if set(left) <= result and not set(right) <= result:
result |= set(right)
changed = True
return result
閉包是判定一切的基礎工具:要檢查 $\{\text{學號},\text{課號}\}$ 是不是候選鍵(candidate key),只需確認它的閉包等於全部屬性,且移除任一屬性後就不再如此(最小性)。判斷某個相依 $X \rightarrow Y$ 是否成立,也只要檢查 $Y \subseteq X^{+}$。
值得指出的是,3NF 在某些病態情況下仍不夠嚴格,於是有更緊的 Boyce-Codd 正規化形式(BCNF),要求每條非平凡函數相依的左側都必須是超鍵(superkey)。但 BCNF 與另一個我們想保留的性質——保持相依分解(dependency-preserving decomposition)——有時無法兼得,這是資料庫理論裡一個深刻的張力:有些關係能無損(lossless)地拆到 BCNF,卻會在拆解過程中失去某些原本能由結構檢查的相依。更高階的 4NF、5NF 則進一步處理多值相依(multivalued dependency) 與連接相依,那是當「兩個彼此獨立的多值屬性」共存於一表時才會浮現的問題。
把視野再拉開,會發現正規化只是「一致性如何被保證」這個大命題的一個切面。它與資料庫的 ACID 交易性質(特別是 Consistency 與 Isolation)互補:正規化在靜態結構上消除矛盾的可能,交易機制則在動態並行中維護不變式(invariant)。而在 NoSQL 與分散式系統的世界裡,這個權衡又被重新洗牌——文件型資料庫常刻意嵌入(embed)冗餘文件以避免分散式 JOIN,本質上是把反正規化當作預設策略,再用 CAP 定理框架下的最終一致性(eventual consistency)去承接隨之而來的同步難題。理解了函數相依與正規化,你就握住了一把尺,能在任何資料系統裡丈量「這份冗餘,到底是設計,還是債務」。