EP.04系統設計系列

資料庫設計入門:
ER Diagram、一對多、正規化

從「一張超大表」的痛苦,到規範化設計的清爽—— 系統設計面試必備。你的表結構決定了系統的上限。

Joseph Chen 2026 16 min read 資料庫設計 · ER Diagram · 正規化 · SQL

「你要設計一個社群媒體:每個用戶可以發多篇文章,每篇文章可以有多個留言, 用戶可以追蹤其他用戶。你的資料庫要怎麼設計? 把所有資料放在一張表?你很快就會後悔。」

很多初學者的第一張表都是「超大表」:什麼都塞進去,簡單直接。 短期沒問題,但三個月後你就知道痛苦了——更新一個欄位要動幾百列, 刪一個資料意外把另一個刪掉,插入新資料發現欄位是 NULL... 這篇帶你從根本上理解「好的資料庫設計」是什麼樣子。

從「一張超大表」開始

先看一個壞設計長什麼樣子。假設你要設計一個部落格系統,你想: 「用戶、文章、留言都有關聯,不如放在一起最方便。」

壞設計:把所有東西塞進一張表

users_posts_comments_table(壞設計)
┌────────┬──────────┬─────────────┬──────────────┬──────────────────┐
│ user_id│ username │ email       │ post_title   │ comment_text     │
├────────┼──────────┼─────────────┼──────────────┼──────────────────┤
│   1    │ Joseph   │ j@mail.com  │ 我的第一篇文 │ 好文!           │
│   1    │ Joseph   │ j@mail.com  │ 我的第一篇文 │ 讚讚讚           │
│   1    │ Joseph   │ j@mail.com  │ 我的第二篇文 │ NULL             │
│   2    │ Mary     │ m@mail.com  │ NULL         │ NULL             │
└────────┴──────────┴─────────────┴──────────────┴──────────────────┘

問題一眼可見:
- Joseph 的 email 重複出現了好幾列
- Mary 沒有文章和留言,大量 NULL
- 刪除 Joseph 的文章,可能連他的帳號資料一起刪了

這不只是「看起來醜」的問題,而是會造成三種具體的「資料異常」—— 資料庫設計理論稱之為更新異常、插入異常、刪除異常

✏️

更新異常(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

部落格系統 ER Diagram(ASCII Art)
users                posts              comments
┌──────────┐         ┌──────────┐       ┌──────────────┐
│ user_id  │1       N│ post_id  │1     N│ comment_id   │
│ username │─────────│ user_id  │───────│ post_id      │
│ email    │         │ title    │       │ user_id      │
│ password │         │ content  │       │ text         │
│ created  │         │ created  │       │ created_at   │
└──────────┘         └──────────┘       └──────────────┘

一個 user 可以有多個 posts(一對多 1:N)
一個 post 可以有多個 comments(一對多 1:N)
一個 user 也可以發多個 comments(一對多 1:N)

          follows(追蹤關係)
┌──────────────────────────────┐
│ follower_id(user_id)       │  ← 追蹤者
│ following_id(user_id)      │  ← 被追蹤者
└──────────────────────────────┘

users 追蹤 users → 同一實體的多對多(M:N)
透過中間表 follows 解決(稍後說明)

一對一(1:1)

用戶 ↔ 用戶設定檔

資訊可選、讀取頻率不同時分表

一對多(1:N)

用戶 → 文章、訂單 → 明細

最常見的關係,用外鍵實現

多對多(M:N)

用戶 ↔ 用戶(追蹤)、訂單 ↔ 商品

需要中間表(Junction Table)


正規化:每個事實只存一次

資料庫正規化(Normalization)是一套設計原則,核心思想只有一句話:每個事實在資料庫裡只存一次,不要重複。正規化分 1NF、2NF、3NF,從低到高逐步消除冗餘。

正規化的直覺

想像你在整理衣服:1NF 是「每件衣服放進格子裡,不要疊放」; 2NF 是「按照類型分層,不要把毛衣和短袖混在同一格」; 3NF 是「把品牌資訊移到品牌區,不要在每件衣服上貼品牌說明書」。 每一層都是在減少「同一件事說兩遍」的情況。

1NF

第一正規形式:原子值,無重複欄位

每個欄位只存一個值(原子值),不能存清單或陣列。 不能有 phone1、phone2、phone3 這種重複欄位。

1NF 示範
-- ❌ 違反 1NF:一個欄位存多個值
CREATE TABLE users_bad (
  user_id  INT,
  phones   VARCHAR(100)  -- '0912-123456, 0955-654321'(一格存了兩支電話)
);

-- ❌ 也違反 1NF:重複欄位
CREATE TABLE users_bad2 (
  user_id  INT,
  phone1   VARCHAR(20),  -- 第一支電話
  phone2   VARCHAR(20),  -- 第二支電話(如果沒有就 NULL)
  phone3   VARCHAR(20)   -- 第三支電話
);

-- ✅ 符合 1NF:每個事實一列
CREATE TABLE user_phones (
  user_id  INT REFERENCES users(user_id),
  phone    VARCHAR(20) NOT NULL,
  PRIMARY KEY (user_id, phone)  -- 一個用戶同一支電話只存一次
);
2NF

第二正規形式:消除部分依賴

所有非鍵欄位都必須完全依賴於整個主鍵,不能只依賴主鍵的一部分。 這個問題只出現在組合主鍵的情況。

2NF 示範
-- ❌ 違反 2NF
-- 組合主鍵是 (order_id, product_id)
-- 但 product_name 只依賴 product_id,不依賴整個組合主鍵
CREATE TABLE order_items_bad (
  order_id     INT,
  product_id   INT,
  product_name VARCHAR(100),  -- ← 只依賴 product_id,部分依賴!
  quantity     INT,
  PRIMARY KEY (order_id, product_id)
);

-- ✅ 符合 2NF:把 product_name 移到 products 表
CREATE TABLE products (
  product_id   INT PRIMARY KEY,
  product_name VARCHAR(100) NOT NULL,
  price        DECIMAL(10, 2) NOT NULL
);

CREATE TABLE order_items (
  order_id     INT REFERENCES orders(order_id),
  product_id   INT REFERENCES products(product_id),
  quantity     INT NOT NULL,
  PRIMARY KEY (order_id, product_id)
);
-- 現在 order_items 裡的每個欄位都完全依賴組合主鍵
3NF

第三正規形式:消除遞移依賴

非鍵欄位不能依賴另一個非鍵欄位。用白話說:「A 欄位決定 B 欄位,B 欄位決定 C 欄位, 但 C 跟主鍵沒有直接關係」——這就是遞移依賴,要消除。

3NF 示範
-- ❌ 違反 3NF
-- user_id → zip_code → city(city 透過 zip_code 間接依賴 user_id)
CREATE TABLE users_bad (
  user_id   INT PRIMARY KEY,
  zip_code  CHAR(5),
  city      VARCHAR(50)  -- ← city 依賴 zip_code,不直接依賴 user_id
);

-- 問題:台北市的 100 郵遞區號存了幾萬次
-- 台北市改名時,要更新幾萬列

-- ✅ 符合 3NF:把 city 移到獨立的 zip_codes 表
CREATE TABLE zip_codes (
  zip_code  CHAR(5) PRIMARY KEY,
  city      VARCHAR(50) NOT NULL,
  district  VARCHAR(50)
);

CREATE TABLE users (
  user_id   INT PRIMARY KEY,
  zip_code  CHAR(5) REFERENCES zip_codes(zip_code)
);
-- 城市名稱只存一次,修改時只要改一列

正規化的取捨

正規化的好處

無資料重複,更新只需改一列
插入、刪除不會造成異常
節省儲存空間(消除冗餘)

過度正規化的壞處

查詢需要大量 JOIN,效能變差
程式碼複雜度增加
OLAP(分析型)場景反而要反正規化

實務建議:OLTP(交易型,如電商、社群)先做到 3NF;OLAP(分析型,如報表、BI)可以用星型架構(反正規化)提升查詢速度。


實戰:設計電商資料庫

理論說夠了,來設計一個真實的系統。電商是資料庫設計面試最常見的題目, 一步一步走過設計流程,比背答案有用多了。

需求清單

用戶可以下訂單
一個訂單包含多個商品
每個商品有庫存數量
商品有分類(一個商品屬於一個分類)
訂單有狀態:pending / paid / shipped / delivered
下訂時要記錄當時的售價(商品價格可能變動)

第一步:找出實體

users(用戶)products(商品)categories(分類)orders(訂單)order_items(訂單明細)

order_items 是訂單和商品之間的中間表——因為一張訂單可以包含多個商品, 一個商品也可以出現在多張訂單,這是典型的多對多(M:N)關係,需要中間表來解決。

第二步:定義關係

實體關係梳理
users      1:N  orders        (一個用戶有多張訂單)
orders     1:N  order_items   (一張訂單有多個商品明細)
products   1:N  order_items   (一個商品可在多張訂單出現)
categories 1:N  products      (一個分類有多個商品)

orders ↔ products 是 M:N,透過 order_items 中間表解決

第三步:建表

電商資料庫完整 Schema
-- 用戶表
CREATE TABLE users (
  user_id    BIGSERIAL PRIMARY KEY,
  email      VARCHAR(255) UNIQUE NOT NULL,
  username   VARCHAR(100) NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- 商品分類表
CREATE TABLE categories (
  category_id   BIGSERIAL PRIMARY KEY,
  name          VARCHAR(100) NOT NULL,
  parent_id     BIGINT REFERENCES categories(category_id)
  -- 自引用:支援多層分類(如 電子產品 > 手機 > 智慧型手機)
);

-- 商品表
CREATE TABLE products (
  product_id   BIGSERIAL PRIMARY KEY,
  category_id  BIGINT REFERENCES categories(category_id),
  name         VARCHAR(255) NOT NULL,
  description  TEXT,
  price        DECIMAL(10, 2) NOT NULL CHECK (price >= 0),
  stock        INT NOT NULL DEFAULT 0 CHECK (stock >= 0),
  created_at   TIMESTAMPTZ DEFAULT NOW()
);

-- 訂單表
CREATE TABLE orders (
  order_id    BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL REFERENCES users(user_id),
  status      VARCHAR(20) NOT NULL DEFAULT 'pending',
               -- pending | paid | shipped | delivered | cancelled
  total       DECIMAL(10, 2),  -- 可用觸發器或應用層計算
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  updated_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 訂單明細(orders ↔ products 的中間表)
CREATE TABLE order_items (
  order_id    BIGINT NOT NULL REFERENCES orders(order_id),
  product_id  BIGINT NOT NULL REFERENCES products(product_id),
  quantity    INT NOT NULL CHECK (quantity > 0),
  unit_price  DECIMAL(10, 2) NOT NULL,
  -- ↑ 關鍵設計:記錄下訂時的售價
  -- 商品日後漲價或打折,訂單歷史不受影響
  PRIMARY KEY (order_id, product_id)  -- 組合主鍵,同一訂單同一商品只能一列
);

設計亮點: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 也有成本:每次寫入都要更新索引, 索引太多反而讓寫入變慢,也佔空間。

根據查詢分析決定 Index
-- 常見查詢一:查某個用戶的所有訂單
SELECT * FROM orders WHERE user_id = 1;
-- user_id 不是主鍵,沒有 Index → 全表掃描
CREATE INDEX idx_orders_user_id ON orders(user_id);  -- ← 加這個

-- 常見查詢二:查某張訂單的所有商品
SELECT p.name, oi.quantity, oi.unit_price
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id
WHERE oi.order_id = 42;
-- order_id 是組合主鍵的一部分 → 已有索引,不需另加

-- 常見查詢三:查庫存不足的商品(股控警示)
SELECT * FROM products WHERE stock < 10;
-- 通常資料量小(幾千筆商品),全表掃描可接受
-- 如果商品很多,可以考慮:
CREATE INDEX idx_products_stock ON products(stock);

-- 常見查詢四:查某個分類下的所有商品
SELECT * FROM products WHERE category_id = 5;
CREATE INDEX idx_products_category ON products(category_id);  -- ← 加

-- 組合 Index:查用戶在某個日期之後的訂單(多欄位過濾)
SELECT * FROM orders WHERE user_id = 1 AND created_at > '2026-01-01';
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);
-- 規則:選擇性高(唯一值多)的欄位放前面

一定要加

WHERE 常用的外鍵欄位(user_id、order_id)、JOIN 的 ON 欄位

考慮加

ORDER BY 欄位(created_at)、高選擇性的過濾欄位

通常不需要

低選擇性欄位(如 status 只有 5 種值)、小表(幾千列以下)

延伸閱讀

Index 的完整原理(B-tree 索引、複合索引的最左前綴原則、Index 的代價分析), 在 Database 系列 EP.02 Index 有詳細說明,這篇先掌握「什麼情況要加」的直覺。

本篇重點回顧

🏗️超大表的三個問題:更新異常、插入異常、刪除異常。根本原因是把不同「事物」塞在同一張表。
🗺️ER Diagram 是設計前的必要步驟:找出實體(什麼東西)、屬性(它有什麼)、關係(它們怎麼連)。
1️⃣1NF:每個欄位只存一個值,不存清單,不存重複欄位。
2️⃣2NF:消除部分依賴——在組合主鍵的表中,每個非鍵欄位必須完全依賴整個主鍵。
3️⃣3NF:消除遞移依賴——非鍵欄位不能透過另一個非鍵欄位間接依賴主鍵。
🛒電商設計核心:users / products / orders / order_items。order_items 是中間表,unit_price 記錄下訂時的售價。
📍Index 加在 WHERE 常用的外鍵欄位和 JOIN 條件欄位。低選擇性欄位(只有幾種值)和小表不需要加。

上一篇

EP.03 — 快取策略

讓系統快 10 倍的關鍵決策

下一篇

EP.05 — Coming Soon

準備中,敬請期待

資料庫設計ER Diagram正規化SQL系統設計EP.04