凌晨兩點半,手機在枕頭邊瘋狂震動。我睡眼惺忪地點開告警,看到的是撮合引擎前面那層 API 的延遲曲線——平常 p99 大概十幾毫秒,那一刻直接衝到八秒,而且還在往上爬。我第一個反應是「資料庫掛了?」結果不是。資料庫好好的,Redis 好好的,撮合核心也好好的。真正的兇手,是一個客戶端。
那是一個做量化的用戶,他的程式在處理下單失敗時寫了一段重試邏輯,但他沒有加退避(backoff)。下單一失敗,立刻重試,再失敗,再立刻重試。偏偏那段時間我們有個下游短暫抖動,於是這位老兄的程式進入了無限重試迴圈,從一台機器、用同一把 API key,每秒打進來大概三千多個請求。三千多 QPS 聽起來不多,但它全部集中在同一個下單端點,而那個端點背後是要進撮合佇列、要寫部位、要算保證金的——是我們整個系統裡最貴的一條路徑。
更慘的是當時那個端點上沒有限流。沒有。我們在最外層的 Nginx 擋了一些粗暴的東西,但針對「單一 key 對單一昂貴端點」的細粒度限流,當時根本沒做。結果就是這一個客戶端的重試風暴,把我們的 worker 連線池吃乾,正常用戶的下單請求排在後面排到天荒地老,p99 飆到八秒。那天晚上我手動在閘道把那把 key 封了五分鐘,系統才喘過氣。
隔天的事後檢討(postmortem)我寫得很痛。因為這完全是可以避免的。限流不是上線之後行有餘力才補的東西,它是對外 API 的基本求生裝備。這篇我想把當時補的東西、以及後來我對限流的整套理解,老老實實講一遍。
限流到底在保護誰
很多人以為限流是拿來擋駭客的。擋惡意攻擊當然是其中一項,但我那晚的教訓告訴我:限流最常救你的,其實是擋「自己人」和「不小心」。
那個量化用戶不是壞人,他只是程式寫得急了點,少加了退避。一個寫壞的客戶端、一個忘了關的爬蟲、一個被 retry library 預設值坑到的服務,殺傷力跟惡意攻擊一模一樣。限流真正要做的,是讓任何單一來源,不管它是好意還是惡意,都不能把整個系統的資源吃光、害到其他所有正常使用者。
我現在會這樣跟團隊解釋限流要守的幾條防線:
- 擋掉惡意的暴力刷接口、撞庫、薅羊毛
- 防止一個失控的客戶端(無限重試、死迴圈)把大家一起拖下水
- 保護下游:你的資料庫、第三方金流、KYC 供應商都有它們的承受極限,限流讓灌進去的流量維持在它們扛得住的範圍
- 維持公平:別讓少數重度使用者把吞吐量全佔走,讓其他人連門都進不來
那晚之後我多了一條私人信條:任何一條會碰到撮合、金流、或寫入核心狀態的端點,沒有限流就不准上線。
幾個演算法,以及它們真正的差別
補限流的時候我重新把幾個經典演算法捋了一遍。它們不是學術名詞,每一個背後都對應到一種你真實會遇到的流量形狀。
固定視窗計數
最直覺的做法:在固定的一段時間裡數請求次數,例如「每分鐘最多 100 次」,超過就擋。一個計數器、一個過期時間,五行程式碼搞定。
但它有個我吃過虧的缺陷,叫邊界突刺。假設限制是每分鐘 100 次,有人可以在第 0 分 59 秒打滿 100 次,再在第 1 分 00 秒打滿 100 次。從計數器的角度看,這兩個視窗各自都沒超標;但從真實世界看,這短短兩秒內你的端點吃了 200 個請求。對撮合這種尖峰敏感的系統,這個瞬間的雙倍突刺就足以讓佇列堆積。
滑動視窗
為了治邊界突刺,滑動視窗看的不是切死的整分鐘,而是「此刻往前數 60 秒」這個一直在移動的區間。它平滑得多、也準得多——不管你怎麼挑時間點,過去 60 秒內的總量都被框住,沒有縫可以鑽。代價是實作和儲存比較重,要嘛維護一串請求時間戳,要嘛用加權的方式估算,記憶體和計算成本都比固定視窗高。
我自己的折衷是滑動視窗計數法(sliding window counter):保留當前視窗和前一個視窗的計數,按時間比例加權估算。它不是完全精確,但在精度和成本之間平衡得不錯,大部分場景夠用。
令牌桶(token bucket)
這是我最常用的,也是那晚之後我給下單端點裝上的那一個。想像一個桶,系統以固定速率往裡面丟令牌,比如每秒丟 50 個;每個進來的請求要拿走一個令牌才能通過,桶空了就擋。
令牌桶最迷人的地方是它允許突發。平常沒什麼流量的時候,桶裡會慢慢攢滿令牌(桶有容量上限);一旦來個短暫尖峰,這些攢下來的令牌就能讓尖峰瞬間通過,而長期的平均速率仍然被那個「每秒丟幾個」死死限制住。這完全貼合真實流量的樣子——平常平穩,偶爾一陣。那個量化用戶正常下單時其實流量不大,桶裡有餘裕讓他偶爾連發;但當他陷入每秒三千的重試風暴,桶很快被掏空,後續請求就被乾淨俐落地擋在門外,再也碰不到撮合佇列。
漏桶(leaky bucket)
漏桶和令牌桶常被放在一起講,但它的哲學不一樣。請求進到一個桶裡排隊,系統以固定速率從桶底「漏」出來處理。不管你進得多突然,輸出速率永遠恆定。
它強調的是輸出的平穩。令牌桶允許突發通過,漏桶則把突發壓平、強制下游收到的是一條穩定的水流。我在對接一個對 QPS 很敏感、超過就會回 429 給我們的第三方金流時,前面就擺了一個漏桶——不是為了保護自己,是為了保護那個下游、不讓我們的尖峰打爆對方。
一句話總結這兩兄弟的差別:令牌桶允許突發、限制平均;漏桶強制平穩輸出。你要先想清楚你保護的是「長期平均量別超」,還是「下游每一秒收到的節奏要穩」,再決定用哪一個。
補限流那週,我真正動手做的事
理解演算法只是第一步。那次事故之後我花了大概一週把限流系統重做了一遍,下面這些是踩過坑之後的實務心得。
限流狀態一定要集中,否則等於沒做
我們的下單 API 那時跑在八台機器後面。我第一版限流圖快,把計數放在每台機器的記憶體裡。結果測試的時候發現完全擋不住——因為負載均衡會把請求分散到八台,每台各自數各自的,等於限制被默默放大了八倍。我設「每秒 50」,實際放行了將近 400。
所以分散式環境下,限流的計數絕對不能各機器各算。它必須放在一個集中、所有機器共享的地方。我用的是 Redis,把令牌桶的狀態存在上面,用一段 Lua 腳本保證「取令牌、扣減、判斷」這幾步是原子的,避免高併發下的競態。所有 worker 共用同一份令牌桶,限流才是真的準。這也是為什麼我寫 Redis 那篇時特別強調原子操作的重要性——限流就是最典型的場景。
在越外層擋越好,別讓垃圾流量碰到核心
被擋掉的請求,應該用全系統最便宜的方式被拒絕。如果一個注定要被限流的請求,還是先進了你的應用、建了 DB 連線、跑了一段業務邏輯,才在某個深處被判定超限——那它已經花掉你資源了,限流的意義少了一半。
所以我的原則是限流盡量往外推:能在 API 閘道做就在閘道做,在請求真正進到那條昂貴的撮合路徑之前就擋下來。閘道層擋粗的、擋通用的(每 IP、每 key 的整體速率),應用層只做那些需要業務語意才能判斷的細粒度限制(例如某個 VIP 等級有不同額度)。分層守,而不是把所有壓力都堆在最裡面。
回應要明確、要友善,給對方一條退路
這點是我那晚最痛的領悟之一。當我們把那個失控的 key 擋下來時,他的程式根本不知道發生了什麼——它收到的可能只是逾時或連線重置,於是它更賣力地重試,火上澆油。
被限流時,正確的做法是回標準的 429(Too Many Requests),而且最好在回應裡帶上 Retry-After,明確告訴對方「過幾秒再來」。一個寫得好的客戶端看到 429 加 Retry-After,會乖乖退避、等一下再試;而不是把 429 當成普通錯誤、立刻重打,變成另一種重試風暴。我後來甚至在 API 文件裡用很大的字寫:看到 429 請務必尊重 Retry-After,否則你的 key 會被自動延長封鎖。限流不只是技術,它也是你跟客戶端之間的一份協議。
分層、分維度,別用一刀切
事故後我做的限流不是單一一條線,而是好幾層疊在一起:
- 每個 IP 一個整體上限,擋最粗暴的來源
- 每把 API key 一個上限,這是那晚真正救命的維度
- 特別昂貴的端點(下單、撤單、批次查詢)單獨再設更嚴的限制
- 不同用戶等級給不同額度,付費的造市商本來就該有更高的天花板
一刀切的單一限制,要嘛太鬆擋不住壞人,要嘛太緊誤傷正常的大用戶。多維度疊加才能既保護系統、又不冤枉好人。
寫在最後
回頭看那晚,最讓我難受的不是被叫醒,也不是 p99 飆到八秒,而是那一切完全在我的預期之外——我以為「應該不會有人這樣打吧」,於是那條最貴的端點裸奔上線。事故教會我的,其實不是哪個演算法比較好,而是一種心態的轉變:你不能假設客戶端是善意的、是寫對的、是會手下留情的。在一個夠大的系統裡,所有可能發生的爛事,遲早都會發生一次。限流的本質,是你提前替「最壞的那個客戶端」做好準備,讓單一來源的失控,永遠只是它自己的問題,而不會變成全體用戶的災難。後來我們的下單端點再也沒因為單一 key 出過事——不是因為運氣變好,是因為我們不再把穩定性押在運氣上。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。