測驗:資料庫優化與擴展 — 索引、複製、分片與快取
共 5 題,點選答案後會立即顯示結果
1. 資料庫索引(Index)最主要的作用是什麼?
2. Replication(複製)和 Sharding(分片)最核心的差異是什麼?
3. 在 Cache-Aside 模式中,當快取沒有命中(Cache Miss)時,系統會怎麼做?
4. 一個使用者更新大頭照後立刻重新整理頁面,卻看到舊的大頭照。這最可能是哪個概念造成的問題?
5. 遇到資料庫效能問題時,文章建議的處理優先順序為何?
**系列**:系統設計 30 概念(第 4 篇,共 6 篇)
**難度**:L2-進階
**前置知識**:本系列第 2 篇(API 設計與資料儲存)、第 3 篇(系統擴展)
**影片來源**:*System Design was HARD until I Learned these 30 Concepts* — Ashish Pratap Singh (YouTube)
這篇在講什麼
上一篇我們聊了「系統怎麼擴展」。但擴展只是把機器加多,真正要讓系統變快,還得從資料庫下手。這篇涵蓋系統設計 30 概念中的第 15-20 個,全部圍繞同一個主題:怎麼讓資料庫又快又穩。
讀完這篇你會知道:
- Database Indexing 如何加速查詢,以及它的代價
- Replication 與 Sharding 的差異 — 一個是「複製」,一個是「切割」
- Caching 和 Denormalization 如何犧牲一致性換取讀取效能
- Vertical Partitioning 按欄位拆分資料表的概念
概念 15:Database Indexing(資料庫索引)
一句話說明
索引就是資料庫的「目錄」,讓查詢不用一筆一筆翻。
最小範例
-- 沒有索引:資料庫要掃描整張表,一行一行找
SELECT * FROM users WHERE email = '[email protected]';
-- 如果有 100 萬筆,最差要看 100 萬次
-- 建立索引
CREATE INDEX idx_users_email ON users(email);
-- 有索引後:同樣的查詢,資料庫直接「翻目錄」找到位置
SELECT * FROM users WHERE email = '[email protected]';
-- 不管有幾百萬筆,幾乎瞬間找到
Code language: JavaScript (javascript)用書來比喻
想像一本 1000 頁的書:
| 情境 | 沒有索引 | 有索引 |
|---|---|---|
| 找「快取」在哪一頁 | 從第 1 頁翻到第 1000 頁 | 翻到目錄,直接跳到第 487 頁 |
| 資料庫操作 | Full Table Scan(全表掃描) | Index Lookup(索引查找) |
| 速度 | O(n) — 資料越多越慢 | O(log n) — 幾乎不受資料量影響 |
索引的代價
索引不是免費的。每次你新增、修改、刪除資料,資料庫都要同步更新索引:
-- 每次 INSERT,資料庫要做兩件事:
-- 1. 寫入資料到 users 表
-- 2. 更新 idx_users_email 索引
INSERT INTO users (name, email) VALUES ('Bob', '[email protected]');
-- 索引越多,寫入越慢
-- 如果你對 5 個欄位都建了索引,每次 INSERT 要更新 5 個索引
Code language: JavaScript (javascript)白話翻譯:索引就像書的目錄。目錄讓你查東西很快,但每次書的內容改了,目錄也要跟著改。目錄越多,改起來越累。
什麼時候該建索引
建索引的好時機:
- WHERE 子句常用的欄位(如 email、user_id)
- JOIN 的欄位
- ORDER BY 的欄位
不需要索引的情況:
- 很少查詢的欄位
- 經常大量 INSERT 的表(索引拖慢寫入)
- 資料量本身就很小的表(全掃也很快)
Vibe Coder 檢查點
看到資料庫相關程式碼時確認:
- 查詢很慢?先檢查有沒有對 WHERE 條件的欄位建索引
- AI 幫你建了一堆索引?想想那些欄位是不是真的常被查
- 寫入變慢了?檢查是不是索引太多
概念 16:Replication(複製)
一句話說明
把同一份資料複製到多台伺服器,讀取分散、掛了不怕。
運作原理
┌──── Replica 1 ◄── 讀取請求
│ (完整副本)
寫入請求 ──► Primary ──┤
(主庫) │
└──── Replica 2 ◄── 讀取請求
(完整副本)
白話翻譯:
- Primary(主庫):唯一負責「寫入」的資料庫
- Replica(副本):Primary 的完整複製品,只負責「讀取」
- 所有寫入都送到 Primary,然後 Primary 把變更同步到所有 Replica
最小範例
用一個 Web 應用的設定來理解:
# 虛擬碼:應用程式怎麼使用 Replication
DATABASE = {
"primary": "db-primary.example.com:5432", # 寫入用這個
"replicas": [
"db-replica-1.example.com:5432", # 讀取用這些
"db-replica-2.example.com:5432",
]
}
def create_user(name, email):
# 寫入 → 送到 Primary
primary_db.execute("INSERT INTO users ...")
def get_user(user_id):
# 讀取 → 送到任一 Replica(分散負載)
replica = random.choice(replicas)
return replica.execute("SELECT * FROM users WHERE id = ?", user_id)
Code language: PHP (php)為什麼要 Replication
| 好處 | 說明 |
|---|---|
| 讀取效能提升 | 3 個 Replica 分擔讀取,等於讀取能力 x3 |
| 高可用性 | Primary 掛了,Replica 可以升級為新的 Primary |
| 資料備份 | 資料存在多台機器,不怕硬碟壞掉 |
要注意的坑:Replication Lag
時間點 1:使用者更新大頭照
→ 寫入 Primary ✓
時間點 2:使用者立刻重新整理頁面
→ 讀取 Replica... 還是舊的大頭照!
→ 因為 Primary 的變更還沒同步到 Replica(延遲幾毫秒到幾秒)
時間點 3:幾秒後再重新整理
→ Replica 同步完成,看到新的大頭照 ✓
這就是 Eventual Consistency(最終一致性):資料「最終」會一致,但中間有一小段時間可能不同步。
Vibe Coder 檢查點
看到系統架構圖出現多個資料庫時確認:
- 讀寫是否分離?寫入是不是都走 Primary?
- 有沒有考慮 Replication Lag 造成的資料不一致?
- 使用者剛寫入就要讀取的場景,是否直接讀 Primary?
概念 17:Sharding(分片)
一句話說明
把一張表的資料「切成好幾塊」,分散存到不同伺服器。
Replication vs. Sharding — 一張圖搞懂
Replication(複製):每台都有「全部」資料
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Primary │ │ Replica 1 │ │ Replica 2 │
│ 用戶 1-100萬│ │ 用戶 1-100萬│ │ 用戶 1-100萬│
└─────────────┘ └─────────────┘ └─────────────┘
Sharding(分片):每台只有「一部分」資料
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Shard 1 │ │ Shard 2 │ │ Shard 3 │
│ 用戶 1-33萬 │ │ 用戶 34-66萬│ │ 用戶 67-100萬│
└─────────────┘ └─────────────┘ └─────────────┘
白話翻譯:
- Replication = 影印整本書,發給三個人各一本(每人都有完整的書)
- Sharding = 把書撕成三份,一人拿一份(每人只有部分內容)
Sharding Key 怎麼選
Sharding 的核心問題是:按什麼規則切? 這個規則就是 Sharding Key。
Sharding Key = user_id
查詢 user_id = 42 的資料:
→ 42 % 3 = 0 → 去 Shard 0 找
→ 只查一台伺服器,速度快
查詢 user_id = 100 的資料:
→ 100 % 3 = 1 → 去 Shard 1 找
→ 同樣只查一台
# 虛擬碼:根據 Sharding Key 決定去哪個 Shard
def get_shard(user_id, total_shards=3):
shard_number = user_id % total_shards
return shards[shard_number]
# 查詢時
shard = get_shard(user_id=42)
user = shard.execute("SELECT * FROM users WHERE id = 42")
Code language: PHP (php)Sharding 的挑戰
| 挑戰 | 說明 |
|---|---|
| 跨 Shard 查詢 | 「找出所有 25 歲的用戶」→ 要查全部 Shard,然後合併結果 |
| 資料不均勻 | 如果按地區分片,熱門地區的 Shard 壓力大(Hot Spot) |
| 重新分片 | 從 3 個 Shard 擴展到 5 個,要搬移大量資料 |
| JOIN 困難 | 兩筆需要 JOIN 的資料可能在不同 Shard |
Vibe Coder 檢查點
看到 Sharding 相關設計時確認:
- Sharding Key 選得合理嗎?大多數查詢都會帶這個欄位嗎?
- 有沒有跨 Shard 查詢的需求?如果有,效能可能很差
- 資料分布均勻嗎?會不會某個 Shard 特別忙?
概念 18:Vertical Partitioning(垂直分割)
一句話說明
按「欄位」把一張大表拆成好幾張小表。
最小範例
-- 原本:一張什麼都放的大表
CREATE TABLE users (
id INT,
name VARCHAR(100), -- 每次登入都要讀
email VARCHAR(200), -- 每次登入都要讀
avatar BLOB, -- 很大,但不常讀
bio TEXT, -- 個人介紹,偶爾讀
preferences JSON, -- 設定偏好,很少讀
created_at TIMESTAMP -- 每次登入都要讀
);
-- 垂直分割後:按讀取頻率拆成兩張表
CREATE TABLE users_core ( -- 常讀的欄位
id INT,
name VARCHAR(100),
email VARCHAR(200),
created_at TIMESTAMP
);
CREATE TABLE users_profile ( -- 不常讀的欄位
user_id INT,
avatar BLOB,
bio TEXT,
preferences JSON
);
Code language: JavaScript (javascript)為什麼要這樣拆
查詢「用戶登入資訊」:
拆之前:SELECT * FROM users WHERE id = 42
→ 讀出 name, email, avatar(2MB), bio, preferences, created_at
→ avatar 很大但根本用不到,浪費 I/O
拆之後:SELECT * FROM users_core WHERE id = 42
→ 只讀 name, email, created_at
→ 資料量小,查詢快
白話翻譯:就像把衣櫃分成「每天穿的」和「偶爾穿的」兩個區域。每天早上只翻常穿區,不用把整個衣櫃翻一遍。
Vertical vs. Horizontal Partitioning
原始表:
┌────┬───────┬───────┬──────────┐
│ id │ name │ email │ avatar │
├────┼───────┼───────┼──────────┤
│ 1 │ Alice │ a@... │ [2MB] │
│ 2 │ Bob │ b@... │ [1.5MB] │
│ 3 │ Carol │ c@... │ [3MB] │
└────┴───────┴───────┴──────────┘
Vertical(垂直):按欄位切
表A: id, name, email 表B: id, avatar
┌────┬───────┬───────┐ ┌────┬──────────┐
│ 1 │ Alice │ a@... │ │ 1 │ [2MB] │
│ 2 │ Bob │ b@... │ │ 2 │ [1.5MB] │
│ 3 │ Carol │ c@... │ │ 3 │ [3MB] │
└────┴───────┴───────┘ └────┴──────────┘
Horizontal(水平)= Sharding:按列切
Shard A: id 1-2 Shard B: id 3
┌────┬───────┬───────┐ ┌────┬───────┬───────┐
│ 1 │ Alice │ a@... │ │ 3 │ Carol │ c@... │
│ 2 │ Bob │ b@... │ └────┴───────┴───────┘
└────┴───────┴───────┘
Code language: CSS (css)Vibe Coder 檢查點
看到資料表設計時確認:
- 這張表是不是塞了太多欄位?有些欄位很少一起用?
- 有沒有很大的欄位(BLOB、TEXT)拖慢常規查詢?
- 拆表後 JOIN 的成本可以接受嗎?
概念 19:Caching(快取)
一句話說明
把常用資料存在記憶體裡,下次直接拿,不用再查資料庫。
為什麼快取這麼重要
讀取速度比較:
記憶體(RAM)讀取 ≈ 0.1 毫秒
SSD 磁碟讀取 ≈ 0.1 毫秒(隨機讀取可達數毫秒)
資料庫查詢(含網路)≈ 1-10 毫秒
跨機房網路請求 ≈ 50-150 毫秒
快取命中 = 省下資料庫查詢的時間
最小範例:Cache-Aside 模式
這是最常見的快取策略,也叫 Lazy Loading:
import redis
cache = redis.Redis(host='localhost', port=6379)
def get_user(user_id):
# 第一步:先去快取找
cached = cache.get(f"user:{user_id}")
if cached:
return json.loads(cached) # 快取命中!直接回傳
# 第二步:快取沒有,去資料庫查
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
# 第三步:查到後存進快取,下次就不用再查資料庫
cache.set(f"user:{user_id}", json.dumps(user), ex=300) # ex=300 表示 300 秒後過期
return user
Code language: PHP (php)白話翻譯:
要找某筆資料?
→ 先翻快取(記憶體)
→ 有找到(Cache Hit)→ 直接回傳,超快
→ 沒找到(Cache Miss)→ 去資料庫查,查到後順便存進快取
三種常見快取策略
1. Cache-Aside(旁路快取)
讀:先查快取 → 沒有就查 DB → 存進快取
寫:直接寫 DB → 刪掉快取(下次讀時會重新載入)
特點:最常見,應用程式自己管快取
2. Write-Through(穿透寫入)
讀:先查快取 → 沒有就查 DB → 存進快取
寫:同時寫 DB 和快取
特點:快取永遠是最新的,但寫入較慢
3. Write-Behind(背後寫入)
讀:先查快取
寫:只寫快取 → 過一段時間再批次寫入 DB
特點:寫入超快,但如果快取掛了可能丟資料
快取失效 — 電腦科學的兩大難題之一
“There are only two hard things in Computer Science: cache invalidation and naming things.”
— Phil Karlton
問題場景:
1. 用戶 A 的資料存在快取中(name = "Alice")
2. 用戶 A 改了名字(name = "Alicia")→ 資料庫更新了
3. 但快取裡還是舊的 "Alice"
4. 其他人查用戶 A → 拿到快取裡的舊資料 "Alice" ❌
常見解法:
- TTL(Time To Live):設過期時間,到期自動清除
- 主動失效:資料更新時,順便刪掉對應的快取
- 版本號:快取 key 帶版本號,更新時版本 +1
Code language: JavaScript (javascript)Vibe Coder 檢查點
看到快取相關程式碼時確認:
- 有設 TTL(過期時間)嗎?沒有的話快取會永遠不更新
- 資料更新時有沒有清除快取?(否則會讀到舊資料)
- 快取掛了的時候,系統還能運作嗎?(快取應該是加速用的,不是必需品)
概念 20:Denormalization(反正規化)
一句話說明
刻意在資料表中存重複資料,用空間換時間,避免 JOIN。
最小範例
-- 正規化設計(教科書寫法):資料不重複,用 JOIN 取得
-- orders 表
| order_id | user_id | product_id | quantity |
|----------|---------|------------|----------|
| 1 | 42 | 101 | 2 |
-- users 表
| user_id | name |
|---------|-------|
| 42 | Alice |
-- products 表
| product_id | name | price |
|------------|---------|-------|
| 101 | T-Shirt | 500 |
-- 查詢訂單詳情要 JOIN 三張表
SELECT o.order_id, u.name, p.name, p.price, o.quantity
FROM orders o
JOIN users u ON o.user_id = u.user_id
JOIN products p ON o.product_id = p.product_id;
-- 反正規化設計:資料有重複,但不需要 JOIN
-- orders_denormalized 表
| order_id | user_id | user_name | product_id | product_name | price | quantity |
|----------|---------|-----------|------------|--------------|-------|----------|
| 1 | 42 | Alice | 101 | T-Shirt | 500 | 2 |
-- 查詢訂單詳情,一張表搞定
SELECT order_id, user_name, product_name, price, quantity
FROM orders_denormalized
WHERE order_id = 1;
正規化 vs. 反正規化
| 比較 | 正規化(Normalization) | 反正規化(Denormalization) |
|---|---|---|
| 資料重複 | 無重複 | 有刻意的重複 |
| 讀取速度 | 慢(要 JOIN) | 快(一張表搞定) |
| 寫入速度 | 快(只改一處) | 慢(多處都要改) |
| 儲存空間 | 省 | 費 |
| 資料一致性 | 容易維持 | 要小心維護 |
| 適用場景 | 寫多讀少 | 讀多寫少 |
什麼時候用反正規化
適合反正規化的場景:
- 電商的訂單紀錄(建立後幾乎不改,但常常被查詢)
- 社群媒體的貼文(讀取量遠大於寫入量)
- 報表系統(只讀,不寫)
不適合的場景:
- 經常變動的資料(如用戶即時狀態)
- 需要嚴格一致性的系統(如銀行帳戶)
白話翻譯:正規化就像把資料整理得井然有序,每樣東西只放一個地方。反正規化就像在每個房間都放一把剪刀 — 雖然浪費剪刀,但你再也不用跑來跑去找了。
Vibe Coder 檢查點
看到資料表設計時確認:
- 如果讀取遠多於寫入,有沒有考慮反正規化來減少 JOIN?
- 反正規化的資料,更新時有沒有同步所有副本?
- 重複的資料量大嗎?儲存成本能接受嗎?
六個概念的關係 — 全局觀
這六個概念不是互斥的,它們經常一起使用:
一個典型的高流量系統可能同時用到:
┌─────────────┐
用戶請求 ──────► │ 快取層 │ ← Concept 19: Caching
│ (Redis) │ 先查快取,命中就回
└──────┬──────┘
│ Cache Miss
┌──────▼──────┐
│ 讀取副本 │ ← Concept 16: Replication
│ (Replica) │ 讀取走副本,分散壓力
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌──────▼──┐ ┌──────▼──┐ ┌──────▼──┐
│ Shard 1 │ │ Shard 2 │ │ Shard 3 │ ← Concept 17: Sharding
│用戶1-33萬│ │用戶34-66萬│ │用戶67-100萬│ 資料分散到多個分片
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
索引加速 索引加速 索引加速 ← Concept 15: Indexing
每個分片內用索引加速
同時:
- 每張表按欄位做了 Vertical Partitioning(概念 18),常用欄位和不常用欄位分開
- 讀多寫少的表做了 Denormalization(概念 20),減少 JOIN
決策順序
遇到資料庫效能問題時,通常按這個順序處理:
1. 先加索引(Indexing) ← 成本最低,效果最明顯
2. 加快取(Caching) ← 減少資料庫壓力
3. 讀寫分離(Replication) ← 分散讀取壓力
4. 垂直分割 / 反正規化 ← 優化資料結構
5. 最後才考慮分片(Sharding) ← 成本最高,複雜度最大
重點整理
| 概念 | 一句話 | 解決什麼問題 | 代價 |
|---|---|---|---|
| Indexing | 資料庫的目錄 | 查詢太慢 | 寫入變慢、佔空間 |
| Replication | 複製整個資料庫 | 讀取壓力大、要高可用 | Replication Lag |
| Sharding | 切割資料分散存 | 單台存不下、單台扛不住 | 跨 Shard 查詢困難 |
| Vertical Partitioning | 按欄位拆表 | 欄位太多拖慢查詢 | JOIN 成本增加 |
| Caching | 存記憶體加速讀取 | 資料庫被讀爆 | 快取失效問題 |
| Denormalization | 存重複資料免 JOIN | JOIN 太多太慢 | 寫入要同步多處 |
Vibe Coder 總檢查點
當你在 AI 生成的程式碼或架構圖中看到這些概念時:
- [ ] 索引:是根據實際查詢模式建的,不是隨便加的嗎?
- [ ] 複製:讀寫有分離嗎?有考慮 Lag 嗎?
- [ ] 分片:Sharding Key 選得合理嗎?大多數查詢都帶這個 Key 嗎?
- [ ] 快取:有設過期時間嗎?資料更新時會清快取嗎?
- [ ] 反正規化:重複的資料有同步更新機制嗎?
- [ ] 整體:是否按「索引 → 快取 → 複製 → 分片」的順序處理?別一開始就上 Sharding
**下一篇預告**:#05 分散式系統核心挑戰 — 我們會進入 CAP 定理、一致性模型、分散式交易等「分散式系統的硬核問題」。
進階測驗:資料庫優化與擴展 — 索引、複製、分片與快取
共 5 題,包含情境題與錯誤診斷題。
1. 你負責一個電商平台的訂單系統。訂單表有 500 萬筆資料,以下查詢執行時間超過 5 秒: 情境題
根據文章建議的決策順序,你應該優先採取什麼措施?
2. 你的社群媒體應用要顯示貼文列表,每篇貼文需要顯示作者名稱和大頭照。目前的設計是: 情境題
為了提升這個查詢的效能,最適合的做法是?
3. 你的 users 表包含以下欄位,每次用戶登入都要查整張表: 情境題
登入查詢很慢,最可能的原因和最佳改善方式是?
4. 團隊為了加速查詢,在一張高頻寫入的日誌表(log table)上加了大量索引。以下是 AI 生成的 schema: 錯誤診斷
上線後發現寫入效能嚴重下降,INSERT 延遲從 1ms 飆升到 50ms。最可能的原因是?
5. 團隊在系統中導入了 Redis 快取,但使用者反映「改了個人資料後,別人看到的還是舊的」。以下是快取相關程式碼: 錯誤診斷
這段程式碼的問題在哪裡?