先別管限流能擋掉多少惡意流量、能不能防雪崩。先看一件事:一個限流器收到請求之後,第一步到底在幹嘛。

答案短到有點掃興——它在數數。數某一段時間裡進來了幾個請求,超過門檻就擋。就這樣。

聽起來三行 code 就寫得完,對吧?if (counter++ > limit) reject()。問題是,這行 code 藏了一個你沒注意到的假設:「某一段時間」到底是哪一段?時間怎麼切,決定了你的限流器是穩如泰山還是形同虛設。市面上四種主流演算法,差別幾乎全在這一刀切在哪裡。我們就從最笨的那一種切法開始,一路把它逼到極限,看它在哪裡崩掉、下一種演算法又是怎麼補上來的。

第一刀:固定視窗,最直覺也最好騙

最容易想到的切法:把時間切成固定的格子。每分鐘一格,格子裡放一個計數器,來一個請求加一,到 60 就擋,整分鐘一到,計數器歸零重來。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 最樸素的固定視窗計數器(單機、非執行緒安全版,只為說明機制)
class FixedWindowLimiter {
private long windowStart = System.currentTimeMillis();
private int counter = 0;
private final int limit = 60; // 每分鐘 60 次
private final long windowSize = 60_000; // 視窗長度 60 秒

synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStart >= windowSize) { // 進到新視窗,重置
windowStart = now;
counter = 0;
}
return counter++ < limit;
}
}

實作簡單、記憶體只吃一個整數、算起來飛快。放到 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 滑動視窗日誌,Redis + Lua 保證整段操作的原子性
-- KEYS[1]=限流 key ARGV[1]=現在時間(ms) ARGV[2]=視窗長度(ms) ARGV[3]=上限
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', key, 0, now - window) -- 清掉視窗外的舊請求
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now) -- 記下這次請求
redis.call('PEXPIRE', key, window) -- 順手設過期,避免冷 key 佔記憶體
return 1 -- 放行
end
return 0 -- 擋下

精確歸精確,代價是它把每一個請求都記進記憶體。QPS 一高,這個 ZSET 會胖得嚇人。所以工程上常退一步用滑動視窗計數器:不記每一筆,只保留當前格子和前一格子的計數,再按當下時間落在格子裡的位置做加權估算。犧牲一點精度,換回記憶體——這種「拿準確度換成本」的取捨,會在後面一再出現,它幾乎是分散式限流的本質。

到這裡,你已經能公平地數清楚一段滑動時間裡的請求量了。但注意,前面兩刀都在解同一個題目:怎麼準確地數過去。接下來兩種演算法,思路整個翻過來——它們不太在乎你過去用了幾次,只在乎你未來以多快的速度打進來。

第三刀:漏桶,不管你倒多快,滴出去的速度我說了算

想像一個底部有小孔的水桶。請求是往桶裡倒的水,桶底以固定速率漏出去被處理。你倒得再猛,出水口就那麼粗,下游拿到的永遠是平穩的一小股。桶滿了還在倒,多出來的水就溢出去——也就是請求被丟棄。

漏桶(leaky bucket)的重點完全不在計數,在整流。它把忽高忽低的入流,強制轉成一條平滑的出流。這對下游是天大的好事:一個只能扛每秒 100 筆的資料庫,最怕的不是總量大,是瞬間尖峰。漏桶就是擋在它前面的那道穩壓器,把尖峰削平成細水長流。

Nginx 的 limit_req 模組底層就是漏桶。你在設定裡寫 rate=10r/s,意思不是「這一秒最多放 10 個」,而是「每 100 毫秒放行一個」——請求被排進佇列,按固定節奏一個一個放出來,多的直接 503。

但漏桶的優點反過來就是它的枷鎖:它恨突發。假設你的系統平常很閒,用戶偶爾一次點五個操作,這種良性的小突刺其實完全扛得住,漏桶卻還是死板地一個個慢慢滴,硬生生把體感變卡。它用「絕對的平穩」換掉了「應對突發的彈性」。而現實世界的流量,恰恰是平時很閒、偶爾要爆一下。有沒有辦法平常允許囤積一點額度、需要時放行一波突發,同時又守得住長期的平均速率?

第四刀:令牌桶,把水桶反過來用

有。把漏桶反過來想就行了。

漏桶控制的是水從桶裡漏出去的速率;令牌桶(token bucket)控制的是令牌被放進桶裡的速率。系統以固定速率(比如每秒 100 個)往桶裡丟令牌,桶有容量上限、滿了就不再加。每個請求進來,得先從桶裡拿走一個令牌才能通行,拿不到就被擋或排隊。

就這麼一個反轉,突發問題迎刃而解。關鍵在桶會囤積令牌:系統空閒時沒人來拿,令牌一路累積到桶滿。這時候突然湧進一波流量,它們可以一口氣把囤著的令牌全領走——瞬間放行一大批。等囤貨清空,放行速率就回落到「令牌生成速率」這條長期紅線上。於是你同時拿到了兩件本來矛盾的事:短期容得下突發,長期守得住均速

Google Guava 的 RateLimiter 就是令牌桶的教科書實作,單機場景幾乎開箱即用:

1
2
3
4
5
6
7
8
9
// 每秒穩定產生 100 個令牌;桶會在空閒時累積額度以應付突發
RateLimiter limiter = RateLimiter.create(100.0);

public void handle(Request req) {
if (!limiter.tryAcquire()) { // 拿不到令牌
throw new RateLimitException("忙碌中,請稍後再試");
}
process(req);
}

看懂這一層,你就會明白為什麼「令牌桶幾乎打趴其他三種」這句話在業界流傳。它不是每個維度都最強——論平滑輸出它輸給漏桶,論實作簡單它輸給固定視窗——但它精準命中了真實流量最重要的那個特徵:大部分時候很閒,關鍵時刻要能爆一下。這也是為什麼從 Guava 到 Sentinel、從 API Gateway 到雲端服務的 quota,繞來繞去多半落在令牌桶。

拆完了,回頭看這四刀到底在切什麼

把四種演算法排在一起,會發現它們不是四個彼此無關的工具,而是同一個問題的四種切法,而那個問題永遠是「時間」:

  • 固定視窗:把時間切成不連續的格子,格子沒記憶——最省事,代價是接縫處漏水。
  • 滑動視窗:讓格子跟著當下往前滑、保留記憶——補上了接縫,代價是要嘛吃記憶體、要嘛犧牲精度。
  • 漏桶:不切時間格子,改盯著出流速率——換來絕對平滑,代價是容不下突發。
  • 令牌桶:盯著入流令牌的生成速率、允許囤積——同時罩住突發與均速,成了通用解。

真正的分水嶺,是前兩種在算帳(過去用了多少),後兩種在控速(未來能來多快)。理解這條分界,選型就不再是背「哪個最好」,而是問自己一句話:我到底想控制的是「用量」還是「速率」?要卡「每人每天最多 API 呼叫 1000 次」這種配額,本質是算帳,滑動視窗;要保護一個脆弱的下游別被打爆,本質是控速,令牌桶或漏桶。

還有一個一旦上了規模就繞不開的維度:計數器放在哪裡。單機的量,Guava 一顆記憶體裡的令牌桶就夠。可是一旦你有五台機器做水平擴充,各數各的,設定的 60 就會悄悄變成 300——這時計數必須抽到一個共享的地方,也就是前面 Lua 腳本示範的 Redis 方案。而分散式限流真正的難點,從來不是演算法本身,是「讀計數、判斷、寫計數」這三步必須是一個不可分割的原子操作,否則高併發下兩個請求同時讀到 59、各自以為安全、雙雙放行,你的門檻就這樣被溢過去了。Lua 腳本在 Redis 裡單執行緒跑完的特性,正是拿來鎖住這三步的。

這四種切法都不難寫,難的是在動手前想清楚:你不是在「加一個限流」,你是在跟時間的某一個維度較勁。搞清楚自己較勁的是哪一個,剩下的就只是挑對應的那把刀而已。