限流器
限制每個用戶每秒能打幾次。固定視窗、滑動視窗、Token bucket 三種做法,差別在邊界上和突發流量時。
流量
10
10
程式碼
放行(上方短線)拒絕(下方短線)放行最多的那一秒
固定視窗
放行 45/50⚠ 某一秒放了 18 個
滑動視窗(記錄)
放行 37/50
Token bucket
放行 43/50⚠ 某一秒放了 16 個
這三種限流器都號稱「每秒 10 個」。固定視窗在某一秒內放過了 18 個,因為計數在邊界上歸零;滑動視窗從沒放過超過 10 個;Token bucket 放過了 16 個——滿桶的 10 個加上這一秒補回來的,這個突發是它刻意允許的。
亮起來的是這一步執行的程式碼
class FixedWindow { private window = -1; private count = 0; constructor(private limit: number) {} allow(now: number): boolean { const w = Math.floor(now); if (w !== this.window) { this.window = w; this.count = 0; } if (this.count >= this.limit) return false; this.count += 1; return true; }} class SlidingWindowLog { private log: number[] = []; // times allowed, oldest first constructor(private limit: number) {} allow(now: number): boolean { while (this.log.length > 0 && this.log[0] <= now - 1) this.log.shift(); if (this.log.length >= this.limit) return false; this.log.push(now); return true; }} class TokenBucket { private tokens: number; private last = 0; constructor(private capacity: number, private ratePerSec: number) { this.tokens = capacity; // starts full } allow(now: number): boolean { this.tokens = Math.min(this.capacity, this.tokens + (now - this.last) * this.ratePerSec); this.last = now; if (this.tokens < 1) return false; this.tokens -= 1; return true; }}模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 比較單一限制器對同一條時間線的判斷;分散式計數器同步、時鐘偏差與網路成本未納入。
什麼時候用
- 保護 API 不被單一用戶(或程式寫錯的用戶)打爆,讓大家公平分享容量。
- 要嚴格保證任何一秒都不超量(例如付費第三方 API 的配額)時用滑動視窗;想允許偶爾的突發、平均不超量時用 token bucket。
- 只要大概擋住濫用、越省越好時,固定視窗最簡單:一個計數器就夠了。
和其他主題的關係
- 延伸閱讀
- 負載平衡
出現在這些架構裡
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 固定視窗:判斷一個請求 | O(1) | O(1) |
| 滑動視窗記錄:判斷一個請求 平均是攤銷的 O(1):每個時間戳只進來一次、出去一次;但安靜一秒之後的那個請求要一次清掉整整一秒的紀錄 | O(1) | O(L) |
| Token bucket:判斷一個請求 | O(1) | O(1) |
空間:O(1) / O(L),每個用戶:固定視窗和 token bucket 只存兩個數;滑動視窗記錄最多要存 L 個時間戳
Big O 實測:n 變大時步數怎麼長
數的是:每個用戶要存幾個數字(峰值;n = 每秒上限,流量持續超量 30%、跑 10 秒);最後一列是每個請求動到幾筆紀錄
| Big O | n = 10 | n = 100 | n = 1,000 | n = 10,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| 固定視窗:存幾個數 | O(1) | 2 | 2 | 2 | 2 | ×1.0 (×1.0) |
| Token bucket:存幾個數 | O(1) | 2 | 2 | 2 | 2 | ×1.0 (×1.0) |
| 滑動視窗記錄:存幾個數 | O(n) | 10 | 100 | 1,000 | 10,000 | ×1,000 (×1,000) |
| 滑動視窗記錄:每個請求的工作 | O(1) | 1.3 | 1.4 | 1.5 | 1.5 | ×1.1 (×1.0) |
滑動視窗記錄準確,代價是記憶體:上限每秒一萬次,每個用戶就要存一萬個時間戳。每個請求的工作量倒是不隨上限變大。
和其他做法比
| 每個用戶要存什麼 | 一秒內最多放行(跨邊界突發) | 一秒內最多放行(持續略超量) | |
|---|---|---|---|
| 固定視窗 | 一個計數器+視窗起點 | 18 | 17 |
| 滑動視窗(記錄) | 最多「上限」個時間戳記 | 10 | 10 |
| Token bucket | token 數+上次補充時間 | 16 | 19 |
上限每秒 10 個、桶容量 10,同一份 10 秒的請求序列跑三種演算法;「一秒內」是任意起點的一秒,不是對齊整秒的視窗。
真實世界裡的它
- HTTP 429 Too Many Requests 加上
Retry-After標頭,就是限流器拒絕你時的回應。 - GitHub、Stripe 等 API 的每小時/每秒配額;nginx 的
limit_req是 leaky bucket,和 token bucket 是同一家族。 - 分散式的版本通常把計數器放在 Redis,讓每台 API 伺服器看到同一份計數。
取捨與陷阱
- 固定視窗的邊界:在 0.9 秒和 1.1 秒各打滿一次上限,就在 0.2 秒內放進了兩倍的量。
- 滑動視窗記錄每個請求的時間戳,用戶多、上限高時很吃記憶體;實務上常用「前後兩個固定視窗加權」來近似它。
- 多台伺服器各自限流時,總量會變成 N 倍;共用計數器又會多一次網路往返。