系統設計面試:如何設計多租戶 API 限流器?
題目與適用情境
請為多租戶 API 平台設計限流器:免費租戶每分鐘 100 次、付費租戶每分鐘 10,000 次,平台有多個應用實例與區域。請比較演算法,說明共享狀態、429 回應、故障降級與驗證方法。
這題適合後端、平台與系統設計職位。公開面試紀錄提供了依使用者維護 token bucket、按時間補充令牌的編碼題;系統設計資料則把限流列為多租戶 API 的典型討論。分類依據是跨實例共享狀態、租戶策略與流量保護,不是記憶某家雲端的設定。
面試官考察點
- 能否先區分平均速率、突發容量、租戶公平與全域保護目標。
- 能否解釋 token bucket、fixed window、sliding window 的邊界與代價。
- 能否把限流狀態放到可原子更新的共享儲存,並處理熱鍵、分區與時鐘問題。
- 能否定義 429、重試提示、降級與觀測,讓限流器本身不成為單點。
回答前需要澄清的問題
- 限制對象是 API key、租戶、使用者、IP,還是它們的組合?匿名請求如何歸屬?
- 限制是平均速率、嚴格滑動視窗,還是允許可控突發?是否還需要每日配額?
- 多區域要求全球精確,還是允許短暫超額來換取可用性?
- 被拒請求應立即回傳 429,還是進入有界佇列等待?下游是否完全不能承受突發?
30 秒回答架構
我會先在閘道做粗粒度的 IP 與未驗證流量保護,再在應用層按租戶策略做精確限流。若 API 允許短突發,預設選 token bucket;每個桶保存令牌數與上次更新時間,按租戶維度放在可原子更新的共享儲存。超過限制回傳 429 與可計算的重試提示。多區域若不要求全球精確計數,就按區域配額與超額監控換取低延遲;儲存不可用時採用明確的 fail-open 或 fail-closed 策略並持續告警。
分步深入解答
先定義預算與公平性
免費租戶的每分鐘 100 次是長期預算,付費租戶的每分鐘 10,000 次也是長期預算;兩者都需要獨立 burst capacity,不能只用一個全域計數器。全域保護還應限制所有租戶合計的 RPS,避免單一大租戶耗盡資料庫連線池。策略鍵至少包含租戶 ID、API 動作與策略版本,避免不同介面意外共用額度。
選擇演算法
| 演算法 | 主要語意 | 成本與風險 | 適用條件 |
|---|---|---|---|
| Token bucket | 平均速率受限,允許容量內突發 | 兩個狀態值,需原子補充與扣減 | 面向使用者的 API,可接受短突發 |
| Fixed window | 固定時間段計數 | 視窗邊界可能接近兩倍流量 | 規則簡單、允許近似 |
| Sliding window log | 精確統計視窗內每個請求 | 保存時間戳,記憶體與清理成本高 | 小規模且需要嚴格精度 |
| Sliding window counter | 用相鄰視窗加權估算 | 近似但記憶體低,需說明誤差 | 大規模公平限流 |
AWS API Gateway 文件明確說明其 throttling 使用 token bucket,令牌速率與 burst 容量分別表達穩態速率與突發上限;超額可能回傳 429,但限制是 best effort,不應視為數學上絕對的天花板。回答時要把「策略目標」與「平台保證」分開。
Token bucket 的不變量
令牌數 tokens 始終位於 [0, capacity]。請求到達時,先用經過時間乘 refill rate 補充令牌並截斷到 capacity,再檢查是否至少有一個令牌;允許請求就扣減一個。這個順序保證閒置期間只累積到 burst 上限,不會因停機時間製造無限額度。
~~~text allow(key, now): state = atomicRead(key) elapsed = max(0, now - state.lastRefill) refilled = min(capacity, state.tokens + elapsed * rate) if refilled < 1: atomicWrite(key, refilled, now) return reject(429) atomicWrite(key, refilled - 1, now) return allow ~~~
偽程式碼在真實實作中必須由一次 Lua 腳本、交易或等價 compare-and-swap 原子執行;先讀後寫的兩個獨立網路請求會在併發下超發令牌。時間戳應由可信的單調時間來源處理,不能讓客戶端提交時間。
部署層級與共享狀態
閘道層負責擋住明顯的 IP 洪泛與未驗證流量;應用層按租戶、使用者或介面做業務限額。所有實例若只在本機記憶體計數,負載平衡後一個租戶可以把請求分散到多個實例而取得多份額度。共享 Redis、帶原子腳本的鍵值儲存或具備條件寫入的資料庫都可以承載狀態,選擇取決於延遲、精度與故障模型。
多區域有三種取捨:單一全球儲存提供較精確額度但增加跨區延遲;每區獨立桶延遲低但可能產生短暫超額;租戶先分配區域配額並設全域保護則介於兩者之間。必須先問清「精確」是否比「可用」重要,不能把區域拆分後仍聲稱全球嚴格不超限。
拒絕、降級與觀測
超限回傳 429,並提供 Retry-After 或回應標頭中的剩餘額度;客戶端應使用有上限的退避,不能立即重試形成回饋迴圈。限流儲存故障時,高風險寫入介面通常 fail-closed 或進入有界佇列,低風險讀取介面可短時間 fail-open,但必須有本機熔斷、過期時間與總量保護。監控應區分允許率、拒絕率、每租戶額度命中、儲存延遲、熱鍵、腳本錯誤與實際下游負載。
高品質示範回答
我會把限流分兩層:閘道先按 IP 與驗證狀態做粗粒度保護,應用層再按租戶與介面執行業務策略。免費租戶與付費租戶分別擁有 rate 與 burst 兩個參數;預設用 token bucket,因為 API 通常允許短突發而仍需限制長期平均速率。每個桶保存令牌數與上次補充時間,補充、檢查和扣減由共享儲存中的原子腳本完成。
多區域如果不要求每次請求都得到全球精確結果,我會按區域分配配額,並用全域指標偵測異常超額;若支付或配額結算必須嚴格,就把決策放到一致性更強的中心服務並接受延遲。超限回傳 429 與重試提示,儲存故障按介面風險選擇 fail-closed、短時間 fail-open 或有界佇列。最後用併發壓測驗證突發容量、視窗邊界、租戶公平、故障恢復與下游負載,而不是只檢查 429 數量。
常見錯誤
- 只說「用 Redis 計數」 → 沒有演算法語意和原子更新 → 寫出桶狀態、腳本邊界與故障策略。
- 所有租戶共用一個限額 → 大租戶可以擠占小租戶 → 按租戶與介面拆分策略,並補充全域保護。
- 把 fixed window 當成嚴格每分鐘上限 → 視窗邊界可形成近兩倍短時流量 → 說明誤差並換 sliding 或 token bucket。
- 限流儲存故障就無限放行 → 保護元件失效後下游先崩潰 → 按業務風險選擇有界降級並告警。
- 讓客戶端立即重試 429 → 拒絕流量變成更高負載 → 回傳重試提示並要求退避、抖動與上限。
追問與應對
同一租戶的請求被分到 20 個實例,如何避免額度放大?
把桶狀態放到所有實例可見的共享儲存,並以租戶策略鍵做原子更新。若暫時只能本地計數,就必須表述為近似保護,並設定閘道總量上限,不能聲稱全球精確。
跨區域必須嚴格執行每分鐘 100 次,怎麼辦?
優先選擇一致性更強的全球決策點,或按租戶固定主區域串行化扣減;代價是跨區延遲與區域故障時的可用性下降。若業務允許短暫超額,可改用區域配額加全域對帳,並把誤差寫進 SLO。
限流器本身變慢,如何避免拖垮 API?
給限流呼叫設定嚴格逾時與熔斷,避免無限等待;預留本機保護上限,在共享儲存不可用時按介面風險降級。把限流延遲、逾時與腳本錯誤單獨監控,不能只看業務介面的最終成功率。
什麼時候應該排隊而不是回傳 429?
如果請求可非同步處理、等待不會超過使用者預算且下游必須平滑輸入,可以使用有界佇列;佇列滿時仍需拒絕。互動式讀取或不可取消的長佇列通常直接 429 更誠實。
如何驗證 fixed window 的邊界問題?
構造視窗結束前發送一批、視窗開始後立即發送一批的測試,統計任意連續視窗內的實際請求數;再對 token bucket 的閒置累積、滿桶突發與併發扣減做同樣的時間驅動測試。