[BEE-7002] 正規化與反正規化
深入閱讀
關於資料庫層級的正規化理論與正式定義,請參閱 DEE-100: 正規化。
背景
每個關聯式結構描述(schema)的設計都面臨一個核心抉擇:資料應拆分到何種程度?正規化透過將資料分解為職責單一的資料表來消除冗餘、避免異常。反正規化則刻意重新引入冗餘,以降低讀取時的 join 成本。兩者都沒有絕對的對錯——正確答案取決於讀寫比例、查詢模式,以及對一致性維護開銷的容忍度。
參考資料:
- Database Normalization: 1NF, 2NF, 3NF & BCNF Examples — DigitalOcean
- Use The Index, Luke — 開發者 SQL 索引與效能指南
- Designing Data-Intensive Applications — Martin Kleppmann(DDIA),關於衍生資料與反正規化取捨
正規形式說明
正規化演進
第一正規形式(1NF)
每個欄位只能存放單一原子值。不允許重複欄位組(例如 phone1、phone2、phone3),也不允許將多個值以逗號分隔儲存在同一個欄位中。
違反範例: 一個 tags 欄位儲存 "postgres,performance,indexing" 這樣的字串。
修正方式: 將標籤移到獨立的 article_tags(article_id, tag) 資料表,或在資料庫支援且不需要按個別標籤篩選的情況下,使用原生陣列型別。
第二正規形式(2NF)
僅適用於複合主鍵的情況。每個非鍵欄位必須依賴於整個主鍵,而非主鍵的一部分。
違反範例: 資料表 order_items(order_id, product_id, product_name, quantity)。product_name 只依賴 product_id,而非完整的 (order_id, product_id) 複合鍵——這就是部分相依性。
修正方式: 將 product_name 移到 products(product_id, product_name, ...) 資料表。
第三正規形式(3NF)
任何非鍵欄位都不能依賴另一個非鍵欄位(傳遞相依性)。
違反範例: 資料表 employees(emp_id, dept_id, dept_name)。dept_name 依賴 dept_id,而 dept_id 依賴 emp_id。若部門名稱變更,必須更新所有員工的每一列資料。
修正方式: 抽離 departments(dept_id, dept_name) 資料表,並在 employees 中以外鍵參照。
Boyce-Codd 正規形式(BCNF)
BCNF 是 3NF 的強化版本:對於每個函數相依性 X → Y,X 必須是超鍵。BCNF 處理了 3NF 無法涵蓋的重疊候選鍵邊緣案例。實務上,3NF 已足以應對大多數 OLTP 結構描述;除非問題已被充分理解,否則追求 BCNF 或更高階正規形式的複雜度通常不划算。
為何先正規化
正規化帶來具體的工程效益:
- 消除更新異常(update anomaly)。 客戶的 email 只儲存一次,不會出現「哪份資料才是對的」的問題。
- 消除插入異常(insertion anomaly)。 可以新增商品而不需要一筆假訂單。
- 消除刪除異常(deletion anomaly)。 刪除某位客戶的最後一筆訂單,不會隱性刪除該客戶資料。
- 減少儲存空間。 重複字串(城市名稱、狀態標籤)只儲存一次。
- 簡化寫入。 插入與更新只需異動一列,而非多列。
經典範例:一個嵌入了客戶資料的未正規化 orders 資料表。
-- 未正規化:每一筆訂單都重複儲存客戶資料
CREATE TABLE orders_bad (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT,
customer_name VARCHAR(100), -- 重複
customer_email VARCHAR(100), -- 重複
customer_city VARCHAR(100), -- 重複
ordered_at TIMESTAMPTZ,
total_cents INT
);若 Alice 更改了 email,她所有的訂單列都必須一起更新。只要有一次更新失敗,資料就會不一致。
正規化後:
CREATE TABLE customers (
customer_id BIGINT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) NOT NULL,
city VARCHAR(100)
);
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(customer_id),
ordered_at TIMESTAMPTZ NOT NULL,
total_cents INT NOT NULL
);Alice 的 email 只存在一列之中。查詢時透過 join 取得即可。
何時應反正規化
正規化的優化目標是寫入的正確性。反正規化以部分正確性保障換取讀取效能。以下情況可考慮反正規化:
- join 是效能瓶頸。 某個查詢需要 join 6 張資料表才能組合出商品列表頁面,且效能分析確認 join 成本是主要開銷。
- 彙總計算代價高昂。 儀表板在每次頁面載入時都要計算每位賣家的歷史總收益。
- 讀取量遠超寫入量。 報表資料庫或分析管線,資料批次寫入後被查詢數千次。
- 延遲要求嚴格。 即時 API 無法在限定的 20ms 內透過索引完成複雜 join。
原則:先正規化,然後量測,再針對具體瓶頸進行反正規化。 在效能分析之前就反正規化是過早優化。
反正規化策略
複製欄位
將父資料表中頻繁讀取的值複製到子資料表,以避免 join。
-- 反正規化:將 customer_name 複製到 orders 以加速顯示
ALTER TABLE orders ADD COLUMN customer_name VARCHAR(100);訂單列表查詢不再需要 join customers 資料表。代價是:當 Alice 更改名稱時,應用程式(或觸發器)也必須同步更新 orders.customer_name。
務必加上文件說明。 在遷移記錄或程式碼註解中說明為何複製此欄位,以及由誰負責保持同步。
物化視圖(Materialized View)
讓資料庫自動維護反正規化的副本。
CREATE MATERIALIZED VIEW order_summaries AS
SELECT
c.customer_id,
c.name AS customer_name,
COUNT(o.order_id) AS order_count,
SUM(o.total_cents) AS lifetime_value_cents
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.name;
CREATE UNIQUE INDEX ON order_summaries(customer_id);刷新策略:
REFRESH MATERIALIZED VIEW CONCURRENTLY order_summaries;— 不阻擋讀取的並發刷新(PostgreSQL)。- 透過背景工作排程刷新,或使用觸發器進行增量更新。
物化視圖是一個清楚的反正規化邊界:正規化的來源資料表保持權威性;視圖是明確的衍生讀取快取。
彙總資料表(Summary Table)
預先將資料聚合到一張專用資料表,並在寫入時同步更新。
CREATE TABLE seller_stats (
seller_id BIGINT PRIMARY KEY,
total_orders INT NOT NULL DEFAULT 0,
total_revenue_cents BIGINT NOT NULL DEFAULT 0,
last_updated_at TIMESTAMPTZ NOT NULL
);應用程式在訂單成立時,原子性地增加 total_orders 與 total_revenue_cents。儀表板查詢直接讀取 seller_stats,而不需要掃描整張訂單資料表。
CQRS 讀取模型
對於複雜的讀取工作負載,可考慮將寫入模型(正規化)與讀取模型(反正規化、為查詢優化)分開。詳細說明請參閱 BEE-5003: CQRS。
一致性成本
每次反正規化都會產生同步問題。除非有某種機制維護,否則重複的資料一定會不同步。你的選擇:
| 機制 | 一致性 | 複雜度 | 說明 |
|---|---|---|---|
| 應用程式程式碼 | 最終一致 | 低—中 | 若更新路徑有遺漏則容易出錯 |
| 資料庫觸發器 | 近即時 | 中 | 難以測試,會將結構描述與邏輯耦合 |
| 物化視圖 | 定期或按需 | 低 | 契約清楚,有一定程度的資料過時 |
| 事件驅動消費者 | 最終一致 | 高 | 擴展性佳,需要基礎設施支援 |
選擇能滿足你容忍的資料過時程度的最簡機制。顯示昨日總計的儀表板可以使用每夜刷新;顯示庫存的結帳頁面則必須接近即時。
實例:訂單歷史頁面
問題: 訂單歷史頁面需要 join orders、customers、order_items、products、shipping_addresses,導致查詢耗時 180ms。
第一步 — 先量測。 確認 join 確實是瓶頸,而非缺少索引。在 orders.customer_id 上新增索引後重新量測。假設降至 90ms,仍然太慢。
第二步 — 找出最小的反正規化範圍。 此頁面只需要 customer_name 與 shipping_city,不需要完整的客戶或地址記錄。
第三步 — 新增反正規化欄位,並附上說明。
ALTER TABLE orders
ADD COLUMN shipping_snapshot JSONB;
-- 儲存訂單成立當下的 {customer_name, shipping_city}。
-- 刻意反正規化:訂單歷史應呈現訂單成立當時的狀態,
-- 而非客戶目前的地址。僅在訂單建立時寫入一次,之後不更新。第四步 — 記錄不變性(invariant)。 快照在訂單建立時設定一次,之後永不更新。這是刻意設計:訂單歷史應反映事件發生當時的真實情況,而非今日的客戶地址。
這實際上是語意正確的反正規化:歷史紀錄通常應該捕捉事件當下的狀態,而非透過外鍵參照到最新資料。
相關 BEE
- BEE-5003: CQRS — 將讀取模型分離,作為一種結構化的反正規化模式
- BEE-6001: SQL vs NoSQL — 文件型資料庫在反正規化資料上的適用場景
- BEE-7001: Entity-Relationship 建模 — 正規化前如何識別實體
常見錯誤
量測前就反正規化。 在效能分析前就因為查詢「感覺很慢」而新增冗餘欄位,是過早優化。務必先量測。
過度正規化到 4NF 或 5NF。 更高階的正規形式處理多值相依性與 join 相依性,這些情況在典型的 OLTP 結構描述中極為罕見。超越 3NF 的正規化通常只是增加 join 而沒有帶來有意義的完整性效益。
反正規化欄位資料不同步。 最常見的失敗模式。更新邏輯被加入某個程式碼分支,但另一個分支(例如繞過正常 API 的管理後台)遺漏了更新。緩解方式:使用資料庫觸發器或物化視圖,讓資料庫(而非應用程式)來強制執行一致性。
未記錄反正規化的原因。 未來的維護者看到重複欄位卻沒有說明,只能三選一:把它當作 bug、刪掉它,或是再繼續複製。務必在程式碼或遷移記錄中留下說明:複製了什麼、為什麼、當初要達到的效能目標,以及同步機制。
將正規化視為全有或全無的選擇。 一個結構描述可以、也應該在某些地方正規化、在其他地方反正規化。核心交易型資料表應正規化;讀取優化的報表資料表可以反正規化。兩者之間的邊界才是重要的設計決策。
原則
預設正規化到 3NF。反正規化必須有量測依據、文件說明,以及明確的同步契約。
從正規化的結構描述出發。讓查詢效能分析告訴你瓶頸在哪裡。套用能解決瓶頸的最小範圍反正規化。記錄哪些東西被複製、原因是什麼,以及如何保持一致。將反正規化資料視為衍生資料——它永遠是正規化來源資料的次要補充,而非主體。