測驗:資料層設計 – 資料庫選型、Schema 與快取策略
共 5 題,點選答案後會立即顯示結果
1. 在系統架構中,為什麼資料庫通常是系統瓶頸?
2. 如果你要建一個涉及金錢交易的電商系統(訂單、支付),最適合選擇哪種資料庫?
3. 看到以下 SQLAlchemy 程式碼,ForeignKey("users.id") 代表什麼意思?
4. 關於「正規化」和「反正規化」,以下敘述何者正確?
5. 在 Cache-Aside Pattern 中,如果大量快取同時過期導致所有請求瞬間打到資料庫,這個問題叫什麼?常見的解法是什麼?
**系列**:工程師的系統設計(第 3 篇,共 4 篇)
**難度**:L2-進階
**前置知識**:第 1 篇系統設計思維入門、第 2 篇 API 設計、基本 SQL 語法(SELECT, INSERT, JOIN)
一句話說明
資料層是系統的「記憶」,選對資料庫、設計好結構、搭配快取,系統才不會慢到崩潰。
你將學會看懂的事
讀完這篇,你能夠:
- 看到專案用 PostgreSQL、MongoDB 或 Redis 時,理解為什麼選它
- 看到 Schema 定義時,理解表跟表之間的關聯
- 看到索引設定時,知道它在加速什麼
- 看到快取邏輯時,理解 Cache-Aside 等常見模式在幹嘛
1. 為什麼資料層通常是系統瓶頸
你的系統架構長這樣:
使用者 → API 伺服器 → 資料庫
(很快) (很慢)
API 伺服器的運算在記憶體裡完成,速度是奈秒等級。但資料庫要讀寫磁碟、處理鎖定、維護索引,速度是毫秒等級。差了 一百萬倍。
所以系統設計的核心問題之一就是:怎麼讓資料層不拖垮整個系統。
答案有三個方向:
- 選對資料庫 — 不同場景用不同工具
- 設計好 Schema — 讓查詢盡量簡單
- 加上快取 — 減少直接打資料庫的次數
接下來我們逐一看懂。
2. SQL vs NoSQL 選型指南
一句話區分
| 類型 | 代表 | 一句話 |
|---|---|---|
| 關聯式(SQL) | PostgreSQL, MySQL | 資料有嚴格結構,表跟表之間有關聯 |
| 文件型(NoSQL) | MongoDB | 資料像 JSON,結構彈性大 |
| 鍵值型(NoSQL) | Redis | 超快的「key-value」存取,常當快取用 |
什麼時候選哪個
你的資料有明確的關聯嗎?(用戶 → 訂單 → 商品)
→ 是:PostgreSQL(關聯式)
你的資料結構經常變動嗎?(每篇文章欄位都不同)
→ 是:MongoDB(文件型)
你需要超高速讀取嗎?(快取、排行榜、Session)
→ 是:Redis(鍵值型)
最小範例:三種資料庫存同一筆「用戶」資料
PostgreSQL — 結構嚴格,先定義欄位才能存
CREATE TABLE users (
id SERIAL PRIMARY KEY, -- 自動遞增的 ID
name VARCHAR(100) NOT NULL, -- 名字,不能空
email VARCHAR(255) UNIQUE -- 信箱,不能重複
);
INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com');
Code language: PHP (php)MongoDB — 直接丟 JSON 進去
db.users.insertOne({
name: "Alice",
email: "alice@example.com",
hobbies: ["reading", "coding"] // 想多加欄位?直接加
})
Code language: CSS (css)Redis — 用 key 對應 value,像字典一樣
SET user:1:name "Alice"
SET user:1:email "alice@example.com"
Code language: CSS (css)翻譯對照
| 你會看到 | 意思 |
|---|---|
SERIAL PRIMARY KEY |
自動產生的唯一編號 |
NOT NULL |
這個欄位必填 |
UNIQUE |
這個欄位的值不能重複 |
VARCHAR(100) |
最多 100 字的文字 |
insertOne({...}) |
塞一筆文件(JSON)進 MongoDB |
SET key value |
在 Redis 存一個鍵值對 |
ACID 是什麼(知道就好)
你會在資料庫文件裡常看到 ACID,翻譯一下:
- Atomicity(原子性):一組操作要嘛全成功,要嘛全失敗
- Consistency(一致性):資料永遠保持合理狀態
- Isolation(隔離性):多人同時操作不會互相干擾
- Durability(持久性):存進去就不會丟
PostgreSQL 天生支援 ACID,MongoDB 和 Redis 在這方面比較弱。所以涉及金錢的系統(電商、支付)幾乎都選關聯式資料庫。
3. Schema 設計實戰:電商系統
最小範例:用 Python + SQLAlchemy 定義 Schema
SQLAlchemy 是 Python 最常用的資料庫工具,讓你用 Python 類別定義資料表:
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime
from sqlalchemy.orm import declarative_base, relationship
from datetime import datetime
Base = declarative_base() # 所有 Model 的基底類別
class User(Base):
__tablename__ = "users" # 對應資料庫裡的 users 表
id = Column(Integer, primary_key=True) # 主鍵
name = Column(String(100), nullable=False) # 必填
email = Column(String(255), unique=True) # 不能重複
orders = relationship("Order", back_populates="user")
# ↑ 這行的意思:一個 User 可以有多筆 Order(一對多)
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True)
name = Column(String(200), nullable=False)
price = Column(Float, nullable=False)
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id")) # 外鍵:指向 users 表
product_id = Column(Integer, ForeignKey("products.id"))
quantity = Column(Integer, default=1)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="orders")
# ↑ 反向關聯:從 Order 可以找到對應的 User
逐行翻譯重點
Base = declarative_base()
# ↑ 建立一個「基底」,之後所有 Model 都繼承它
__tablename__ = "users"
# ↑ 這個 Python 類別對應資料庫裡叫 "users" 的表
Column(Integer, primary_key=True)
# ↑ 整數欄位,是這張表的主鍵(唯一識別碼)
Column(String(255), unique=True)
# ↑ 最多 255 字的文字,值不能重複
ForeignKey("users.id")
# ↑ 外鍵:這個欄位的值必須是 users 表裡某個 id
relationship("Order", back_populates="user")
# ↑ 定義關聯:讓你可以用 user.orders 取得該用戶的所有訂單
Code language: PHP (php)關聯圖:看懂表跟表的關係
users orders products
┌─────────┐ ┌──────────────┐ ┌──────────┐
│ id (PK) │◄───│ user_id (FK) │ │ id (PK) │
│ name │ │ product_id(FK)│───►│ name │
│ email │ │ quantity │ │ price │
└─────────┘ │ created_at │ └──────────┘
└──────────────┘
PK = Primary Key(主鍵,唯一識別)
FK = Foreign Key(外鍵,指向另一張表)
一個 User 可以有很多 Order(一對多),一個 Order 對應一個 Product。
4. 正規化 vs 反正規化
一句話區分
- 正規化:拆表,消除重複資料(適合「寫多讀少」)
- 反正規化:合表,冗餘儲存加快查詢(適合「讀多寫少」)
最小範例:訂單要顯示商品名稱
正規化做法(拆表):
-- 查訂單時要 JOIN 才能拿到商品名稱
SELECT orders.id, products.name, products.price
FROM orders
JOIN products ON orders.product_id = products.id
WHERE orders.user_id = 1;
翻譯:「從 orders 表和 products 表裡,用 product_id 把它們關聯起來,找出用戶 1 的所有訂單。」
反正規化做法(合表,冗餘儲存):
-- 訂單表直接存商品名稱和價格(不用 JOIN)
SELECT id, product_name, product_price
FROM orders
WHERE user_id = 1;
翻譯:「直接從 orders 表拿,因為商品名稱和價格已經複製一份存在裡面了。」
怎麼選
你的場景是「讀多寫少」嗎?(商品頁面、文章列表)
→ 傾向反正規化:查詢快,少做 JOIN
你的場景是「寫多讀少」嗎?(後台管理、資料匯入)
→ 傾向正規化:修改一處就生效,不用到處同步
你的場景涉及金錢或庫存嗎?
→ 正規化:避免資料不一致(商品改價格後訂單還顯示舊價格)
實務上的做法:先正規化設計,遇到查詢效能問題再局部反正規化。不要一開始就為了效能犧牲資料一致性。
5. 索引策略:讓查詢快一百倍
一句話說明
索引像書的「目錄」,讓資料庫不用翻遍整本書就能找到你要的頁面。
沒有索引 vs 有索引
沒有索引(全表掃描):
從第 1 筆掃到第 100 萬筆,找 email = 'alice@example.com'
→ 很慢
有索引:
直接跳到 'alice@example.com' 所在的位置
→ 很快
Code language: JavaScript (javascript)B-Tree 索引原理(概念層面)
你不需要理解演算法細節,只需要知道:
B-Tree 索引像一棵倒過來的樹:
[M]
/ \
[D,H] [Q,T]
/ | \ / | \
資料 資料 資料 資料
要找 "H":
1. 看根節點 → H < M → 走左邊
2. 看左節點 → H = H → 找到了!
100 萬筆資料只要比對約 20 次就能找到(不是 100 萬次)
Code language: HTML, XML (xml)用 SQLAlchemy 建立索引
from sqlalchemy import Column, Integer, String, Index
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String(100))
email = Column(String(255), unique=True, index=True)
# ↑ 加上 index=True 就建了索引
city = Column(String(100))
age = Column(Integer)
# 複合索引:同時用兩個欄位查詢時很有用
Index("idx_city_age", User.city, User.age)
# ↑ 當查詢是 WHERE city = 'Taipei' AND age > 20 時,這個索引會加速
逐行翻譯
index=True
# ↑ 替這個欄位建立索引,用這個欄位查詢時會變快
Index("idx_city_age", User.city, User.age)
# ↑ 建立「複合索引」,名字叫 idx_city_age
# 當你同時用 city 和 age 查詢時會加速
Code language: PHP (php)什麼時候該加索引、什麼時候不該
該加索引的情況:
- WHERE 條件常用的欄位(如 email, user_id)
- JOIN 的外鍵欄位
- ORDER BY 排序的欄位
不該加索引的情況:
- 表很小(幾百筆),全表掃描反而更快
- 很少用來查詢的欄位
- 經常大量寫入的表(索引會拖慢寫入速度)
覆蓋索引(知道就好)
-- 假設有索引 (city, age, name)
SELECT name FROM users WHERE city = 'Taipei' AND age > 20;
-- ↑ 所有需要的欄位都在索引裡,資料庫連表都不用查
-- 這叫「覆蓋索引」,是最快的查詢方式
Code language: JavaScript (javascript)6. 快取層設計
為什麼需要快取
沒有快取:
每次請求 → 查資料庫 → 回應
100 個人同時看同一個商品 → 資料庫被查 100 次
有快取:
第 1 次請求 → 查資料庫 → 存到快取 → 回應
後續 99 次 → 直接從快取拿 → 回應
資料庫只被查 1 次
Redis 是最常見的快取工具,讀取速度在微秒等級(比資料庫快 100 倍以上)。
Cache-Aside Pattern(最常見)
這是你在程式碼裡最常看到的快取模式:
import redis
import json
r = redis.Redis(host="localhost", port=6379, db=0)
# ↑ 連接本機的 Redis
def get_product(product_id: int) -> dict:
cache_key = f"product:{product_id}"
# ↑ 快取的 key,例如 "product:42"
# 1. 先查快取
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# ↑ 快取命中!直接回傳(不查資料庫)
# 2. 快取沒有,查資料庫
product = db.query(Product).filter(Product.id == product_id).first()
# 3. 寫入快取,設定 300 秒過期
r.setex(cache_key, 300, json.dumps(product.to_dict()))
# ↑ setex = SET + EXpire,存值並設定過期時間
return product.to_dict()
Code language: PHP (php)逐行翻譯
r = redis.Redis(host="localhost", port=6379, db=0)
# ↑ 連到 Redis 伺服器(預設 port 6379,用第 0 號資料庫)
r.get(cache_key)
# ↑ 從 Redis 拿資料,沒有的話回傳 None
r.setex(cache_key, 300, json.dumps(data))
# ↑ 存資料到 Redis,300 秒後自動過期刪除
# json.dumps 是把 Python 字典轉成 JSON 字串
Code language: PHP (php)三種快取策略比較
| 策略 | 運作方式 | 適用場景 |
|---|---|---|
| Cache-Aside | 讀:先查快取,沒有才查 DB 再寫入快取 | 最常用,適合大部分讀多場景 |
| Write-Through | 寫:同時寫 DB 和快取 | 需要快取永遠最新的場景 |
| Write-Behind | 寫:先寫快取,之後再批次寫 DB | 寫入量極大的場景(如計數器) |
最小範例:三種策略的 Python 寫法
Cache-Aside(上面已經展示過,這是最常見的)
Write-Through:
def update_product(product_id: int, data: dict):
# 同時更新資料庫和快取
db.query(Product).filter(Product.id == product_id).update(data)
db.commit()
# ↑ 先寫資料庫
r.setex(f"product:{product_id}", 300, json.dumps(data))
# ↑ 再寫快取(保持快取最新)
Code language: PHP (php)Write-Behind:
def increment_view_count(product_id: int):
r.incr(f"product:{product_id}:views")
# ↑ 先只寫 Redis(超快)
# 資料庫由背景排程定期同步(例如每 5 分鐘)
Code language: PHP (php)快取常見問題
你在看系統設計文件時會遇到這三個術語:
快取穿透(Cache Penetration)
問題:查詢不存在的資料,快取永遠沒有,每次都打 DB
例如:惡意請求 product_id = -1(根本不存在)
解法:查不到也存一個空值到快取
def get_product_safe(product_id: int):
cached = r.get(f"product:{product_id}")
if cached == b"NULL":
return None # 已知不存在,不查 DB
if cached:
return json.loads(cached)
product = db.query(Product).filter(Product.id == product_id).first()
if product is None:
r.setex(f"product:{product_id}", 60, "NULL")
# ↑ 不存在也存起來,60 秒過期
return None
r.setex(f"product:{product_id}", 300, json.dumps(product.to_dict()))
return product.to_dict()
Code language: PHP (php)快取雪崩(Cache Avalanche)
問題:大量快取同時過期,瞬間所有請求都打 DB
例如:所有商品快取都設 300 秒,300 秒後全部同時失效
解法:過期時間加上隨機值
import random
ttl = 300 + random.randint(0, 60)
# ↑ 過期時間在 300~360 秒之間隨機
# 這樣不同商品的快取不會同時失效
r.setex(cache_key, ttl, data)
Code language: PHP (php)快取擊穿(Cache Breakdown)
問題:某個超熱門的快取過期,瞬間大量請求同時查 DB
例如:首頁推薦商品的快取剛好過期
解法:用鎖讓只有一個請求去查 DB,其他請求等待
def get_hot_product(product_id: int):
cached = r.get(f"product:{product_id}")
if cached:
return json.loads(cached)
lock_key = f"lock:product:{product_id}"
if r.set(lock_key, "1", nx=True, ex=5):
# ↑ nx=True 表示「只有當 key 不存在時才設定」
# ex=5 表示鎖最多存在 5 秒(防止死鎖)
# 只有一個請求能拿到這把鎖
product = db.query(Product).filter(Product.id == product_id).first()
r.setex(f"product:{product_id}", 300, json.dumps(product.to_dict()))
r.delete(lock_key)
return product.to_dict()
else:
import time
time.sleep(0.1)
return get_hot_product(product_id)
# ↑ 沒拿到鎖的請求,等一下再試
Code language: PHP (php)7. 資料一致性與 CAP 定理
CAP 定理的直觀理解
分散式系統(多台機器)只能三選二:
C - Consistency(一致性):每台機器看到的資料都一樣
A - Availability(可用性):每次請求都能得到回應
P - Partition Tolerance(分區容忍):網路斷線時系統還能運作
C
/ \
/ \
A --- P
現實中 P 是必選的(網路一定會出問題),
所以實際上是在 C 和 A 之間取捨。
翻譯對照
| 選擇 | 白話 | 代表 |
|---|---|---|
| CP(選一致性) | 寧可回應慢,也不能給錯誤資料 | 銀行轉帳、庫存扣減 |
| AP(選可用性) | 寧可資料稍微過期,也要快速回應 | 社群媒體動態、商品瀏覽數 |
最終一致性 vs 強一致性
強一致性:
寫入後,所有人立刻看到新資料
例:銀行轉帳,轉完錢餘額立刻更新
最終一致性:
寫入後,可能有幾秒延遲才同步到所有節點
例:社群貼文的按讚數,不同人可能短暫看到不同數字
看到程式碼裡有「eventual consistency」的註解,就是在說「這邊允許短暫的資料不一致」。
8. 完整實戰:電商商品查詢系統
把以上所有概念整合成一個完整範例:
"""
電商商品查詢系統
用到:SQLAlchemy(資料庫)+ Redis(快取)+ Cache-Aside Pattern
"""
from sqlalchemy import create_engine, Column, Integer, String, Float, Index
from sqlalchemy.orm import declarative_base, Session
import redis
import json
import random
# --- 資料庫設定 ---
engine = create_engine("postgresql://user:pass@localhost/shop")
# ↑ 連接 PostgreSQL 資料庫,資料庫名稱叫 shop
Base = declarative_base()
# --- Redis 設定 ---
cache = redis.Redis(host="localhost", port=6379, db=0)
# ↑ 連接 Redis,用來做快取
# --- Schema 定義 ---
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True)
name = Column(String(200), nullable=False)
price = Column(Float, nullable=False)
category = Column(String(100), index=True)
# ↑ category 加了索引,用分類查詢時會快很多
# 複合索引:常一起查的欄位放在同一個索引
Index("idx_category_price", Product.category, Product.price)
# ↑ 加速 WHERE category = 'electronics' ORDER BY price 這類查詢
# --- 商品查詢(含快取) ---
def get_product_by_id(product_id: int) -> dict | None:
"""
Cache-Aside Pattern:
1. 先查 Redis 快取
2. 沒有就查 PostgreSQL
3. 查到後寫回快取
"""
cache_key = f"product:{product_id}"
# 步驟 1:查快取
cached = cache.get(cache_key)
if cached:
if cached == b"NULL":
return None # 已知不存在的商品(防快取穿透)
return json.loads(cached)
# 步驟 2:查資料庫
with Session(engine) as session:
product = session.query(Product).filter(
Product.id == product_id
).first()
# 步驟 3:寫入快取
if product is None:
cache.setex(cache_key, 60, "NULL")
# ↑ 商品不存在也快取 60 秒(防穿透)
return None
result = {
"id": product.id,
"name": product.name,
"price": product.price,
"category": product.category,
}
ttl = 300 + random.randint(0, 60)
# ↑ 隨機過期時間 300~360 秒(防雪崩)
cache.setex(cache_key, ttl, json.dumps(result))
return result
# --- 依分類查詢商品列表 ---
def get_products_by_category(category: str) -> list[dict]:
"""
列表查詢通常不快取每一筆,而是快取整個查詢結果
"""
cache_key = f"products:category:{category}"
cached = cache.get(cache_key)
if cached:
return json.loads(cached)
with Session(engine) as session:
products = session.query(Product).filter(
Product.category == category
).order_by(Product.price).all()
# ↑ 這個查詢會用到 idx_category_price 複合索引
result = [
{"id": p.id, "name": p.name, "price": p.price}
for p in products
]
ttl = 120 + random.randint(0, 30)
cache.setex(cache_key, ttl, json.dumps(result))
return result
# --- 更新商品(Write-Through) ---
def update_product(product_id: int, name: str, price: float):
"""
Write-Through:同時更新資料庫和快取
"""
with Session(engine) as session:
product = session.query(Product).filter(
Product.id == product_id
).first()
if product:
product.name = name
product.price = price
session.commit()
# ↑ 先更新資料庫
result = {
"id": product.id,
"name": product.name,
"price": product.price,
"category": product.category,
}
cache.setex(
f"product:{product_id}",
300,
json.dumps(result)
)
# ↑ 再更新快取(確保快取跟 DB 一致)
# 清除分類列表快取(因為商品資料變了)
cache.delete(f"products:category:{product.category}")
這段程式碼做了什麼
- 用 SQLAlchemy 定義了
Product表的 Schema,包含索引設定 get_product_by_id實作了 Cache-Aside Pattern,並防範快取穿透和雪崩get_products_by_category展示了列表查詢的快取策略update_product實作了 Write-Through,更新時同步 DB 和快取
Vibe Coder 檢查點
看到資料層相關的程式碼時,確認這些重點:
資料庫選型
- [ ] 有錢相關的邏輯嗎?應該用關聯式資料庫(PostgreSQL/MySQL)
- [ ] Schema 會經常變動嗎?可以考慮 MongoDB
- [ ] 需要超快讀取嗎?Redis 適合做快取或排行榜
Schema 設計
- [ ] 表之間的關聯(ForeignKey)合理嗎?
- [ ] 有沒有必要的 NOT NULL 和 UNIQUE 約束?
- [ ] 是正規化還是反正規化?跟使用場景匹配嗎?
索引
- [ ] WHERE 常用的欄位有加索引嗎?
- [ ] 是不是加太多索引了?(會拖慢寫入)
- [ ] 複合索引的欄位順序對嗎?(最常過濾的放前面)
快取
- [ ] 用了什麼快取策略?(Cache-Aside / Write-Through / Write-Behind)
- [ ] 有設過期時間嗎?(沒設的話快取會永遠不更新)
- [ ] 有處理快取穿透嗎?(不存在的資料也要快取)
- [ ] 過期時間有加隨機值嗎?(防雪崩)
必看懂 vs 知道就好
必看懂(系統設計核心)
- SQL vs NoSQL 的選型邏輯
- Schema 的 ForeignKey 和 relationship
- Cache-Aside Pattern(最常見的快取模式)
- 索引的基本概念(為什麼能加速查詢)
知道就好(遇到再查)
- B-Tree 的具體實作細節
- 覆蓋索引的最佳化技巧
- Write-Behind 的批次同步實作
- CAP 定理的數學證明
- 分散式鎖的各種實作方式(Redlock 等)
總結
資料層設計三件事:
1. 選對資料庫
└ 有關聯 → SQL、結構彈性 → NoSQL、要快 → Redis
2. 設計好 Schema
└ 先正規化 → 遇到效能問題再局部反正規化
└ 常查的欄位加索引
3. 搭配快取
└ Cache-Aside 是最常見的模式
└ 注意穿透、雪崩、擊穿三個問題
下一篇我們將進入系統設計的最後一塊拼圖:可擴展性與部署架構,學會如何讓系統從單機擴展到分散式。
進階測驗:資料層設計 – 資料庫選型、Schema 與快取策略
共 5 題,包含情境題與錯誤診斷題。