限流器設計#

限流器 (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/>處理"]
    end

1. 令牌桶 (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)。
  • 同步問題:多個限流器各自為政,節點 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-* 標頭