題目與適用情境
為一個多租戶 API 設計分散式限流器。規則依 (tenantId, routeId) 生效,每條規則包含持續速率、突發容量與單次請求成本。系統部署 100 個無狀態 API 閘道,峰值為每秒 100 萬次限流檢查;面試中暫定限流鏈路新增延遲目標為 p99 不超過 5 毫秒,可用性目標為 99.99%。這些數字是本題的設計輸入,不是任何供應商的效能承諾。
超限請求回傳 HTTP 429,並提供可執行的重試時間。一般業務介面在限流儲存短暫故障時優先維持可用,登入、昂貴寫入等高風險介面可採用更嚴格的故障策略。系統要支援規則動態發布、灰度觀察、熱點租戶、多地域部署與水平擴充;計費、DDoS 清洗、排隊調度與後端自適應負載丟棄不在本題範圍內。
這道題適合中高階後端、平台與基礎設施職位。面試官要看候選人能否把「一個請求是否消耗配額」落實為可擴充、可觀測且有明確失敗語意的同步決策鏈路。
面試官考察重點
強回答會先鎖定限流維度、準確性與故障語意。若 100 個閘道各自保存完整令牌桶,同一租戶可在每個閘道分別消耗配額,實際放行量會隨執行個體數量與負載分布改變。共享狀態或受租約約束的本地狀態才具有全域意義。
第二個訊號是把原子邊界畫對。令牌桶的一次判斷包含讀取舊餘額、依時間補充令牌、判斷餘額、扣減與寫回。把這些步驟拆成多次網路請求,即使單條 Redis 命令具備原子性,也會讓並行請求讀到同一餘額並重複消耗。讀—算—寫必須在同一個短 Lua 指令碼或等價的伺服器端原子操作中完成。
第三個訊號是有數量級。峰值每秒 100 萬次檢查表示資料面也要承受每秒 100 萬次同步決策,不能在請求路徑上查詢設定資料庫。假設有 1000 萬個活躍桶,鍵平均 64 位元組,兩個 8 位元組狀態欄位,邏輯資料約為 10,000,000 × 80 B = 800 MB;Redis 物件、雜湊表、過期索引與複寫會顯著增加實際占用,容量規劃必須用真實鍵形態壓測。若每個檢查請求約 100 位元組、回應約 32 位元組,單向入站約 100 MB/s、出站約 32 MB/s,也應納入網卡與代理預算。
最後看取捨是否閉環:令牌桶為何符合突發需求、熱鍵如何拆分、儲存失聯要放行還是拒絕、非同步複寫可能造成什麼偏差、多地域要精確上限還是分區可用性,以及上線前如何用影子模式觀察誤判。
回答前需要釐清的問題
- 限流鍵是什麼? 本題依租戶與正規化路由組合限流,不直接把原始 URL、查詢參數或未經驗證的轉送 IP 放進鍵,避免無界基數與身分偽造。
- 規則是固定視窗還是允許突發? 本題要求持續速率加突發容量,因此選擇令牌桶。若要求任意滑動視窗內絕不超過
N次,演算法與儲存成本都會改變。 - 一次請求是否只消耗一個令牌? 預設成本為 1,也允許依介面傳入正整數成本;餘額與補充速率使用固定精度整數,避免浮點累積誤差。
- 限流要多精確? 單地域正常路徑要求同一桶的判斷具備原子性;故障切換與多地域下允許的超放或少放必須另外約定,不能只說「最終一致」。
- 儲存故障時優先可用還是優先保護? 一般讀取介面採用逾時後放行並疊加閘道本地緊急上限;登入、計費相關或高成本介面可拒絕或使用預先分配的租約。
- 規則多久生效? 控制面替規則加上單調遞增版本,推送並快取到限流服務;資料面不在每個請求上查詢資料庫,版本落後的執行個體需要監控。
- 429 回應需要什麼? 回傳機器可讀原因與
Retry-After。IETF 對 429 定義了「給定時間內請求過多」,並允許用Retry-After告知等待時間,但沒有規定伺服器端必須採用哪一種計數演算法。
30 秒回答框架
「我會把限流檢查放在 API 閘道,閘道把租戶、正規化路由、成本與規則版本傳給無狀態限流服務。服務依 (tenantId, routeId) 計算穩定鍵,在 Redis Cluster 的單一分片上用短 Lua 指令碼,原子完成令牌補充、判斷、扣減、TTL 與重試時間計算。規則由獨立控制面版本化並推送,資料面只讀本地快取。正常路徑水平擴充限流服務與 Redis 分片;熱鍵改用配額租約。一般介面故障時放行並啟用本地保護,高風險介面拒絕。上線先以影子模式執行,觀察延遲、超限率、錯誤率、熱鍵與設定版本,再逐步執行攔截。」
分步深入解答
先做容量拆分。100 個閘道平均每個承擔每秒 1 萬次檢查,但不能依平均值設定連線池與執行個體數量,應按故障轉移後的峰值與安全餘量壓測。若單個 Redis 分片在真實 Lua 指令碼、鍵大小、持久化與複寫設定下測得安全吞吐為 Q,主分片數至少是 ceil(1,000,000 / Q),再為故障與擴充預留餘量。這裡刻意不套用網路上的「單機每秒多少次」,因為指令碼長度、硬體、網路與複寫模式都會改變結果。
資料面由五部分組成:閘道限流過濾器、無狀態限流服務、分片狀態儲存、規則控制面與可觀測管線。閘道負責可信身分解析與路由正規化;限流服務負責規則比對與決策;Redis Cluster 保存活躍桶;控制面從資料庫發布帶版本的規則;指標管線記錄允許、拒絕、錯誤、故障放行與延遲。成熟代理已採用「過濾器產生 descriptor、呼叫外部限流服務、超限回傳 429」的邊界,說明這個介面可以獨立於業務服務演進。
內部 API 可以這樣定義:
CheckRateLimitRequest {
tenant_id: string
route_id: string
cost: uint32
rule_version: uint64
}
CheckRateLimitResponse {
allowed: bool
remaining: uint64
retry_after_ms: uint64
rule_id: string
applied_rule_version: uint64
}閘道只接受認證鏈路產生的 tenantid,routeid 來自路由範本,例如 /orders/:id,不能使用會隨訂單編號成長的原始路徑。Redis 鍵可寫成 rl:{tenantId:routeId};大括號內的穩定標籤讓一個桶的狀態落在同一個 Cluster 槽位。狀態只需 tokens 與 lastrefillms,缺少鍵表示一個已補滿的新桶。限流鍵應使用容量獨立、禁止靜默淘汰的儲存空間;若記憶體策略淘汰仍活躍的桶,下次存取會錯誤地恢復滿額。
令牌桶每毫秒補充 refillperms 個固定精度令牌,容量為 capacity。一次決策的核心是:
elapsed = max(0, now_ms - last_refill_ms)
available = min(capacity, tokens + elapsed * refill_per_ms)
if available >= cost:
allowed = true
available = available - cost
retry_after_ms = 0
else:
allowed = false
retry_after_ms = ceil((cost - available) / refill_per_ms)指令碼接著寫回餘額與時間,並設定「桶從空到補滿所需時間加安全餘量」的 TTL。閒置時間超過補滿時間後刪除鍵是安全的,因為下次存取本來就應看到滿桶。時間應來自同一桶穩定使用的可信時鐘來源,並把負數 elapsed 歸零;令牌計算使用整數縮放。Redis 文件明確保證指令碼原子執行,但指令碼會阻塞同一執行個體的其他活動,所以邏輯必須短小,不能在其中掃描鍵或存取多個無關桶。
演算法選擇要與需求綁定。固定視窗每個鍵只需計數與過期時間,成本低但視窗邊界兩側可能連續放行兩批。滑動日誌保存視窗內每次請求時間,精確但每鍵空間隨請求數增加。滑動視窗計數器空間固定,卻是近似值。令牌桶用兩個狀態量表達持續速率與可控突發;AWS API Gateway 的公開文件也將 rate 與 burst 分開,並說明其限流是 best-effort 目標。因此本文的自建方案不能借供應商行為宣稱「任何故障下都絕不超額」。
正常請求流程如下:閘道解析身分與正規路由,由本地路由設定產生 descriptor 與期望規則版本;呼叫同地域限流服務;服務從本地規則快取取得參數,定位 Redis 分片並執行原子指令碼;允許時把請求轉給後端,拒絕時回傳 429、原因與依餘額算出的 Retry-After。內部的毫秒等待值向上取整為 HTTP 標頭使用的整秒數,JSON 回應仍可保留毫秒精度。控制面發布新版本時先驗證規則,再推送到限流服務;執行個體回報已套用版本。緊急規則可推送,一般規則允許短暫傳播延遲,但同一回應必須帶實際套用版本,方便定位版本分歧。
為了守住 5 毫秒 p99 目標,閘道到限流服務以及限流服務到 Redis 都使用長連線與連線池,每次判斷只產生一次 Redis 往返,並替內部呼叫設定約 3 毫秒的截止時間,為閘道處理預留預算。限流服務優先選擇近端執行個體;壓測若仍無法達標,就要減少網路跳數或對高流量租戶使用本地租約。目標只能由峰值負載下的端到端分位數證明,不能用 Redis 單次命令的平均延遲代替。
瓶頸通常出現在熱鍵、共享儲存與同步網路跳數。某個大租戶若占到每秒 10 萬次檢查,即使叢集總吞吐足夠,也可能壓滿一個槽位。解法是由全域桶向多個限流節點發放小額、短期令牌租約,本地消耗後再續租。租約降低共享儲存存取,卻帶來未用令牌浪費、節點失聯後的回收延遲與公平性問題;所有未到期租約總和必須受總預算約束。一般租戶繼續走共享桶,避免為少數熱點增加全域複雜度。
故障策略依業務風險分層。限流服務逾時或 Redis 短暫不可用時,一般讀取介面可 fail-open,同時由每個閘道的粗粒度緊急限流器保護後端,並記錄 failuremodeallowed;這個本地上限只保護容量,不承諾租戶全域配額。登入、昂貴寫入或防濫用介面可 fail-closed,或只消耗先前取得的租約。Redis 主從切換若使用非同步複寫,最近扣減可能遺失並造成短暫超放;答案應把它寫進誤差預算,不能把「有副本」等同於零資料遺失。
多地域嚴格共用一個桶會把跨地域網路延遲與分區放進每個請求。更實用的方案是中央配額分配器按短週期向各地域租出令牌,地域內再原子消耗。總租約不超過全域預算時不會憑空增加配額,但網路分區中的地域只能使用剩餘租約,犧牲部分可用性;若允許各地域先放行再非同步合併,可用性更高,但超放上界要依地域數與本地額度計算。面試中必須讓業務選擇這條精確性—可用性邊界。
上線從影子模式開始:計算決策但不攔截,核對哪些租戶與路由會被拒絕;再依規則與租戶小流量啟用。監控至少包括決策 p50/p95/p99、允許/拒絕/錯誤/故障放行計數、Redis 指令碼耗時、分片負載與熱鍵、規則版本落後量,以及後端在限流前後的飽和度。Stripe 的公開工程實務也強調暗發布、開關、清楚錯誤與故障安全,這些操作面資訊比只畫一張元件圖更接近生產設計。
高品質示範回答
「我先把目標定為單地域正常路徑下,對 (tenantId, routeId) 的令牌消耗做原子判斷。輸入是 100 個閘道、峰值每秒 100 萬次檢查、p99 新增延遲 5 毫秒。每條規則有持續速率、突發容量與請求成本,超限回傳 429 與 Retry-After。
閘道解析可信租戶身分並把原始 URL 正規化成路由範本,接著呼叫同地域的無狀態限流服務。服務從本地快取取得帶版本規則,用 rl:{tenantId:routeId} 定位 Redis Cluster 槽位。一個短 Lua 指令碼讀取令牌與上次補充時間,依經過時間補充到容量上限,判斷並扣減,再寫回 TTL;讀、算、寫位於同一個原子邊界內,避免兩個閘道重複消耗最後一個令牌。
容量方面,我會用真實指令碼壓測得到單分片安全吞吐 Q,至少設定 ceil(1,000,000 / Q) 個主分片並預留故障餘量。1000 萬個活躍桶依 64 位元組鍵加兩個 8 位元組欄位計算,邏輯資料約 800 MB,實際記憶體還要加入 Redis 物件、過期索引與副本開銷。熱租戶不能只靠增加一般分片,我會向限流節點發放小額短租約,讓本地消耗並把未到期租約總量納入全域預算。
故障語意依路由分層:一般讀取介面逾時後放行,但啟用閘道本地保護並記錄故障放行;登入與高成本寫入拒絕,或只使用已有租約。多地域也採用配額租約,明確在分區時於精確上限與可用性之間做選擇。發布先跑影子模式,再依租戶灰度,盯住決策延遲、超限、錯誤、故障放行、熱鍵與規則版本。這樣系統的正常精度、故障偏差與營運回滾都有可驗證邊界。」
常見錯誤
- 每個閘道維護完整本地桶 → 同一配額被執行個體數量放大,負載平衡變化也會改變結果 → 正常流量使用共享原子狀態,熱點最佳化使用總額受約束的租約。
- 先
GET,在應用程式裡計算,再SET→ 並行請求可讀到相同餘額並重複扣減 → 把讀取、補充、判斷、扣減與寫回放進一個短伺服器端指令碼。 - 只說「用 Redis 就能撐住」 → 沒有吞吐、記憶體、複寫與熱鍵預算,無法判斷設計是否可行 → 用真實指令碼壓測
Q,據此計算分片數並回報熱點分布。 - 允許限流鍵被記憶體策略靜默淘汰 → 活躍桶消失後會以滿桶重建,造成無監控的超放 → 使用獨立容量與非淘汰策略,並對記憶體水位和寫入失敗發出警示。
- 用原始 URL 或用戶端自報身分作為鍵 → 鍵基數失控或配額可被繞過 → 使用認證內容中的租戶與路由範本產生 descriptor。
- 把 429 當成固定視窗專屬回應 → 協定狀態碼沒有規定內部演算法 → 回傳 429、原因與依目前演算法計算的
Retry-After。 - 所有故障都 fail-open → 登入、昂貴寫入與防濫用鏈路失去保護 → 依路由風險設定 fail-open、fail-closed 或預先分配租約。
- 宣稱 Redis 副本保證絕不超放 → 非同步複寫與故障切換可能遺失最近扣減 → 把複寫遺失寫入誤差預算,並為嚴格情境選擇更強一致性或較少可用性。
- 多地域各自設定完整額度 → 全域上限最多依地域數被放大 → 由中央分配地域租約,或明確計算並接受非同步方案的超放上界。
- 直接全量開啟攔截 → 錯誤規則會立刻影響真實流量 → 先影子計算,再依規則、租戶與比例灰度,並保留關閉開關。
追問與應對
追問一:如何實作嚴格的全球上限?
最直接的方法是讓所有地域同步存取一個強一致配額服務,但每個請求都會承受跨地域延遲,分區時也必須拒絕部分請求。更常用的是中央分配器依短週期發放地域租約,保證所有有效租約總和不超過全域額度。地域可以低延遲在本地消耗,代價是閒置租約造成利用率下降,分區時只能消耗剩餘額度。若業務要求分區期間繼續全量服務,就必須接受可計算的超放,三者不能同時免費取得。
追問二:一個超級租戶形成熱鍵怎麼辦?
單純增加 Redis 分片不會拆開同一個鍵。讓全域桶向多個限流節點發放例如幾百個令牌的短租約,節點在記憶體中原子消耗,低水位時續租。租約批次越大,共享儲存壓力越低,但節點當機時的浪費與短時不公平越大;批次越小,精度更高但續租更頻繁。依熱點程度動態調整批次,並監控未使用租約與續租延遲。
追問三:Redis 完全不可用時如何避免後端被打垮?
一般介面的 fail-open 旁邊必須有獨立本地保護:每個閘道設定粗粒度總入口上限、連線池上限與斷路器,不嘗試維持精確租戶配額。高風險介面 fail-closed 或只消耗預取租約。逾時要遠小於業務請求預算,故障放行單獨計數並警示。儲存恢復後不補扣已放行請求,避免恢復瞬間產生第二次衝擊。
追問四:限流服務逾時後重試,會不會重複扣令牌?
會。伺服器端可能已經扣減,只是回應遺失。低成本請求可以約定閘道不在同步路徑重試,直接執行該路由的故障策略。若扣減必須具備冪等性,請求帶唯一 requestId,指令碼在與桶相同的 Cluster 槽位保存短期去重記錄並回傳第一次結果;這會增加記憶體與寫入放大,TTL 至少涵蓋呼叫端重試視窗。不能只在 RPC 層開啟自動重試。
追問五:同時有租戶總額度和介面額度,如何保證一次判斷一致?
把鍵改為 rl:{tenantId}:global 與 rl:{tenantId}:route:<routeId>,讓同一租戶的相關鍵進入同一個 Cluster 槽位。指令碼先計算所有桶,只有全部允許時才統一扣減。規則很多時,單次指令碼成本與熱鍵都會增加;可把最關鍵的兩層放在原子路徑,其餘作為獨立保護,並明確說明可能出現的部分計數。跨槽位交易會顯著增加複雜度,不應在沒有嚴格業務要求時預設引入。