System Design 核心:Redis、Rate Limiting、Circuit Breaker 與 Scaling 策略

這篇整理 SRE 面試高頻的 System Design 核心概念: Redis 的資料結構與使用場景、四種 Rate Limiting 演算法、Circuit Breaker 的三個狀態, 以及 Scaling Reads 和 Scaling Writes 的常見設計模式。

Redis

為什麼需要 Redis

資料庫每次讀取都要走磁碟 I/O, 延遲約 1-10ms。Redis 把資料存在記憶體, 延遲約 0.1ms。對於熱資料 (頻繁讀取、不常變動), cache 可以讓資料庫壓力降低幾個數量級。

Redis 是 key-value store, 但不只是 cache — 它的資料結構讓它能勝任多種不同的角色。

五種資料結構

結構 類比 適合場景
String 變數 cache、session、計數器
Hash 物件/dict 使用者資料、設定檔
List 雙向佇列 訊息佇列、最近活動紀錄
Set 無序集合 標籤、好友清單、去重
Sorted Set 有分數的集合 排行榜、優先隊列、時間序列
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
String:
  SET user:123:name "Alice"
  GET user:123:name  -> "Alice"
  INCR user:123:loginCount  -> atomic increment

Hash:
  HSET user:123 name "Alice" age 30 city "Toronto"
  HGET user:123 name  -> "Alice"
  HGETALL user:123    -> all fields

List:
  RPUSH queue:tasks "job1" "job2"
  LPOP queue:tasks  -> "job1"  (FIFO)

Set:
  SADD user:123:tags "golang" "devops"
  SMEMBERS user:123:tags  -> {"golang", "devops"}

Sorted Set:
  ZADD leaderboard 9500 "user:123"
  ZADD leaderboard 8200 "user:456"
  ZREVRANGE leaderboard 0 9 WITHSCORES  -> top 10 with scores

七個使用場景

Cache: 最常見。讀資料庫後存入 Redis, 下次直接從 Redis 拿。TTL 控制過期。

Distributed Lock: 多個服務搶同一資源時 (例如搶票), 用 SET key value NX EX 300 建 lock。NX = 只有 key 不存在時才設定 (原子操作), 避免兩個 instance 同時拿到 lock。EX 300 = 300 秒後自動釋放, 防止 lock 永遠不釋放。

Leaderboard: Sorted Set 天生支援排行榜。ZADD 加分數, ZREVRANGE 取前 N 名, 時間複雜度 O(log N)。

Rate Limiting: 用 INCR 計數, 每個 key 代表一個時間窗口。跨多個 API Gateway instance 共享同一個 Redis, 做到全域限流。

Proximity Search: GEOADD 加入座標, GEODIST 計算距離, GEOSEARCH 找範圍內的點。適合外送、叫車、地圖搜尋。

Streams: 類似 Kafka 的訊息流, 支援 consumer group、訊息持久化、重播。比 Pub/Sub 更可靠。

Pub/Sub: 發布/訂閱模式, 即時推送通知。特性是 fire-and-forget — 訊息送出後不保證對方收到, 斷線期間的訊息會遺失。適合即時通知, 不適合需要保證送達的場景。

Hot Key 問題

Hot Key 是指被大量請求集中打到同一個 Redis key 的情況。例如明星發文, 幾百萬人同時讀同一個貼文的 cache key, 造成那個 key 所在的 Redis node 過載。

三種解法:

本地快取 (Local Cache): 在每個應用服務 instance 上存一份記憶體內 cache, 減少打到 Redis 的請求數量。缺點是各 instance 的 cache 可能不一致, 且不同 instance 占用的記憶體加總起來會大很多。

副本 (Replica): 把 Hot Key 複製到多個 Redis 節點, 例如 post:viral:123:replica1, post:viral:123:replica2。讀取時隨機選一個副本, 分散壓力。缺點是需要額外的管理邏輯來維護副本的一致性。

Key 分散 (Key Sharding): 在 key 後面加隨機後綴 post:viral:123:{0~9}, 寫入時同時寫到所有後綴, 讀取時隨機選一個。本質上是把 Hot Key 打散到多個物理 key, 利用 Redis Cluster 的不同 slot 分流。這是最根本的解法, 因為它直接把流量分散到不同節點, 而不只是減少請求數量。

三種部署模式

模式 特性 適合
Standalone 單節點, 簡單 開發環境
Sentinel 主從複製 + 自動 failover 中型系統, HA 需求
Cluster 自動分片, 多主節點 大型系統, 水平擴展

Rate Limiting

四種演算法

Fixed Window: 把時間切成固定窗口 (例如每分鐘), 每個窗口有一個計數器。請求進來時計數器加一, 超過上限就拒絕。

1
2
3
4
5
6
Window: 00:00 - 01:00
Counter: 95 requests
Limit: 100

request at 00:59 -> counter = 96 -> allowed
request at 01:00 -> new window, counter = 0 -> allowed

問題: 窗口交界處可以瞬間打兩倍流量 — 00:59 的 100 次 + 01:00 的 100 次 = 2 秒內 200 次。

Sliding Window: 計算的是「過去 N 秒內」的請求數, 而不是「這個窗口內」。解決 Fixed Window 的邊界問題。代價是需要記錄每筆請求的時間戳, 儲存成本較高。

Token Bucket: 令牌桶以固定速率產生令牌 (例如每秒 10 個), 桶有容量上限。每個請求消耗一個令牌, 桶空了就拒絕。允許短暫的流量爆發 (burst) 只要桶裡有令牌。適合允許一定彈性的 API。

1
2
3
4
5
6
7
Bucket capacity: 100 tokens
Refill rate: 10 tokens/second

Burst scenario:
  - bucket is full (100 tokens)
  - 80 requests arrive at once -> allowed (80 tokens consumed)
  - next second: 10 tokens refilled

Leaky Bucket: 請求進入一個固定容量的佇列 (桶), 以固定速率從桶底流出處理。桶滿了就拒絕新請求。輸出速率絕對平滑, 沒有 burst。適合需要嚴格控制流量的場景 (例如付費 API)。

演算法 Burst 支援 實作複雜度 適合場景
Fixed Window 是 (邊界) 最低 簡單 API
Sliding Window 精確限流
Token Bucket 是 (可控) 一般 API
Leaky Bucket 嚴格平滑輸出

分散式 Rate Limiting

多個 API Gateway instance 各自計數, 用戶可以把限流繞過去 — 打三個 instance 各打 100 次, 實際是 300 次。

解法: 所有 instance 共用 Redis 做計數器。

1
2
3
Gateway A --\
Gateway B ----> Redis (shared counter) -> enforce global limit
Gateway C --/
1
2
INCR rate:user:123:minute:1744840800   # key = user + current minute timestamp
EXPIRE rate:user:123:minute:1744840800 60

INCR 是原子操作 — Redis 保證同一時間只有一個操作能修改這個 key, 不會有兩個 instance 同時讀到相同的數字再各自加一的問題。

Circuit Breaker

為什麼需要 Circuit Breaker

Service A 呼叫 Service B, B 開始回應很慢 (例如資料庫過載)。A 的請求堆積等待 B 的回應, A 的線程全部被卡住, A 也開始無法處理新請求。這就是 Cascading Failure (級聯失敗) — 一個服務的問題蔓延到上游。

Circuit Breaker 的設計: 偵測到下游失敗率超過閾值時, 直接短路 (不再嘗試呼叫 B), 立刻回傳錯誤或 fallback。讓 A 保持可用, 不被 B 拖垮。

三個狀態

1
2
3
4
Closed -> (error rate > threshold) -> Open
Open   -> (after timeout)          -> Half-Open
Half-Open -> (test request fails)  -> Open
Half-Open -> (test request ok)     -> Closed

Closed (正常): 所有請求正常送出。統計失敗率, 超過閾值就切到 Open。

Open (斷開): 不再嘗試呼叫下游, 直接回傳錯誤或 fallback 結果。讓下游有時間恢復。

Half-Open (試探): 等待一段時間後, 放行少量測試請求。如果成功, 切回 Closed。如果還是失敗, 回到 Open 繼續等待。

Fallback 可以是: 回傳快取的舊資料、回傳預設值、降級功能 (例如電商不顯示個人化推薦, 改顯示熱門商品)。

Scaling Reads

讀流量擴展的標準路徑: Index → Denormalization → Read Replica → Sharding → Caching Layer → CDN。

Cache Invalidation

Cache 的核心問題: 資料更新後, cache 裡的舊資料什麼時候失效?

TTL: 最簡單。設定過期時間, 時間到自動失效。缺點是資料更新到 TTL 過期這段時間, 用戶看到的是舊資料。

Write-through: 每次寫入資料庫時, 同步更新 cache。資料一致性最高, 但增加寫入延遲。

Write-behind (Write-back): 先寫 cache, 非同步批次寫入資料庫。寫入速度快, 但 cache 當機時可能遺失資料。

Tagged Invalidation: 給 cache key 打標籤。更新資料時, 把該分類下的所有 cache key 一起失效。例如「所有 category:electronics 的商品 cache」。

Versioned Keys: key 加版本號, 例如 product:123:v5。更新資料時, 版本號加一, 舊 key 自然失效 (沒人讀就等 TTL 自動清除)。好處是不需要主動刪除 cache, 舊 key 繼續存在但不會被讀到。缺點是需要一個地方儲存「目前最新版本號是多少」。

TTL 和 Write-through 通常一起用 — Write-through 確保資料更新立刻反映, TTL 作為保底機制。

CDN 與 Edge Caching

CDN (Content Delivery Network) 在全球多個節點 (edge) 儲存靜態資源的副本。用戶讀取時打到最近的 edge node, 不需要每次都打到 origin server。

動態內容也可以 cache 在 CDN — 例如 API 回應在 CDN edge 快取 30 秒。要注意 cache invalidation: 資料更新後要主動 purge CDN 的快取。

Cache Stampede

Cache 過期的瞬間, 大量請求同時穿透到資料庫, 造成資料庫瞬間過載, 這叫 Cache Stampede (快取雪崩)。

三種解法:

Request Coalescing (請求合併): 第一個打到 cache miss 的請求去查資料庫, 其他請求等待。資料庫回傳後, 所有等待的請求一起拿到結果。問題是需要一個協調機制判斷「誰是第一個」— 通常用 Distributed Lock 實作, 拿到 lock 的去查 DB, 其他人等 lock 釋放後再讀 cache。

Stale-While-Revalidate: 先回傳過期的舊資料給用戶 (不讓用戶等待), 同時非同步去更新 cache。用戶感受不到延遲, 但有短暫的資料不一致窗口。適合對時效性要求不高的場景。

Probabilistic Early Refresh: 在 cache 到期之前, 用機率決定是否提前刷新。越接近到期時間, 刷新的機率越高。這樣 cache 不會在同一時間大量到期, 流量分散到各個時間點。適合高流量系統, 不需要 Distributed Lock, 實作相對簡單。

實際使用上, Request Coalescing 和 Stale-While-Revalidate 常一起用, Probabilistic Early Refresh 是更進階的解法。

Scaling Writes

寫流量擴展比讀流量複雜, 因為寫入需要保證一致性。

資料庫選型

選資料庫是寫 Scaling 的第一步, 不同 DB 對高寫入的支援能力差異很大。

資料庫 適合場景 寫入特性
PostgreSQL 一般交易型系統 ACID, 強一致
Cassandra 高寫入, 時間序列 Write-optimized, eventual consistency
InfluxDB IoT, metrics 時序資料, 高壓縮率
Column Store 分析查詢 批次寫入, 不適合即時

Horizontal Sharding

資料量太大時, 把資料水平切分到多個資料庫 instance。

Partition Key 選擇: 決定資料落在哪個 shard 的依據。選不好會造成 hot shard — 大量資料集中在同一個 shard。

1
2
3
4
5
6
user_id % 4 -> shard 0, 1, 2, 3

user:1 -> shard 1
user:2 -> shard 2
user:3 -> shard 3
user:4 -> shard 0

問題: cross-shard query 複雜, JOIN 無法跨 shard。需要在應用層做聚合。

Vertical Partitioning

把不同的 table 或 column 拆到不同的資料庫。

1
2
3
4
5
Before: one DB with all tables
After:
  Users DB    -> user_id, name, email
  Orders DB   -> order_id, user_id, amount
  Products DB -> product_id, name, price

和 Foreign Key 的差別: Vertical Partitioning 是物理上把 table 放到不同的 DB instance, 不只是邏輯上的關聯。跨 DB 的 JOIN 變成需要在應用層處理。

Queue 解耦寫入

高峰流量時, 把寫入請求先放進 Queue, 非同步處理。

1
User request -> API -> Queue -> Consumer -> DB

好處: API 立刻回應 (不等 DB 寫入), 流量峰值被 Queue 吸收。壞處: 資料不是立刻寫入, 用戶可能短暫看不到自己剛才的操作結果。

Load Shedding

系統超載時, 主動拒絕低優先度的請求, 保護核心功能。

1
2
3
4
Traffic spike:
  - Priority 1 (payment): always process
  - Priority 2 (read profile): process if capacity > 60%
  - Priority 3 (recommendation): drop if capacity < 40%

和 Rate Limiting 的差別: Rate Limiting 是對單一用戶限流, Load Shedding 是系統層級的優先度決策。

Hierarchical Aggregation

高頻寫入的計數類資料 (例如按讚數、播放次數), 如果每個事件都直接寫入中央資料庫, root 會過載。

1
2
3
4
5
6
7
8
9
Without aggregation:
  Event 1: likes++ -> Central DB
  Event 2: likes++ -> Central DB
  ... (1M events/sec -> Central DB explodes)

With Hierarchical Aggregation:
  Level 1 (edge nodes): buffer locally, aggregate every 1s
  Level 2 (regional nodes): aggregate from L1 every 10s
  Level 3 (central): receive pre-aggregated data every 60s

L1 node 每秒彙整一次, 送到 L2 的不是 1000 個事件而是一個「這 1 秒有 1000 個 likes」。L2 再彙整 10 秒的資料送到 central。Central 接收的是已經縮減幾個數量級的流量。

注意: root 收到的是彙整後的數量, 不是每個原始事件。如果業務需求是「知道每個用戶各自按了幾次讚」, 就需要在 edge 層保留更細的資料。Hierarchical Aggregation 適合「總數統計」, 不適合「明細追蹤」。

References