🎯 什麼情境該想到我
當你要限制「某使用者/IP/API 在一段時間內最多幾次請求」,保護服務不被流量或濫用打垮時。
⚙️ 怎麼用(選演算法)
- Token Bucket(權杖桶):以固定速率補充權杖,每次請求耗一個;桶有容量 → 允許突發、最常用。
- Leaky Bucket(漏桶):請求排隊以固定速率流出 → 平滑輸出、不允許突發。
- 固定視窗計數:每個時間窗計數,簡單但視窗邊界會爆量(兩窗交界瞬間 2 倍)。
- 滑動視窗:用滑動時間窗平滑計數,較準但成本略高。
實作要點:分散式下把計數放共享儲存(如 Redis),並回傳 429 Too Many Requests + Retry-After。
★ 四種演算法的數學:一個窗口內最多放行幾個
設限制是「每分鐘 N 個」:
| 演算法 | 任意 60 秒內最多放行 | 為什麼 |
|---|---|---|
| 固定視窗計數 | 2N | 在 0:59 送 N 個(第一個窗口)、1:00 再送 N 個(第二個窗口)→ 兩秒內 2N 個 |
| 滑動視窗日誌 | N(精確) | 記下每個請求的時間戳,每次數最近 60 秒內有幾個;代價是記憶體 O(N) 每個使用者 |
| 滑動視窗計數 | ≈ N(近似) | 見下方公式 |
| 權杖桶(容量 b、每秒補 r 個) | b + r × 60 | 桶滿時可以一口氣用掉 b 個,之後以速率 r 補充 |
| 漏桶(佇列長 q、每秒流出 r 個) | 輸出最多 r × 60 | 輸出速率固定;突發流量進佇列排隊,佇列滿了才丟棄 |
滑動視窗計數的公式(《系統設計面試》第 4 章):
目前窗口的請求數 + 上一個窗口的請求數 × 滑動窗口與上一個窗口重疊的比例
書中的例子:限制每分鐘 7 個,上一分鐘有 5 個,這一分鐘有 3 個,新請求在這一分鐘的 30% 位置到達 → 重疊 70% → ,無條件捨去成 6 → 還沒到 7,放行。
它假設上一個窗口的請求平均分布,所以是近似值。書引用 Cloudflare 的實驗:在 4 億個請求中,只有 0.003% 被錯誤放行或錯誤擋下。每個使用者只需要兩個計數器,是記憶體與準確度之間最好的平衡。
★ Go 的單機實作:golang.org/x/time/rate
rate.Limiter 就是一個權杖桶:容量 b、初始為滿、每秒補 r 個,可以安全地被多個 goroutine 同時使用(依 golang.org/x/time v0.16.0 文件,2026-09-25)。三個主要方法在沒有權杖時的行為不同:
| 方法 | 沒有權杖時 | 用在 |
|---|---|---|
Allow() | 立刻回傳 false | API 伺服器:擋掉並回 429 |
Wait(ctx) | 阻塞到拿到權杖或 ctx 被取消 | 呼叫外部 API 的客戶端:自我節流 |
Reserve() | 回傳一張預約,告訴你要等多久 | 需要算出 Retry-After 時 |
依使用者限流的中介層(已在本機以 Go 1.26.4 編譯執行):
type perKey struct {
mu sync.Mutex
m map[string]*rate.Limiter
r rate.Limit
b int
}
func (p *perKey) get(k string) *rate.Limiter {
p.mu.Lock()
defer p.mu.Unlock()
l, ok := p.m[k]
if !ok {
l = rate.NewLimiter(p.r, p.b)
p.m[k] = l
}
return l
}
func (p *perKey) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
res := p.get(r.Header.Get("X-API-Key")).Reserve()
if d := res.Delay(); d > 0 {
res.Cancel() // 不打算等,把預約的權杖還回去
w.Header().Set("Retry-After", strconv.Itoa(int(d.Seconds())+1))
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// 每 100ms 補一個(每秒 10 個)、容量 5
p := &perKey{m: map[string]*rate.Limiter{}, r: rate.Every(100 * time.Millisecond), b: 5}實測:同一個 key 連續呼叫 20 次 Allow(),放行 5 個(容量);等 300 ms 後再呼叫 20 次,放行 3 個(0.3 秒 × 每秒 10 個)——跟權杖桶的公式一致。
這個範例的缺陷:map 只增不減,每個出現過的 key 都永遠留著 → 記憶體洩漏。正式環境要定期清掉長時間沒用的 limiter,或用有容量上限的 LRU。
★ 分散式:計數放 Redis 時的兩個問題
書中點出的兩個問題:
- 競爭條件:「讀計數 → 檢查 +1 是否超過 → 寫回」三步不是原子的。兩個請求同時讀到 3,都寫回 4,實際應該是 5。書提到兩種常見解法:Lua 腳本(Redis 會原子地執行整段腳本)和 Redis 的 sorted set。
- 同步問題:多台限流伺服器各自計數 → 使用者換一台就重新計算。書不建議用 sticky session(不易擴展),建議集中式儲存如 Redis。
固定視窗的原子寫法(Redis 官方文件 INCR 指令的「限流器」模式;本機未安裝 Redis,未實際執行):
-- KEYS[1] = "rl:{user}:{minute}",ARGV[1] = 窗口秒數
local c = redis.call('INCR', KEYS[1])
if c == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return c為什麼要包成腳本:如果 INCR 和 EXPIRE 分兩次送,程式在兩者之間當掉 → 這個 key 永遠不會過期 → 該使用者被永久限流。
★ 回應要讓客戶端知道怎麼做
- 狀態碼 429 Too Many Requests。
- 書列出的標頭:
X-Ratelimit-Remaining(窗口內還剩幾次)、X-Ratelimit-Limit(每個窗口的上限)、X-Ratelimit-Retry-After(幾秒後可以重試)。HTTP 標準的Retry-After標頭也適用於 429。 - 客戶端收到 429 要指數退避加隨機抖動重試,否則所有客戶端在同一時刻重試,又造成一波尖峰。
🧪 我實際套用的紀錄
- 2026-07-15:(待填)
⚠️ 注意
-
分散式多實例要共享狀態,否則每台各自限流=實際上限被放大 N 倍。
-
限流要放在最便宜的地方:在閘道擋掉的請求,成本遠低於進到應用程式、查過資料庫之後才擋。多層都有限流時,外層放寬、內層收緊(見 工具-透明多級分流)。
-
Redis 掛了怎麼辦要先決定:fail-open(放行,服務可能被打垮)還是 fail-closed(全部擋掉,服務等於停擺)。多數 API 選 fail-open 並加上單機的
rate.Limiter當後備。
🔗 相關工具
- 工具-服務容錯設計(限流是容錯的一環)、工具-透明多級分流、工具-系統設計思考框架