限流的四種演算法 — 固定視窗、滑動視窗、漏桶、令牌桶,差別全藏在時間怎麼切
先別管限流能擋掉多少惡意流量、能不能防雪崩。先看一件事:一個限流器收到請求之後,第一步到底在幹嘛。
答案短到有點掃興——它在數數。數某一段時間裡進來了幾個請求,超過門檻就擋。就這樣。
聽起來三行 code 就寫得完,對吧?if (counter++ > limit) reject()。問題是,這行 code 藏了一個你沒注意到的假設:「某一段時間」到底是哪一段?時間怎麼切,決定了你的限流器是穩如泰山還是形同虛設。市面上四種主流演算法,差別幾乎全在這一刀切在哪裡。我們就從最笨的那一種切法開始,一路把它逼到極限,看它在哪裡崩掉、下一種演算法又是怎麼補上來的。
第一刀:固定視窗,最直覺也最好騙
最容易想到的切法:把時間切成固定的格子。每分鐘一格,格子裡放一個計數器,來一個請求加一,到 60 就擋,整分鐘一到,計數器歸零重來。
1 | // 最樸素的固定視窗計數器(單機、非執行緒安全版,只為說明機制) |
實作簡單、記憶體只吃一個整數、算起來飛快。放到 Redis 上也乾淨俐落:INCR 一個帶過期時間的 key 就完事。很多人的限流生涯就停在這裡。
然後某天大流量打進來,明明設了每分鐘 60 次,監控上卻看到某一瞬間湧進了 120 個請求。你盯著 code 看半天,找不到 bug——因為那不是 bug,是這個切法本身的破洞。
問題出在格子的邊界。假設攻擊者算準時間,在 12:00:59 這一秒送 60 個請求,全部落在第一格、剛好塞滿;再撐一秒到 12:01:00,計數器歸零,他再送 60 個。前後只隔兩秒,你的系統實際上吃了 120 個請求,但每一格看起來都乖乖守在 60 以內。這就是固定視窗惡名昭彰的臨界突刺(boundary burst):你以為你限的是「每分鐘」,實際上跨在格子接縫上的任何一個 60 秒滑動區間,都可能塞進兩倍流量。
根子在哪?在於「歸零」這個動作太粗暴。它把時間當成一段一段沒有記憶的格子,格子一翻頁,前面發生什麼全忘光。而流量不會配合你的格子作息——它是連續的。
第二刀:滑動視窗,把「這個鐘頭」換成「過去六十分鐘」
既然問題是格子沒記憶,那就別讓它忘。與其問「這一分鐘用了幾次」,改問「從現在往回數 60 秒,一共用了幾次」。視窗不再固定卡在整分鐘,而是跟著當下時間往前滑。這一改,臨界突刺當場消失——因為不管攻擊者把請求塞在哪一秒,任何一個 60 秒的回看區間都逃不過統計。
最精確的做法是滑動視窗日誌(sliding window log):把每個請求的時間戳都記下來,每次進來就把 60 秒前的舊紀錄丟掉,再數剩下幾筆。Redis 用一個 ZSET(有序集合)就能漂亮地做到——時間戳當 score,先 ZREMRANGEBYSCORE 清掉過期的,再 ZCARD 數數:
1 | -- 滑動視窗日誌,Redis + Lua 保證整段操作的原子性 |
精確歸精確,代價是它把每一個請求都記進記憶體。QPS 一高,這個 ZSET 會胖得嚇人。所以工程上常退一步用滑動視窗計數器:不記每一筆,只保留當前格子和前一格子的計數,再按當下時間落在格子裡的位置做加權估算。犧牲一點精度,換回記憶體——這種「拿準確度換成本」的取捨,會在後面一再出現,它幾乎是分散式限流的本質。
到這裡,你已經能公平地數清楚一段滑動時間裡的請求量了。但注意,前面兩刀都在解同一個題目:怎麼準確地數過去。接下來兩種演算法,思路整個翻過來——它們不太在乎你過去用了幾次,只在乎你未來以多快的速度打進來。
第三刀:漏桶,不管你倒多快,滴出去的速度我說了算
想像一個底部有小孔的水桶。請求是往桶裡倒的水,桶底以固定速率漏出去被處理。你倒得再猛,出水口就那麼粗,下游拿到的永遠是平穩的一小股。桶滿了還在倒,多出來的水就溢出去——也就是請求被丟棄。
漏桶(leaky bucket)的重點完全不在計數,在整流。它把忽高忽低的入流,強制轉成一條平滑的出流。這對下游是天大的好事:一個只能扛每秒 100 筆的資料庫,最怕的不是總量大,是瞬間尖峰。漏桶就是擋在它前面的那道穩壓器,把尖峰削平成細水長流。
Nginx 的 limit_req 模組底層就是漏桶。你在設定裡寫 rate=10r/s,意思不是「這一秒最多放 10 個」,而是「每 100 毫秒放行一個」——請求被排進佇列,按固定節奏一個一個放出來,多的直接 503。
但漏桶的優點反過來就是它的枷鎖:它恨突發。假設你的系統平常很閒,用戶偶爾一次點五個操作,這種良性的小突刺其實完全扛得住,漏桶卻還是死板地一個個慢慢滴,硬生生把體感變卡。它用「絕對的平穩」換掉了「應對突發的彈性」。而現實世界的流量,恰恰是平時很閒、偶爾要爆一下。有沒有辦法平常允許囤積一點額度、需要時放行一波突發,同時又守得住長期的平均速率?
第四刀:令牌桶,把水桶反過來用
有。把漏桶反過來想就行了。
漏桶控制的是水從桶裡漏出去的速率;令牌桶(token bucket)控制的是令牌被放進桶裡的速率。系統以固定速率(比如每秒 100 個)往桶裡丟令牌,桶有容量上限、滿了就不再加。每個請求進來,得先從桶裡拿走一個令牌才能通行,拿不到就被擋或排隊。
就這麼一個反轉,突發問題迎刃而解。關鍵在桶會囤積令牌:系統空閒時沒人來拿,令牌一路累積到桶滿。這時候突然湧進一波流量,它們可以一口氣把囤著的令牌全領走——瞬間放行一大批。等囤貨清空,放行速率就回落到「令牌生成速率」這條長期紅線上。於是你同時拿到了兩件本來矛盾的事:短期容得下突發,長期守得住均速。
Google Guava 的 RateLimiter 就是令牌桶的教科書實作,單機場景幾乎開箱即用:
1 | // 每秒穩定產生 100 個令牌;桶會在空閒時累積額度以應付突發 |
看懂這一層,你就會明白為什麼「令牌桶幾乎打趴其他三種」這句話在業界流傳。它不是每個維度都最強——論平滑輸出它輸給漏桶,論實作簡單它輸給固定視窗——但它精準命中了真實流量最重要的那個特徵:大部分時候很閒,關鍵時刻要能爆一下。這也是為什麼從 Guava 到 Sentinel、從 API Gateway 到雲端服務的 quota,繞來繞去多半落在令牌桶。
拆完了,回頭看這四刀到底在切什麼
把四種演算法排在一起,會發現它們不是四個彼此無關的工具,而是同一個問題的四種切法,而那個問題永遠是「時間」:
- 固定視窗:把時間切成不連續的格子,格子沒記憶——最省事,代價是接縫處漏水。
- 滑動視窗:讓格子跟著當下往前滑、保留記憶——補上了接縫,代價是要嘛吃記憶體、要嘛犧牲精度。
- 漏桶:不切時間格子,改盯著出流速率——換來絕對平滑,代價是容不下突發。
- 令牌桶:盯著入流令牌的生成速率、允許囤積——同時罩住突發與均速,成了通用解。
真正的分水嶺,是前兩種在算帳(過去用了多少),後兩種在控速(未來能來多快)。理解這條分界,選型就不再是背「哪個最好」,而是問自己一句話:我到底想控制的是「用量」還是「速率」?要卡「每人每天最多 API 呼叫 1000 次」這種配額,本質是算帳,滑動視窗;要保護一個脆弱的下游別被打爆,本質是控速,令牌桶或漏桶。
還有一個一旦上了規模就繞不開的維度:計數器放在哪裡。單機的量,Guava 一顆記憶體裡的令牌桶就夠。可是一旦你有五台機器做水平擴充,各數各的,設定的 60 就會悄悄變成 300——這時計數必須抽到一個共享的地方,也就是前面 Lua 腳本示範的 Redis 方案。而分散式限流真正的難點,從來不是演算法本身,是「讀計數、判斷、寫計數」這三步必須是一個不可分割的原子操作,否則高併發下兩個請求同時讀到 59、各自以為安全、雙雙放行,你的門檻就這樣被溢過去了。Lua 腳本在 Redis 裡單執行緒跑完的特性,正是拿來鎖住這三步的。
這四種切法都不難寫,難的是在動手前想清楚:你不是在「加一個限流」,你是在跟時間的某一個維度較勁。搞清楚自己較勁的是哪一個,剩下的就只是挑對應的那把刀而已。










