效能設計的目標是提升系統的吞吐量與回應時間,主要策略包括快取、非同步處理、讀寫分離與資料分片。

快取設計 (Caching)#

快取是提高效能最有效的方式之一,通過將熱點資料放在記憶體中,減少對資料庫的存取。

快取是通過犧牲強一致性來提高效能的。並非所有業務都適合使用快取,需要根據業務需求評估資料的即時性要求。

Cache Aside 模式#

最常用的快取更新模式(Facebook 採用此策略):

讀取流程:
  1. 從快取讀取
  2. 快取命中 → 回傳資料
  3. 快取未命中 → 從資料庫讀取 → 存入快取 → 回傳資料

更新流程:
  1. 更新資料庫
  2. 刪除快取(不是更新快取!)

為什麼是刪除快取而不是更新快取?

避免兩個並行寫操作導致髒資料:

時間線:
T1: 操作 A 更新資料庫為 valueA
T2: 操作 B 更新資料庫為 valueB
T3: 操作 B 更新快取為 valueB
T4: 操作 A 更新快取為 valueA  ← 髒資料!

結果:資料庫是 valueB,快取是 valueA
Cache 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 策略,這也是為什麼非正常關機可能導致資料丟失。

快取設計要點#

  1. 命中率:80% 以上算良好,追求效能可到 95%
  2. 過期時間:太短會頻繁穿透,太長會浪費記憶體
  3. LRU 策略:淘汰最久未使用的資料(注意加鎖開銷)
  4. 防爬蟲:避免爬蟲爬到冷門資料,擠掉熱點資料
  5. 分散式快取:使用 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:#fff9c4

Broker 模式#

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需要均勻分布的場景

強烈建議從業務角度分片,不要使用雜湊散列分片!

雜湊分片的問題:

  • 跨分片查詢困難
  • 跨分片事務複雜
  • 擴容需要重新雜湊

分片的挑戰#

  1. 跨分片查詢:需要查詢多個分片後合併結果
  2. 跨分片事務:需要使用分散式事務(2PC/TCC)
  3. 資料傾斜:某些分片資料過多,需要定期重新平衡
  4. 分片路由:需要資料存取層中介軟體解析 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

效能最佳化的原則:

  1. 先測量,再最佳化(沒有資料的最佳化是盲目的)
  2. 優先最佳化瓶頸點
  3. 在一致性和效能之間做取捨
  4. 能用快取解決的問題,不要用分片