Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 26 · 臺南 AQI 21 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

資料模型與正規化

資料模型與正規化

從一個學生地址裂成三個版本的災難談起,用 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{數量}$$

逐步拆解:

  1. 鍵是 $\{\text{訂單號}, \text{商品ID}\}$。商品名、單價只靠商品 ID 決定 → 部分相依,違反 2NF。
  2. 客戶名、客戶城市透過「訂單號 → 客戶 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)去承接隨之而來的同步難題。理解了函數相依與正規化,你就握住了一把尺,能在任何資料系統裡丈量「這份冗餘,到底是設計,還是債務」。

AI 共讀助教正在陪你讀:資料模型與正規化
嗨!我是這篇文章的共讀助教,只根據〈資料模型與正規化〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。