「你要設計一個社群媒體:每個用戶可以發多篇文章,每篇文章可以有多個留言, 用戶可以追蹤其他用戶。你的資料庫要怎麼設計? 把所有資料放在一張表?你很快就會後悔。」
很多初學者的第一張表都是「超大表」:什麼都塞進去,簡單直接。 短期沒問題,但三個月後你就知道痛苦了——更新一個欄位要動幾百列, 刪一個資料意外把另一個刪掉,插入新資料發現欄位是 NULL... 這篇帶你從根本上理解「好的資料庫設計」是什麼樣子。
從「一張超大表」開始
先看一個壞設計長什麼樣子。假設你要設計一個部落格系統,你想: 「用戶、文章、留言都有關聯,不如放在一起最方便。」
壞設計:把所有東西塞進一張表
這不只是「看起來醜」的問題,而是會造成三種具體的「資料異常」—— 資料庫設計理論稱之為更新異常、插入異常、刪除異常。
更新異常(Update Anomaly)
Joseph 換了 Email。你要找出所有 user_id = 1 的列,逐一更新。如果漏更新一列,同一個用戶就有兩個不同的 Email——資料不一致了。
插入異常(Insert Anomaly)
新用戶 Bob 剛註冊,還沒發文。你怎麼插入他的資料?post_title 要填什麼?只能填 NULL。但 NULL 代表什麼意思?沒有標題?還是沒有文章?語意不清。
刪除異常(Delete Anomaly)
你要刪除 Joseph 的所有文章。結果:所有 post_title 不是 NULL 的列被刪了,Joseph 的帳號資訊(email、username)也一起消失了。
根本原因
所有問題都來自同一個錯誤:把描述不同「事物」的資料塞在同一張表。 用戶是一種事物,文章是另一種事物,留言是第三種事物。它們有關聯,但不是同一種東西。 解法:分開設計,用外鍵(Foreign Key)建立關聯。
ER Diagram:用圖說清楚關係
ER Diagram(Entity-Relationship Diagram)是資料庫設計的第一步, 在寫任何 SQL 之前,先把「有哪些實體、它們之間是什麼關係」畫出來。
實體(Entity)
現實中獨立存在的「物件」,用矩形框表示。
users、posts、comments、products
屬性(Attribute)
實體的特徵,用橢圓或欄位名稱表示。
user_id, username, email, created_at
關係(Relationship)
實體之間的關聯,標明 1:1、1:N、M:N。
一個用戶有多篇文章(1:N)
部落格系統 ER Diagram
一對一(1:1)
用戶 ↔ 用戶設定檔
資訊可選、讀取頻率不同時分表
一對多(1:N)
用戶 → 文章、訂單 → 明細
最常見的關係,用外鍵實現
多對多(M:N)
用戶 ↔ 用戶(追蹤)、訂單 ↔ 商品
需要中間表(Junction Table)
正規化:每個事實只存一次
資料庫正規化(Normalization)是一套設計原則,核心思想只有一句話:每個事實在資料庫裡只存一次,不要重複。正規化分 1NF、2NF、3NF,從低到高逐步消除冗餘。
正規化的直覺
想像你在整理衣服:1NF 是「每件衣服放進格子裡,不要疊放」; 2NF 是「按照類型分層,不要把毛衣和短袖混在同一格」; 3NF 是「把品牌資訊移到品牌區,不要在每件衣服上貼品牌說明書」。 每一層都是在減少「同一件事說兩遍」的情況。
第一正規形式:原子值,無重複欄位
每個欄位只存一個值(原子值),不能存清單或陣列。 不能有 phone1、phone2、phone3 這種重複欄位。
第二正規形式:消除部分依賴
所有非鍵欄位都必須完全依賴於整個主鍵,不能只依賴主鍵的一部分。 這個問題只出現在組合主鍵的情況。
第三正規形式:消除遞移依賴
非鍵欄位不能依賴另一個非鍵欄位。用白話說:「A 欄位決定 B 欄位,B 欄位決定 C 欄位, 但 C 跟主鍵沒有直接關係」——這就是遞移依賴,要消除。
正規化的取捨
正規化的好處
過度正規化的壞處
實務建議:OLTP(交易型,如電商、社群)先做到 3NF;OLAP(分析型,如報表、BI)可以用星型架構(反正規化)提升查詢速度。
實戰:設計電商資料庫
理論說夠了,來設計一個真實的系統。電商是資料庫設計面試最常見的題目, 一步一步走過設計流程,比背答案有用多了。
需求清單
第一步:找出實體
order_items 是訂單和商品之間的中間表——因為一張訂單可以包含多個商品, 一個商品也可以出現在多張訂單,這是典型的多對多(M:N)關係,需要中間表來解決。
第二步:定義關係
第三步:建表
設計亮點:unit_price
order_items 裡的 unit_price 記錄的是下訂當時的售價, 而非 products.price(現在的售價)。這樣設計是因為: 商品可以打折、調價,但已成立的訂單金額不應該變動。 如果直接 JOIN products.price,三年前的訂單總額就會跑掉。
設計亮點:組合主鍵
order_items 用 (order_id, product_id) 作為組合主鍵, 確保同一張訂單同一個商品只能出現一列。 要買 3 個同款商品,用 quantity = 3,不是插入三列。 這個設計還帶來免費的複合索引(主鍵即索引)。
Index 要加在哪裡?
設計完表結構,下一步是想清楚 Index。沒有 Index,查詢就是全表掃描(Full Table Scan)——對一張百萬列的訂單表執行WHERE user_id = 1, PostgreSQL 會逐列檢查每一列,回傳需要幾秒。
思考方式:根據查詢模式決定 Index
先問自己:「最常見的查詢是什麼?WHERE 條件是哪些欄位?」那些欄位就是 Index 的候選。但 Index 也有成本:每次寫入都要更新索引, 索引太多反而讓寫入變慢,也佔空間。
一定要加
WHERE 常用的外鍵欄位(user_id、order_id)、JOIN 的 ON 欄位
考慮加
ORDER BY 欄位(created_at)、高選擇性的過濾欄位
通常不需要
低選擇性欄位(如 status 只有 5 種值)、小表(幾千列以下)
延伸閱讀
Index 的完整原理(B-tree 索引、複合索引的最左前綴原則、Index 的代價分析), 在 Database 系列 EP.02 Index 有詳細說明,這篇先掌握「什麼情況要加」的直覺。
本篇重點回顧
上一篇
EP.03 — 快取策略
讓系統快 10 倍的關鍵決策
下一篇
EP.05 — Coming Soon
準備中,敬請期待