限流器設計#
限流器 (Rate Limiter) 控制用戶端在指定時間窗內能發出的請求數,超出就拒絕。它是保護系統的第一道閘門,常見於 API 閘道、登入端點、寫入密集的服務前。
限流是分散式彈性設計的一環,與熔斷、降級互補;理論定位見 分散式系統 — 彈性設計,閘道層的整合位置見 微服務 — API 閘道。
目的與場景#
- 防範阻斷服務攻擊 (DoS):阻擋單一使用者/IP 的爆量請求。
- 降低成本:限制 API 呼叫量,尤其是按次計費的第三方服務(支付、徵信、簡訊)。
- 避免伺服器過載:過濾機器人或行為異常造成的多餘流量。
真實案例:某平台限制「每 3 小時 300 則貼文」;某文件 API 限制「每使用者每 60 秒 300 次讀取」。限流規則通常綁定維度(使用者、IP、API key)。
五種演算法#
flowchart TB
subgraph TB["Token Bucket"]
direction LR
R1["定速補充<br/>令牌"] --> B1[("桶<br/>容量上限")]
B1 --> O1{"取得令牌?"}
end
subgraph LB["Leaking Bucket"]
direction LR
I2["請求入隊"] --> Q2[("FIFO 佇列")]
Q2 --> O2["定速漏出<br/>處理"]
end1. 令牌桶 (Token Bucket)#
桶有固定容量,令牌以固定速率補充;每個請求消耗 1 個令牌,沒令牌就拒絕。
- 參數:桶大小 (bucket size)、補充速率 (refill rate)。
- 優點:實作簡單、省記憶體、允許短時突發(只要桶裡還有累積的令牌)。
- 缺點:兩個參數要調得恰當不容易。
Token Bucket 偽碼
class TokenBucket:
bucket_size = 10 # 桶容量
refill_rate = 2 # 每秒補充 2 個令牌
tokens = bucket_size
last_refill = now()
function allow_request():
elapsed = now() - last_refill
tokens = min(tokens + elapsed * refill_rate, bucket_size)
last_refill = now()
if tokens >= 1:
tokens -= 1
return ACCEPT
return REJECT # 回傳 HTTP 429關鍵在於「補充」是按經過時間惰性計算,不需背景執行緒持續加令牌。
2. 漏桶 (Leaking Bucket)#
請求進入一個固定容量的 FIFO 佇列,以固定速率漏出處理;佇列滿就拒絕新請求。
- 參數:桶大小(佇列容量)、漏出速率 (outflow rate)。
- 優點:省記憶體,輸出速率穩定,適合需要平滑流量的下游。
- 缺點:舊請求佔滿佇列時,新請求被拒;兩參數同樣難調。
3. 固定視窗計數 (Fixed Window Counter)#
把時間切成固定大小的視窗,每個視窗一個計數器,達到上限就拒絕,直到下一視窗重置。
- 優點:省記憶體、易懂、在固定時間點重置。
- 缺點:視窗邊界突刺——例如每分鐘限 5 次,若在 2:00:59 發 5 次、2:01:00 又發 5 次,這 1 分鐘的跨界區間實際放行了 10 次。
4. 滑動視窗日誌 (Sliding Window Log)#
記錄每個請求的時間戳(常用 Redis sorted set)。新請求進來時先移除過期時間戳,再加入當前時間戳;若日誌大小 ≤ 上限就接受。
- 優點:最精確,任何滑動窗內都不會超量。
- 缺點:耗記憶體——即使被拒的請求其時間戳也會先被記錄。
5. 滑動視窗計數 (Sliding Window Counter)#
固定視窗 + 滑動日誌的折衷,用加權近似當前速率:
$$ \text{當前計數} = \text{本視窗請求數} + \text{前一視窗請求數} \times \text{前一視窗重疊比例} $$
- 優點:平滑突刺、省記憶體。
- 缺點:是近似值(假設前一視窗均勻分布);實務誤差極低(曾有報告在 4 億次請求中僅 0.003% 誤差)。
演算法對比#
| 演算法 | 允許突發 | 記憶體 | 精確度 | 適用場景 |
|---|---|---|---|---|
| 令牌桶 | 是 | 低 | 中 | 通用、需容忍短時突發 |
| 漏桶 | 否 | 低 | 中 | 需穩定輸出速率的下游 |
| 固定視窗計數 | 邊界會 | 低 | 低 | 簡單、可容忍邊界誤差 |
| 滑動視窗日誌 | 否 | 高 | 高 | 要求嚴格精確 |
| 滑動視窗計數 | 部分 | 低 | 中高 | 通用、兼顧精度與成本 |
分散式限流#
單一限流器撐不住數百萬使用者,必須水平擴展為多個限流節點共享狀態,此時會遇到兩個問題。
flowchart LR
C1[Client A] --> RL1[限流器 1]
C2[Client B] --> RL2[限流器 2]
C3[Client C] --> RL3[限流器 3]
RL1 --> R[("Redis<br/>集中計數")]
RL2 --> R
RL3 --> R- 競態條件 (Race Condition):多個節點同時 read-modify-write 同一計數器。例如計數器為 3,兩個請求都讀到 3、各自加 1 寫回 4(正確應為 5)。
- 解法:用 Redis Lua 指令稿把「讀取 ➡️ 檢查 ➡️ 遞增」包成單一原子操作;滑動視窗日誌則用 sorted set 的原子操作(
ZADD/ZREMRANGEBYSCORE/ZCARD)。
- 解法:用 Redis Lua 指令稿把「讀取 ➡️ 檢查 ➡️ 遞增」包成單一原子操作;滑動視窗日誌則用 sorted set 的原子操作(
- 同步問題:多個限流器各自為政,節點 1 不知道節點 2 看過的請求。
- 解法:用**集中式儲存(Redis)**共享計數,避免 sticky session(不易擴展)。
Redis 之所以是限流首選,是因為它單執行緒、原子操作、低延遲,能天然消解競態。Redis 的資料結構(sorted set、過期機制)細節見 Redis 資料結構剖析。
HTTP 回應協定#
當請求被限流時,伺服器應回傳標準狀態碼與標頭,讓用戶端能正確退避重試:
- 狀態碼:
429 Too Many Requests - 回應標頭:
| 標頭 | 意義 |
|---|---|
X-Ratelimit-Limit | 時間窗內允許的最大請求數 |
X-Ratelimit-Remaining | 當前窗內剩餘可用請求數 |
X-Ratelimit-Retry-After | 還要等幾秒才能重試 |
設計重點還包括:限流規則該放哪(記憶體 / 設定中心 / 資料庫)、被限流的請求要丟棄還是排入佇列稍後處理、以及在用戶端用回應標頭做指數退避 (exponential backoff)。
小結#
| 問題 | 對策 |
|---|---|
| 容忍短時突發 | 令牌桶 |
| 需要穩定輸出 | 漏桶 |
| 要求嚴格精確 | 滑動視窗日誌 |
| 精度與成本兼顧 | 滑動視窗計數 |
| 分散式競態 | Redis + Lua 指令稿 / sorted set 原子操作 |
| 通知用戶端 | HTTP 429 + X-Ratelimit-* 標頭 |