跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 限流器

限流器

限制每個用戶每秒能打幾次。固定視窗、滑動視窗、Token bucket 三種做法,差別在邊界上和突發流量時。

流量
10
10
程式碼
放行(上方短線)拒絕(下方短線)放行最多的那一秒
0s1s2s3s4s5s6s7s8s9s10s固定視窗一秒內最多 18 ⚠0.33 s · 放行0.33 s · 放行0.58 s · 放行1.90 s · 放行3.05 s · 放行3.16 s · 放行3.48 s · 放行3.72 s · 放行3.75 s · 放行3.78 s · 放行3.81 s · 放行3.83 s · 放行3.87 s · 放行3.90 s · 放行3.90 s · 拒絕3.92 s · 拒絕3.96 s · 拒絕3.98 s · 拒絕4.01 s · 放行4.04 s · 放行4.08 s · 放行4.09 s · 放行4.10 s · 放行4.13 s · 放行4.17 s · 放行4.20 s · 放行4.22 s · 放行4.25 s · 放行4.29 s · 拒絕5.84 s · 放行6.05 s · 放行6.27 s · 放行6.32 s · 放行6.49 s · 放行6.59 s · 放行6.64 s · 放行6.87 s · 放行6.89 s · 放行7.06 s · 放行7.54 s · 放行7.66 s · 放行7.73 s · 放行7.74 s · 放行7.93 s · 放行8.22 s · 放行8.76 s · 放行8.87 s · 放行8.95 s · 放行9.31 s · 放行9.57 s · 放行滑動視窗(記錄)一秒內最多 100.33 s · 放行0.33 s · 放行0.58 s · 放行1.90 s · 放行3.05 s · 放行3.16 s · 放行3.48 s · 放行3.72 s · 放行3.75 s · 放行3.78 s · 放行3.81 s · 放行3.83 s · 放行3.87 s · 放行3.90 s · 放行3.90 s · 拒絕3.92 s · 拒絕3.96 s · 拒絕3.98 s · 拒絕4.01 s · 拒絕4.04 s · 拒絕4.08 s · 放行4.09 s · 拒絕4.10 s · 拒絕4.13 s · 拒絕4.17 s · 放行4.20 s · 拒絕4.22 s · 拒絕4.25 s · 拒絕4.29 s · 拒絕5.84 s · 放行6.05 s · 放行6.27 s · 放行6.32 s · 放行6.49 s · 放行6.59 s · 放行6.64 s · 放行6.87 s · 放行6.89 s · 放行7.06 s · 放行7.54 s · 放行7.66 s · 放行7.73 s · 放行7.74 s · 放行7.93 s · 放行8.22 s · 放行8.76 s · 放行8.87 s · 放行8.95 s · 放行9.31 s · 放行9.57 s · 放行Token bucket一秒內最多 16 ⚠0.33 s · 放行0.33 s · 放行0.58 s · 放行1.90 s · 放行3.05 s · 放行3.16 s · 放行3.48 s · 放行3.72 s · 放行3.75 s · 放行3.78 s · 放行3.81 s · 放行3.83 s · 放行3.87 s · 放行3.90 s · 放行3.90 s · 放行3.92 s · 放行3.96 s · 放行3.98 s · 放行4.01 s · 放行4.04 s · 放行4.08 s · 拒絕4.09 s · 拒絕4.10 s · 拒絕4.13 s · 放行4.17 s · 拒絕4.20 s · 拒絕4.22 s · 放行4.25 s · 拒絕4.29 s · 拒絕5.84 s · 放行6.05 s · 放行6.27 s · 放行6.32 s · 放行6.49 s · 放行6.59 s · 放行6.64 s · 放行6.87 s · 放行6.89 s · 放行7.06 s · 放行7.54 s · 放行7.66 s · 放行7.73 s · 放行7.74 s · 放行7.93 s · 放行8.22 s · 放行8.76 s · 放行8.87 s · 放行8.95 s · 放行9.31 s · 放行9.57 s · 放行桶裡的 token0 到 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 On = 10n = 100n = 1,000n = 10,000成長倍數:實測(理論)
固定視窗:存幾個數O(1)2222×1.0 (×1.0)
Token bucket:存幾個數O(1)2222×1.0 (×1.0)
滑動視窗記錄:存幾個數O(n)101001,00010,000×1,000 (×1,000)
滑動視窗記錄:每個請求的工作O(1)1.31.41.51.5×1.1 (×1.0)

滑動視窗記錄準確,代價是記憶體:上限每秒一萬次,每個用戶就要存一萬個時間戳。每個請求的工作量倒是不隨上限變大。

和其他做法比

每個用戶要存什麼一秒內最多放行(跨邊界突發)一秒內最多放行(持續略超量)
固定視窗一個計數器+視窗起點1817
滑動視窗(記錄)最多「上限」個時間戳記1010
Token buckettoken 數+上次補充時間1619

上限每秒 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 倍;共用計數器又會多一次網路往返。

LeetCode 練習