在應對秒殺,搶購等高并發壓力的場景時,限流已經成為了標配技術解決方案,為保證系統的平穩運行起到了關鍵性的作用。不管應用場景是哪種,限流無非就是針對超過預期的流量,通過預先設定的限流規則選擇性的對某些請求進行限流“熔斷”。通過限流,我們可以很好地控制系統的QPS,從而達到保護系統的目的。接下來的內容將會介紹一下常用的限流算法以及他們各自的特點
地址
- 文章: 4. 使用分布式限流
- Github:https://github.com/dolyw/SeckillEvolution
- Gitee(碼云):https://gitee.com/dolyw/SeckillEvolution
1. 計數器(時間窗口)
1.1. 固定時間窗口
固定時間窗口是限流算法里最簡單也是最容易實現的一種算法。比如我們規定,對于 A 接口來說,我們 1 分鐘的訪問次數不能超過 100 個。那么我們可以這么做:在一開始的時候,我們可以設置一個計數器 counter,每當一個請求過來的時候,counter 就加 1,如果 counter 的值大于 100 并且該請求與第一個請求的間隔時間還在 1 分鐘之內,那么說明請求數過多;如果該請求與第一個請求的間隔時間大于 1 分鐘,且 counter 的值還在限流范圍內,那么就重置 counter
這種基于固定時間窗口的限流算法的缺點在于臨界問題,限流策略過于粗略,無法應對兩個時間窗口臨界時間內的突發流量,我們看下圖
從上圖中我們可以看到,假設有一個惡意用戶,他在 0:59 時,瞬間發送了 100 個請求,并且 1:00 又瞬間發送了 100 個請求,那么其實這個用戶在 1 秒里面,瞬間發送了 200 個請求。我們剛才規定的是 1 分鐘最多 100 個請求,也就是每秒鐘最多 1.7 個請求,用戶通過在時間窗口的重置節點處突發請求,可以瞬間超過我們的速率限制。用戶有可能通過算法的這個漏洞,瞬間壓垮我們的應用。
剛才的問題其實是因為我們統計的精度太低。那么如何很好地處理這個問題呢?或者說,如何將臨界問題的影響降低呢?我們可以看下面的滑動時間窗口算法
1.2. 滑動時間窗口
滑動時間窗口算法是對固定時間窗口算法的一種改進,下面這張圖,很好地解釋了滑動窗口
在上圖中,整個紅色的矩形框表示一個時間窗口,在我們的例子中,一個時間窗口就是一分鐘。然后我們將時間窗口進行劃分,比如圖中,我們就將滑動窗口劃成了 6 格,所以每格代表的是 10 秒鐘。每過 10 秒鐘,我們的時間窗口就會往右滑動一格。每一個格子都有自己獨立的計數器 counter,比如當一個請求在 0:35 秒的時候到達,那么 0:30 ~ 0:39 對應的 counter 就會加 1。
那么滑動窗口怎么解決剛才的臨界問題的呢?我們可以看上圖,0:59 到達的 100 個請求會落在灰色的格子中,而 1:00 到達的請求會落在橘黃色的格子中。當時間到達 1:00 時,我們的窗口會往右移動一格,那么此時時間窗口內的總請求數量一共是 200 個,超過了限定的 100 個,所以此時能夠檢測出來觸發了限流。
由此可見,當滑動窗口的格子劃分的越多,那么滑動窗口的滾動就越平滑,限流的統計就會越精確,但是格子越多,復雜度越高,內存占用會更多
2. 桶限流
上面我們講了兩種基于時間窗口的限流算法(固定和滑動時間窗口算法),兩種限流算法都無法應對細時間粒度的突發流量,對流量的整形效果在細時間粒度上不夠平滑
介紹兩種更加平滑的限流算法(漏桶和令牌桶算法),在某些場景下,這兩種算法會優于時間窗口算法成為首選。實際上漏桶和令牌桶算法的算法思想大體類似
2.1. 漏桶限流算法
漏桶算法非常簡單,就是將流量放入桶中并按照一定的速率流出。如果流量過大時候并不會提高流出效率,而溢出的流量也只能是拋棄掉了,因為桶容量是不變的,保證了整體的速率
但是對于很多應用場景來說,除了要求能夠限制數據的平均傳輸速率外,還要求允許某種程度的突發傳輸。這時候漏桶算法可能就不合適了,令牌桶算法更為適合
2.2. 令牌桶限流算法
令牌桶算法的原理是系統會以一個恒定的速度往桶里放入令牌,而如果請求需要被處理,則需要先從桶里獲取一個令牌,當桶里沒有令牌可取時,請求則會被阻塞,簡單的流程如下
令牌桶算法支持先消費后付款,比如一個請求可以獲取多個甚至全部的令牌,但是需要后面的請求付費。也就是說后面的請求需要等到桶中的令牌補齊之后才能繼續獲取
- 所有的請求在處理之前都需要拿到一個可用的令牌才會被處理
- 根據限流大小,設置按照一定的速率往桶里添加令牌
- 桶設置最大的放置令牌限制,當桶滿時、新添加的令牌就被丟棄或者拒絕
- 請求達到后首先要獲取令牌桶中的令牌,拿著令牌才可以進行其他的業務邏輯,處理完業務邏輯之后,將令牌直接刪除
- 令牌桶有最低限額,當桶中的令牌達到最低限額的時候,請求處理完之后將不會刪除令牌,以此保證足夠的限流
2.3. 漏桶與令牌桶對比
漏桶算法與令牌桶算法在表面看起來類似,很容易將兩者混淆。但事實上,這兩者具有截然不同的特性,且為不同的目的而使用。漏桶算法與令牌桶算法的區別在于:
- 漏桶算法能夠強行限制數據的傳輸速率
- 令牌桶算法能夠在限制數據的平均傳輸速率的同時還允許某種程度的突發傳輸
在某些情況下,漏桶算法不能夠有效地使用網絡資源。因為漏桶的漏出速率是固定的,所以即使網絡中沒有發生擁塞,漏桶算法也不能使某一個單獨的數據流達到端口速率。因此,漏桶算法對于存在突發特性的流量來說缺乏效率。而令牌桶算法則能夠滿足這些具有突發特性的流量。通常,漏桶算法與令牌桶算法結合起來為網絡流量提供更高效的控制
3. 業務場景對比
令牌桶和漏桶算法比較適合阻塞式限流,比如一些后臺 job 類的限流,超過了最大訪問頻率之后,請求并不會被拒絕,而是會被阻塞到有令牌后再繼續執行
對于像秒殺接口這種對響應時間比較敏感的限流場景,會比較適合選擇基于時間窗口的否決式限流算法,其中滑動時間窗口限流算法空間復雜度較高,內存占用會比較多,所以對比來看,盡管固定時間窗口算法處理臨界突發流量的能力較差,但實現簡單,而簡單帶來了好的性能和不容易出錯,所以固定時間窗口算法也不失是一個好的秒殺接口限流算法
4. 限流規則的合理性
限流規則包含三個部分:時間粒度,接口粒度,最大限流值。限流規則設置是否合理直接影響到限流是否合理有效。對于限流時間粒度的選擇,我們既可以選擇 1 秒鐘不超過 1000 次,也可以選擇 10 毫秒不超過 10 次,還可以選擇 1 分鐘不超過 6 萬次,雖然看起這幾種限流規則都是等價的,但過大的時間粒度會達不到限流的效果,比如限制 1 分鐘不超過 6 萬次,就有可能 6 萬次請求都集中在某一秒內;相反,過小的時間粒度會削足適履導致誤殺很多本不應該限流的請求,因為接口訪問在細時間粒度上隨機性很大。所以,盡管越細的時間粒度限流整形效果越好,流量曲線越平滑,但也并不是越細越合適。對于訪問量巨大的接口限流,比如秒殺,雙十一,這些場景下流量可能都集中在幾秒內,QPS 會非常大,幾萬甚至幾十萬,需要選擇相對小的限流時間粒度。相反,如果接口 QPS 很小,建議使用大一點的時間粒度,比如限制 1 分鐘內接口的調用次數不超過 1000 次
5. 一些算法的代碼實現
5.1. 固定時間窗口
單機版本
簡單的實現了下,可以自己封裝為一個方法(或者做成注解的形式),詳細查看: 4. 使用分布式限流
/**
* 一個時間窗口內最大請求數(限流最大請求數)
*/
private static final Long MAX_NUM_REQUEST = 2L;
/**
* 一個時間窗口時間(毫秒)(限流時間)
*/
private static final Long TIME_REQUEST = 1000L;
/**
* 一個時間窗口內請求的數量累計(限流請求數累計)
*/
private AtomicInteger requestNum = new AtomicInteger(0);
/**
* 一個時間窗口開始時間(限流開始時間)
*/
private AtomicLong requestTime = new AtomicLong(System.currentTimeMillis());
/**
* 計數器(固定時間窗口)請求接口
*
* @param
* @return java.lang.String
* @throws
* @author wliduo[i@dolyw.com]
* @date 2019/11/25 16:19
*/
@GetMapping
public String index() {
long nowTime = System.currentTimeMillis();
// 判斷是在當前時間窗口(限流開始時間)
if (nowTime < requestTime.longValue() + TIME_REQUEST) {
// 判斷當前時間窗口請求內是否限流最大請求數
if (requestNum.longValue() < MAX_NUM_REQUEST) {
// 在時間窗口內且請求數量還沒超過最大,請求數加一
requestNum.incrementAndGet();
logger.info("請求成功,當前請求是{}次", requestNum.intValue());
return "請求成功,當前請求是" + requestNum.intValue() + "次";
}
} else {
// 超時后重置(開啟一個新的時間窗口)
requestTime = new AtomicLong(nowTime);
requestNum = new AtomicInteger(0);
}
logger.info("請求失敗,被限流,當前請求是{}次", requestNum.intValue());
return "請求失敗,被限流,當前請求是" + requestNum.intValue() + "次";
}
分布式版本
一般分布式我們都是借助 Redis + Lua 來實現,放兩個 Lua 腳本參考
- 一個秒級限流(每秒限制多少請求)
- 一個自定義參數限流(自定義多少時間限制多少請求)
詳細使用可以查看: 4. 使用分布式限流
- 秒級限流(每秒限制多少請求)
-- 實現原理
-- 每次請求都將當前時間,精確到秒作為 key 放入 Redis 中
-- 超時時間設置為 2s, Redis 將該 key 的值進行自增
-- 當達到閾值時返回錯誤,表示請求被限流
-- 寫入 Redis 的操作用 Lua 腳本來完成
-- 利用 Redis 的單線程機制可以保證每個 Redis 請求的原子性
-- 資源唯一標志位
local key = KEYS[1]
-- 限流大小
local limit = tonumber(ARGV[1])
-- 獲取當前流量大小
local currentLimit = tonumber(redis.call('get', key) or "0")
if currentLimit + 1 > limit then
-- 達到限流大小 返回
return 0;
else
-- 沒有達到閾值 value + 1
redis.call("INCRBY", key, 1)
-- 設置過期時間
redis.call("EXPIRE", key, 2)
return currentLimit + 1
end
- 自定義參數限流(自定義多少時間限制多少請求)
-- 實現原理
-- 每次請求都去 Redis 取到當前限流開始時間和限流累計請求數
-- 判斷限流開始時間加超時時間戳(限流時間)大于當前請求時間戳
-- 再判斷當前時間窗口請求內是否超過限流最大請求數
-- 當達到閾值時返回錯誤,表示請求被限流,否則通過
-- 寫入 Redis 的操作用 Lua 腳本來完成
-- 利用 Redis 的單線程機制可以保證每個 Redis 請求的原子性
-- 一個時間窗口開始時間(限流開始時間)key名稱
local timeKey = KEYS[1]
-- 一個時間窗口內請求的數量累計(限流累計請求數)key名稱
local requestKey = KEYS[2]
-- 限流大小,限流最大請求數
local maxRequest = tonumber(ARGV[1])
-- 當前請求時間戳
local nowTime = tonumber(ARGV[2])
-- 超時時間戳,一個時間窗口時間(毫秒)(限流時間)
local timeRequest = tonumber(ARGV[3])
-- 獲取限流開始時間,不存在為0
local currentTime = tonumber(redis.call('get', timeKey) or "0")
-- 獲取限流累計請求數,不存在為0
local currentRequest = tonumber(redis.call('get', requestKey) or "0")
-- 判斷當前請求時間戳是不是在當前時間窗口中
-- 限流開始時間加超時時間戳(限流時間)大于當前請求時間戳
if currentTime + timeRequest > nowTime then
-- 判斷當前時間窗口請求內是否超過限流最大請求數
if currentRequest + 1 > maxRequest then
-- 在時間窗口內且超過限流最大請求數,返回
return 0;
else
-- 在時間窗口內且請求數沒超,請求數加一
redis.call("INCRBY", requestKey, 1)
return currentRequest + 1;
end
else
-- 超時后重置,開啟一個新的時間窗口
redis.call('set', timeKey, nowTime)
redis.call('set', requestKey, '0')
-- 設置過期時間
redis.call("EXPIRE", timeKey, timeRequest / 1000)
redis.call("EXPIRE", requestKey, timeRequest / 1000)
-- 請求數加一
redis.call("INCRBY", requestKey, 1)
return 1;
end
5.2. 令牌桶算法
- 單機的可以直接使用 Guava 包中的 RateLimiter
- 分布式的借助 Redis + Lua 來實現,放一個 Lua 腳本做參考
-- 令牌桶限流
-- 令牌的唯一標識
local bucketKey = KEYS[1]
-- 上次請求的時間
local last_mill_request_key = KEYS[2]
-- 令牌桶的容量
local limit = tonumber(ARGV[1])
-- 請求令牌的數量
local permits = tonumber(ARGV[2])
-- 令牌流入的速率
local rate = tonumber(ARGV[3])
-- 當前時間
local curr_mill_time = tonumber(ARGV[4])
-- 添加令牌
-- 獲取當前令牌的數量
local current_limit = tonumber(redis.call('get', bucketKey) or "0")
-- 獲取上次請求的時間
local last_mill_request_time = tonumber(redis.call('get', last_mill_request_key) or "0")
-- 計算向桶里添加令牌的數量
if last_mill_request_time == 0 then
-- 令牌桶初始化
-- 更新上次請求時間
redis.call("HSET", last_mill_request_key, curr_mill_time)
return 0
else
local add_token_num = math.floor((curr_mill_time - last_mill_request_time) * rate)
end
-- 更新令牌的數量
if current_limit + add_token_num > limit then
current_limit = limit
else
current_limit = current_limit + add_token_num
end
redis.pcall("HSET",bucketKey, current_limit)
-- 設置過期時間
redis.call("EXPIRE", bucketKey, 2)
-- 限流判斷
if current_limit - permits < 1 then
-- 達到限流大小
return 0
else
-- 沒有達到限流大小
current_limit = current_limit - permits
redis.pcall("HSET", bucketKey, current_limit)
-- 設置過期時間
redis.call("EXPIRE", bucketKey, 2)
-- 更新上次請求的時間
redis.call("HSET", last_mill_request_key, curr_mill_time)
end
參考
- 感謝crossoverJie的限流算法: https://github.com/crossoverJie/JCSprout/blob/master/MD/Limiting.md
- 感謝gongfukangEE的限流算法: https://github.com/gongfukangEE/Distributed-Learn
- 感謝王爭的微服務接口限流的設計與思考: https://mp.weixin.qq.com/s/k9tm-4lBwm69nxnYp9octA
- 感謝Ruthless的三種常見的限流算法: https://www.cnblogs.com/linjiqin/p/9707713.html
- 感謝xuwc的高并發系統限流-漏桶算法和令牌桶算法: https://www.cnblogs.com/xuwc/p/9123078.html