資料庫正規化(一~三)
作者:阿寶|iT 邦幫忙 2020 鐵人賽系列《後端基礎 PHP+Mysql & Laravel 30 日養成計畫》Day 32
統整
定義與目的
關聯式資料庫正規化的過程,其實就是將一些實體的描述資料,透過一定的程序,將表單簡化,直到一張表單只單純描述一個事實為止。
目的三點:提昇儲存與操作效率、減少資料異常、讓維護更容易。
正規化後的資料庫應具備四個特性:
- 欄位唯一性:每個欄位只儲存一項資料
- 主關鍵欄位:每筆資料都有主鍵可區別
- 功能關聯性:欄位之間的關聯要明確
- 欄位獨立性:欄位之間不應存在遞移相依
起點:完全沒正規化的表
一張 orders 表塞下所有東西(原文表格的表頭比資料多一欄,以下依語意對回來,date 原文無資料):
orders
| custmor_id | custmor_name | gender | items | prices | store_id | store_name | address | sex |
|---|---|---|---|---|---|---|---|---|
| 1 | 阿寶 | 女 | 鉛筆, 橡皮擦 | 20, 50 | 1 | 久成久 | 東區大學路 | 女 |
| 2 | 豆芽 | 女 | 牛奶, 三明治 | 70, 10 | 3 | 全家 | 東區北門路 | 女 |
| 3 | 老皮 | 男 | 鉛筆, 牛奶 | 20, 70 | 1 | 久成久 | 東區大學路 | 男 |
三個問題:
items、prices一個欄位存兩項以上資料,違反原子性(atomicity)gender和sex意義重複,浪費空間與時間- 沒有 unique 的 primary key,這張表容許存在兩筆內容一模一樣的資料
第一正規化:原子性、去重複欄位、定主鍵
要完成三件事:一個欄位只能有單一值、消除意義上重複的欄位、決定主鍵(必須 unique)。
做法是把每一項商品的消費拆成獨立的列,刪掉多餘欄位(sex 沒了),並以 custmor_id + item_id 組成複合主鍵。
orders(PK = custmor_id + item_id)
| custmor_id | custmor_name | gender | item_id | item_name | price | count | total_amount | store_name | address |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 阿寶 | 女 | 1 | 鉛筆 | 20 | 1 | 20 | 久成久 | 東區大學路 |
| 1 | 阿寶 | 女 | 2 | 橡皮擦 | 50 | 2 | 100 | 久成久 | 東區大學路 |
| 2 | 豆芽 | 女 | 3 | 牛奶 | 70 | 3 | 210 | 全家 | 東區北門路 |
| 2 | 豆芽 | 女 | 4 | 三明治 | 10 | 4 | 40 | 全家 | 東區北門路 |
| 3 | 老皮 | 男 | 1 | 鉛筆 | 20 | 1 | 20 | 久成久 | 東區大學路 |
| 3 | 老皮 | 男 | 3 | 牛奶 | 70 | 2 | 140 | 全家 | 東區北門路 |
(原文這張表同樣多夾了一欄沒有表頭的數字,去掉之後 total_amount = price × count 六列全部成立,這裡照此呈現。)
結果每一列變成一筆「某人買某商品」的紀錄,但消費者性別、商品名稱、商店名稱與地址在每一列重複出現。
作者用兩個情境說明痛點:
- 老皮在全家訂一百個三明治 → 姓名、性別、商店地址要重複輸入一百次
- 全家搬到民族路 → 那一百筆要一筆一筆改
第二正規化:消除部分相依
部分相依的意思為跟主鍵只有一部份有關係、另一部份沒有關係的欄位。
在這個例子裡主鍵是 customer_id + item_id,那麼 customer_name 只和 customer_id 相依、item_name 只和 item_id 相依——這些都該被移到另一張表。
作者補了一段很實用的旁註:
部分相依通常發生在使用複合主鍵的情況下。許多框架建表時預設以
id作為 PK,若該表已完成第一正規化,設定id為 PK 則直接滿足第二正規化,因為 PK 為單一鍵(已經是最小單位,自然不會有和 PK「部分相依」的情況)。
拆完變成四張表:
customers(id, name, gender)
stores(id, name, address)
items(id, name, price, store_id)
orders(customer_id, item_id, count, total_amount, date) -- FK → customers / items
customers
| id | name | gender |
|---|---|---|
| 1 | 阿寶 | 女 |
| 2 | 豆芽 | 女 |
| 3 | 老皮 | 男 |
stores
| id | name | address |
|---|---|---|
| 1 | 久成久 | 東區大學路 |
| 2 | 全家 | 東區北門路 |
items
| id | name | price | store_id |
|---|---|---|---|
| 1 | 鉛筆 | 20 | 1 |
| 2 | 橡皮擦 | 50 | 1 |
| 3 | 牛奶 | 70 | 2 |
| 4 | 三明治 | 10 | 2 |
orders(PK = custmor_id + item_id)
| custmor_id | item_id | count | total_amount | date |
|---|---|---|---|---|
| 1 | 1 | 1 | 20 | 12/17 |
| 1 | 2 | 2 | 100 | 12/17 |
| 2 | 3 | 3 | 210 | 12/18 |
| 2 | 4 | 1 | 40 | 12/18 |
| 3 | 1 | 4 | 20 | 12/19 |
| 3 | 3 | 1 | 140 | 12/19 |
這張 orders 是原文數值,有三列的 total_amount 對不上 price × count(三明治 10 元買 1 個卻是 40、鉛筆 20 元買 4 個卻是 20、牛奶 70 元買 1 個卻是 140)。見下方矛盾點。
遞移相依與第三正規化
所謂遞移關係,在這個範例裡就是指:
total_amount是依賴price及count的資訊,而item_id和count又和主鍵直接相關,那total_amount和主鍵之間的關係就是遞移關係。
兩個定義:
- 遞移相依:欄位1 和主鍵相關,欄位2 和欄位1 相關,欄位2 和主鍵就是遞移相依
- 候選鍵:欄位組合讓資料能是唯一的,並且是最小唯一
規則是「非主鍵屬性的欄位都只能和候選鍵相關,非主鍵屬性的欄位彼此間應該要是獨立無關的」。
作者給的白話理由:為了避免 count 改變而 total_amount 沒改到造成資料錯誤,應該把 total_amount 從這張表中移除。
customers、stores、items 三張表原封不動,只有 orders 少一欄:
orders(PK = customer_id + item_id)
| customer_id | item_id | count | date |
|---|---|---|---|
| 1 | 1 | 1 | 12/17 |
| 1 | 2 | 2 | 12/17 |
| 2 | 3 | 3 | 12/18 |
| 2 | 4 | 1 | 12/18 |
| 3 | 1 | 4 | 12/19 |
| 3 | 3 | 1 | 12/19 |
金額改成查詢時算:items.price × orders.count。
三個階段對照
| schema | 這一步解決的問題 | 留下的問題 | |
|---|---|---|---|
| 原始 | orders(custmor_id, custmor_name, gender, items, prices, store_id, store_name, address, sex) | — | 欄位塞多值、gender/sex 重複、沒有主鍵 |
| 1NF | orders(custmor_id, custmor_name, gender, item_id, item_name, price, count, total_amount, store_name, address),PK = custmor_id + item_id | 原子性、去重複欄位、有主鍵 | 姓名/性別/商品名/店名地址每列重複,改一次要改很多列 |
| 2NF | customers(id, name, gender)/stores(id, name, address)/items(id, name, price, store_id)/orders(custmor_id, item_id, count, total_amount, date) | 消除部分相依,重複資料各自集中到一張表 | total_amount 與 count/price 之間會不同步 |
| 3NF | 同上,但 orders(customer_id, item_id, count, date) | 消除遞移相依(拿掉衍生欄位) | 查金額要 join items;商品改價後查不到當時的成交價 |
BUT:正規化不是教條
文章最後轉折,給了兩個「不該繼續正規化」的情境:
- 高頻查詢要跨表:業務需求就是很高頻率查「某消費者購買的商品名稱及商店地址」,得從 customers 關聯到 items 再關聯到 stores。「這樣第二正規化之後所帶來的好處其實並不比他所帶來的開銷還要大,其實只要做到第一正規化就好了。」
- 需要保留歷史值:無論商品價格之後改幾次,需求就是要查得到「購買當下的價格」——照二、三正規化拆完反而不適用這個情境。
結論:資料庫正規化並不是什麼完全不可違背的教條,要進行到什麼程度,應視對資料庫操作的需求和資料量而定,而不是每次建表都只要無腦的全部照著正規化的規範去設計。
留言區的補充
讀者「微中子」留言指出:正規化有五種,這篇只介紹前三種,並附上自己寫的英文文章《Good RDB Design with the Concept of Normal Forms》。(作者未回應內容)
反覆出現的關鍵字
作者一再回到的說法,是這篇真正想講的東西:
- 「一張表單只單純描述一個事實」——開頭的定義,也是三個 NF 的共同判準;每一步都在回答「這張表混了幾個事實」。
- 相依(dependency):部分相依 → 遞移相依。三個 NF 的差別被收斂成同一個問句:這個欄位到底跟誰有關。
- 重複輸入/一筆一筆修改:說明 1NF 不夠時用的,說明 2NF 時又用一次。作者對「資料異常」的具體想像就是同一個事實被存在很多列,改的時候會漏。
- 原子性(atomicity):1NF 的唯一判準。
- 複合主鍵:部分相依的成因,也是單一
idPK 直接跳過 2NF 的原因。 - 「並不比他所帶來的開銷還要大」/「應視需求和資料量而定」:結尾反覆強調的節制,佔了整篇的最後五分之一。
矛盾點
文章內部
- 表格自己對不齊,而且 2NF 的 orders 算錯三列。原始表與 1NF 表的表頭欄數和資料欄數不一致(各多/少一欄,
date整欄沒有值)。更實質的是 2NF 之後的orders:(2, 4, 1, 40)三明治 10 元買 1 個卻是 40、(3, 1, 4, 20)鉛筆 20 元買 4 個卻是 20、(3, 3, 1, 140)牛奶 70 元買 1 個卻是 140——六列裡三列的total_amount ≠ price × count(1NF 那張表把夾帶的雜欄去掉後反而六列全對)。用來論證「count 改了 total 沒跟著改會出錯」的表格,自己就是那個錯誤的實例(推論:這反而意外地證成了作者的主張)。 - 2NF 段落的敘述與表格不符。文中說拆完後「orders 就單純的只儲存消費者和購買的商品,以及消費日期三種資料而已」,但同段的 orders 表明明還有
count和total_amount。 total_amount的處置被放在 2NF 段落講,卻是 3NF 的工作。遞移相依的定義、要移除total_amount的結論都寫在第二正規化那一節的末尾,第三正規化那節只剩貼表格。- FK 指向的是 id 不是 name。「消費者對應到 customers 的姓名,商品對應到 items 的名稱」與下面
orders(customer_id, item_id)的實作對不上。 total_amount算不算「遞移相依」是可疑的(推論)。教科書的 3NF 講的是「非主鍵欄位相依於另一個非主鍵欄位」;total_amount的來源price在 2NF 之後根本不在這張表裡,它其實是衍生欄位(derived column)。兩者的處置剛好一樣(都該拿掉),但理由不同——把衍生欄位掛到 3NF 名下,會讓人以為「只要不是衍生的就沒事」。- 「單一
idPK 直接滿足 2NF」是半個真話(推論)。定義上成立(沒有複合主鍵就沒有部分相依),但它並沒有消除底層的重複:真正的候選鍵可能仍是複合的,加一個代理鍵只是讓 2NF 的檢查通過,該拆的表還是沒拆。照字面讀很容易得到「用 Laravel 預設 id 就不用管 2NF」的錯誤結論。 - 「保留購買當下價格」不是正規化的反例(推論)。成交價和商品目前售價是兩個不同的事實,把成交價存進 orders 完全符合「一張表只描述一個事實」。作者把它寫成「遵循二、三正規化反而不適用」,其實是誤把 snapshot 需求當成反正規化。
與 vault 既有筆記
- JOIN 貴在每一列都要去另一張表配對:文章說「多層關聯的開銷不比好處大,只做到 1NF 就好」,但沒有給任何成本模型。那篇筆記的判準更精確——join 貴不貴取決於 join 欄位有沒有索引與參與配對的列數,而不是取決於關聯了幾層。(推論)用「幾張表」當反正規化的判準是最常見的誤判來源。
- 同一個寫法在互動式列表沒事,在全量掃描必爆:文章結論「視需求和資料量而定」正是同一個主張的資料庫版本——慣例的適用範圍由負載形狀決定。這兩篇可以互鏈。
- SQL Basic 已收錄 JOIN、GROUP BY 等關鍵字,但沒有任何 schema 設計層的內容;正規化是這份速查缺的上游。
待確認
- 文章只講到 3NF,留言指出還有 BCNF / 4NF / 5NF。(推論:實務上 3NF 之後最常遇到的其實是 BCNF,值不值得再補一輪?)
- 「高頻查詢就退回 1NF」這個處方,跟現在更常見的做法(保持正規化 + 加索引 / 物化視圖 / 讀模型)差在哪?這篇 2020 年的文章沒有提。
抽出的原子筆記
- 正規化的三個階段是同一個問句的三種問法 — 三個 NF 其實是同一個問句
- 遞移相依:欄位靠另一個普通欄位才連到主鍵 — 3NF 的定義與「久成久改名」例子
- 單一主鍵讓 2NF 自動成立,但重複一點都沒少 — 代理鍵只是換了罪名
- 改漏一列的代價不是工多,是資料安靜地不一致 — 正規化到底在買什麼保險
- 拆表把成本集中在一次 join,不拆表把成本攤到每一列 — join 成本 vs 每列變胖
- 反正規化要排在索引與物化視圖後面 — 五步順序,別從第 1 步跳到第 5 步
- 衍生欄位該拿掉的理由不是遞移相依 — 文章把兩件事混在一起講
- 保留成交價不是反正規化,是另一個事實 — 文章第二個「反例」其實不成立