效能設計的目標是提升系統的吞吐量與回應時間,主要策略包括快取、非同步處理、讀寫分離與資料分片。
快取設計 (Caching)#
快取是提高效能最有效的方式之一,通過將熱點資料放在記憶體中,減少對資料庫的存取。
快取是通過犧牲強一致性來提高效能的。並非所有業務都適合使用快取,需要根據業務需求評估資料的即時性要求。
Cache Aside 模式#
最常用的快取更新模式(Facebook 採用此策略):
讀取流程:
1. 從快取讀取
2. 快取命中 → 回傳資料
3. 快取未命中 → 從資料庫讀取 → 存入快取 → 回傳資料
更新流程:
1. 更新資料庫
2. 刪除快取(不是更新快取!)為什麼是刪除快取而不是更新快取?
避免兩個並行寫操作導致髒資料:
時間線:
T1: 操作 A 更新資料庫為 valueA
T2: 操作 B 更新資料庫為 valueB
T3: 操作 B 更新快取為 valueB
T4: 操作 A 更新快取為 valueA ← 髒資料!
結果:資料庫是 valueB,快取是 valueACache Aside 的並行問題
理論上,Cache Aside 也可能出現髒資料:
T1: 讀操作發現快取失效
T2: 讀操作從資料庫讀取舊資料
T3: 寫操作更新資料庫
T4: 寫操作刪除快取
T5: 讀操作把舊資料放入快取 ← 髒資料!但這種情況發生的機率很低,因為:
- 需要讀操作先進入資料庫
- 讀操作還要晚於寫操作更新快取
- 寫操作比讀操作慢得多,還要加鎖
解決方案:
- 使用 2PC 或 Paxos(太慢或太複雜)
- 設置快取過期時間(簡單有效)
Read/Write Through 模式#
應用程式只與快取互動,快取負責與資料庫同步:
flowchart TD
A[應用程式] -->|讀/寫| B[快取]
B -->|同步| C[資料庫]
B -.->|負責與資料庫同步| B
style A fill:#e3f2fd
style B fill:#fff9c4
style C fill:#c8e6c9- Read Through:快取失效時,由快取服務負責載入資料
- Write Through:寫入時,由快取服務同步寫入資料庫
Write Behind (Write Back) 模式#
只更新快取,非同步批量寫入資料庫:
寫入流程:
1. 更新快取
2. 標記為 dirty
3. 非同步批量寫入資料庫
優點:
- 極高的寫入效能
- 可合併多次寫操作
缺點:
- 資料可能丟失(如系統崩潰)
- 不保證強一致性Linux 的 Page Cache 就是使用 Write Back 策略,這也是為什麼非正常關機可能導致資料丟失。
快取設計要點#
- 命中率:80% 以上算良好,追求效能可到 95%
- 過期時間:太短會頻繁穿透,太長會浪費記憶體
- LRU 策略:淘汰最久未使用的資料(注意加鎖開銷)
- 防爬蟲:避免爬蟲爬到冷門資料,擠掉熱點資料
- 分散式快取:使用 Redis 叢集,避免本地快取的一致性問題
非同步處理 (Asynchronous)#
非同步處理可以提高系統吞吐量,讓資源得到更好的利用。
同步 vs 非同步#
| 特性 | 同步 | 非同步 |
|---|---|---|
| 吞吐量 | 受最慢環節限制 | 可並行處理 |
| 資源利用 | 等待時資源閒置 | 資源利用率高 |
| 一致性 | 即時一致 | 最終一致 |
| 複雜度 | 簡單 | 較複雜 |
非同步通訊方式#
請求回應式#
sequenceDiagram
participant S as 發送方
participant R as 接收方
S->>R: 請求
R-->>S: 回呼問題:發送方需要提供回呼 URL,仍有一定耦合。
訂閱式 (Pub/Sub)#
flowchart LR
S[發送方] -->|發布事件| Q[訊息佇列]
Q -->|訂閱| R[接收方]
style Q fill:#fff9c4Broker 模式#
flowchart LR
A[服務 A] --> B[Broker]
A2[服務 B] --> B
B --> C[服務 C]
B --> D[服務 D]
style B fill:#ffccbc完全解耦,所有服務只依賴 Broker。
事件驅動架構 (EDA)#
flowchart TD
O[下單服務] -->|下單事件| Q[訊息佇列]
Q --> S1[訂單服務]
Q --> S2[庫存服務]
Q --> S3[支付服務]
S1 --> R1[生成訂單]
S2 --> R2[佔住庫存]
S3 --> R3[處理支付]
style Q fill:#fff9c4
style R1 fill:#c8e6c9
style R2 fill:#c8e6c9
style R3 fill:#c8e6c9優點:
- 服務間無依賴,高度可重用
- 開發、測試、運維隔離
- 不會相互阻塞
- 便於增加中間處理(日誌、認證、限流)
事件溯源 (Event Sourcing)#
只記錄不可變的事件,通過回放事件得到最終狀態:
傳統方式:
餘額 = 1000(狀態)
事件溯源:
+500 ──▶ +200 ──▶ -100 ──▶ +400 = 1000
(事件流)
優點:
- 完整的歷史記錄
- 可以重新計算狀態(修復 bug 後重放)
- 無並行衝突讀寫分離 (CQRS)#
基本讀寫分離#
flowchart TD
S[服務層] -->|寫| M[(主庫)]
S -->|讀| R[(從庫)]
M -->|同步| R
style S fill:#e3f2fd
style M fill:#ffccbc
style R fill:#c8e6c9優點:
- 簡單易實現
- 業務隔離良好
- 分擔讀負載
缺點:
- 主庫單點問題
- 同步延遲,不適合強一致性場景
CQRS (Command Query Responsibility Segregation)#
命令 (Command):
- 寫操作(增、刪、改)
- 不回傳資料,只回傳執行狀態
- 會改變系統狀態
查詢 (Query):
- 讀操作
- 回傳資料
- 不改變系統狀態CQRS + Event Sourcing:
flowchart LR
subgraph Write["命令端 (寫模型)"]
W[寫入操作]
end
subgraph Read["查詢端 (讀模型)"]
R[讀取操作]
end
W --> ES[(事件儲存)]
ES -->|投影| RV[(讀取視圖)]
RV --> R
style Write fill:#ffccbc
style Read fill:#c8e6c9
style ES fill:#fff9c4
style RV fill:#e3f2fd資料分片 (Sharding)#
當單一資料庫無法承載資料量時,需要進行分庫分表。
分片策略#
| 策略 | 說明 | 適用場景 |
|---|---|---|
| 按租戶 | 用租戶 ID 分片 | 多租戶 SaaS 系統 |
| 按類型 | 按商品類別、地區等分片 | 電商平台 |
| 按範圍 | 按時間、ID 範圍分片 | 訂單系統(按月分表) |
| 按雜湊 | ID % N | 需要均勻分布的場景 |
強烈建議從業務角度分片,不要使用雜湊散列分片!
雜湊分片的問題:
- 跨分片查詢困難
- 跨分片事務複雜
- 擴容需要重新雜湊
分片的挑戰#
- 跨分片查詢:需要查詢多個分片後合併結果
- 跨分片事務:需要使用分散式事務(2PC/TCC)
- 資料傾斜:某些分片資料過多,需要定期重新平衡
- 分片路由:需要資料存取層中介軟體解析 SQL 並路由
分片路由的實現
flowchart TB
subgraph DAL["資料存取層"]
S1["1. 解析 SQL"]
S2["2. 根據分片鍵路由到對應分片"]
S3["3. 合併多分片結果"]
S1 --> S2 --> S3
end
DAL --> D1[(分片 1)]
DAL --> D2[(分片 2)]
DAL --> D3[(分片 3)]
style DAL fill:#e3f2fd
style D1 fill:#c8e6c9
style D2 fill:#c8e6c9
style D3 fill:#c8e6c9對於分頁、聚合等操作,需要:
- 到各分片分別查詢
- 在資料存取層合併結果
垂直分片#
將表的欄位拆分到不同的表:
原表:products (id, name, description, price, stock)
垂直分片後:
products_info (id, name, description) ← 不常修改
products_stock (id, price, stock) ← 經常修改優點:避免修改某欄位時鎖住不相關的欄位
分片與重新平衡 (Rebalancing)#
分片後,叢集規模會變動:擴容加節點、故障減節點。重新平衡 (rebalancing) 指把分片在節點間搬移以重新均衡負載。重平衡策略的好壞,決定了擴縮容是「平滑無感」還是「全叢集大遷徙」。
重平衡的設計目標有三:搬移的資料量盡量少、過程中系統仍能讀寫、且不會把「正在搬資料的繁忙節點」誤判成故障節點。三者常彼此拉扯。
為什麼 hash(key) mod N 是災難#
最直覺的雜湊分片是 分片 = hash(key) mod N($N$ 為節點數)。它的致命問題在於:節點數 $N$ 一變,幾乎所有 key 的歸屬都改變。
N = 4 時:hash(key)=100 → 100 mod 4 = 0 → 節點0
擴容到 N = 5: → 100 mod 5 = 0 → 仍節點0(碰巧)
hash(key)=101: 4→ 101 mod 4 = 1 → 節點1
5→ 101 mod 5 = 1 → 節點1
hash(key)=102: 4→ 102 mod 4 = 2 → 節點2
5→ 102 mod 5 = 2 → 節點2 ... 多數 key 全被打散重映射加一個節點,理論上只該搬 $1/N$ 的資料,mod N 卻搬了絕大多數——擴容變成全叢集資料大搬遷,期間網路與磁碟被打滿。永遠不要用 mod 節點數 來決定分片歸屬。
固定分片數(虛擬分片)#
解法之一:把分片數固定在遠多於節點數的數值(例如 1024 個虛擬分片),讓「key ➡️ 虛擬分片」的映射永不改變,只改變「虛擬分片 ➡️ 節點」的指派。
key → hash(key) mod 1024 → 虛擬分片編號(永遠不變)
虛擬分片 → 節點(這層才隨擴縮容調整)
擴容:新節點從每個舊節點「勻」走若干個完整虛擬分片
key 與虛擬分片的關係不動 → 只搬被指派出去的那些分片- 優點:實作簡單、搬移量可控(只搬整個分片)。
- 缺點:分片數一開始就要定好且難改;分片數選太小會限制未來擴展,選太大則每個分片管理開銷上升。
動態分片(分裂 / 合併)#
另一條路:分片數隨資料量動態變化。當某分片資料超過閾值就分裂 (split) 成兩個,資料變少時相鄰分片合併 (merge)。
分片 [0, 100) 資料量超標
→ 分裂為 [0, 50) 與 [50, 100)
→ 其中一半可搬到別的節點- 優點:分片大小自適應,避免熱點分片無限膨脹,適合按範圍 (range) 分片。
- 缺點:分裂/合併本身是有成本的操作,且初期資料少時可能只有單一分片,無法分散負載(需要預先分割 / pre-splitting)。
按節點比例分片#
讓每個節點固定持有相同數量的分片,分片大小隨資料成長。新增節點時,它隨機選若干現有分片各分裂一半接管——搬移量與節點數成比例,整體分布趨於均勻。
對比#
| 策略 | 分片數 | 搬移量 | 適用 |
|---|---|---|---|
hash mod N | = 節點數 | 幾乎全部 | (勿用) |
| 固定虛擬分片 | 固定且偏多 | 只搬被指派的分片 | hash 分片、規模可預估 |
| 動態分裂/合併 | 隨資料變動 | 局部 | range 分片、資料量不確定 |
| 按節點比例 | 與節點成比例 | 與節點數成比例 | 追求均勻、節點數常變 |
flowchart LR
K[key] --> H["hash / range"]
H --> P["邏輯分片<br/>(虛擬分片 / range)"]
P --> A["分片→節點 指派表<br/>(重平衡只動這層)"]
A --> N1[(節點1)]
A --> N2[(節點2)]
A --> N3[(節點3)]
style P fill:#fff9c4
style A fill:#e3f2fd請求路由的三種架構#
客戶端怎麼知道某個 key 在哪個節點?這是服務發現 (service discovery) 在分片場景的體現,有三種架構:
flowchart TB
subgraph M1["架構一:節點轉發"]
C1[客戶端] --> AnyNode[任意節點]
AnyNode -->|不在我這| Right1[(正確節點)]
end
subgraph M2["架構二:路由層"]
C2[客戶端] --> Router[路由層 / Proxy]
Router --> Right2[(正確節點)]
end
subgraph M3["架構三:客戶端感知"]
C3[客戶端<br/>內建分片資訊] --> Right3[(正確節點)]
end
style Router fill:#e3f2fd
style C3 fill:#fff9c4| 架構 | 誰知道分片分布 | 優點 | 缺點 |
|---|---|---|---|
| 節點轉發 | 每個節點 | 客戶端最簡單 | 多一跳轉發 |
| 路由層 | 中間路由 / proxy | 客戶端與分片解耦 | 路由層成為新元件 |
| 客戶端感知 | 客戶端自身 | 無額外跳數,延遲最低 | 客戶端複雜、分布變更難同步 |
三者都要回答同一個問題:分片分布變更時,這份「分片 ➡️ 節點」對照表如何保持一致。這正是線性一致性的用武之地。
ZooKeeper 的角色#
許多系統用 ZooKeeper(或 etcd 等)作為這份對照表的權威來源 (source of truth):
- 各節點向 ZooKeeper 註冊自己負責的分片;
- 路由層 / 客戶端向 ZooKeeper 訂閱對照表,分布一變更即收到通知;
- ZooKeeper 本身是一個 共識 群組,提供 線性一致性,確保所有人看到同一份、最新的對照表,避免路由分歧。
這也呼應 架構模式 的服務發現:分片路由本質上就是「為資料」做服務發現,而非「為服務」。
重平衡期間的級聯風險#
重平衡是危險時刻:搬資料讓節點 CPU、網路、磁碟同時飆高,回應變慢。若此時故障判定(靠逾時,見 共識 — FLP)把這個「只是忙」的節點誤判成「死了」,就可能引爆級聯:
重平衡 → 節點A 變慢
→ 健康檢查逾時 → 誤判 A 死亡
→ 系統把 A 的分片再搬給 B、C
→ B、C 也因搬資料變慢 → 又被誤判
→ 雪崩式重平衡,整個叢集被自己的「修復」拖垮自動重平衡 + 自動故障判定的組合最危險:兩個自動化機制互相觸發,把一次小抖動放大成全叢集事故。實務上常讓重平衡手動或半自動觸發、限速 (throttle) 搬移頻寬,並把故障判定的逾時與重平衡負載解耦,避免「忙」被當成「死」。
負載均衡策略#
| 策略 | 說明 |
|---|---|
| 輪詢 (Round Robin) | 依序分配請求 |
| 加權輪詢 | 根據伺服器效能分配不同權重 |
| 最少連線 | 分配給當前連線數最少的伺服器 |
| IP Hash | 相同 IP 分配到同一伺服器(工作階段保持) |
| 一致性雜湊 | 減少擴縮容時的資料遷移 |
效能設計總結#
flowchart TB
subgraph App["應用層"]
A1["快取(Redis)"]
A2["非同步處理(訊息佇列)"]
A3["連線池"]
end
subgraph Data["資料層"]
D1["讀寫分離"]
D2["分庫分表"]
D3["索引最佳化"]
end
subgraph Infra["基礎設施"]
I1["負載均衡"]
I2["CDN"]
I3["壓縮 / 合併"]
end
App --> Data --> Infra
style App fill:#e3f2fd
style Data fill:#fff9c4
style Infra fill:#c8e6c9效能最佳化的原則:
- 先測量,再最佳化(沒有資料的最佳化是盲目的)
- 優先最佳化瓶頸點
- 在一致性和效能之間做取捨
- 能用快取解決的問題,不要用分片