在高並發的分布式系統,如大型電商系統中,由於接口 API 無法控制上游調用方的行為,因此當瞬間請求量突增時,會導致服務器占用過多資源,發生響應速度降低、超時乃至宕機,甚至引發雪崩造成整個系統不可用。
面對這種情況,一方面我們會提升 API 的吞吐量和 QPS(Query Per Second 每秒查詢量),但總歸會有上限,所以另一方面為了應對巨大流量的瞬間提交,我們需要做對應的限流處理,也就是對請求量進行限制,對於超出限制部分的請求作出快速拒絕、快速失敗、丟棄處理,以保證本服務以及下游資源系統的穩定。
常見的限流算法有計數器、漏斗、令牌桶。
一、計數器
1. 設計思路
計數器限流方式比較粗暴,一次訪問就增加一次計數,在系統內設置每 N 秒的訪問量,超過訪問量的訪問直接丟棄,從而實現限流訪問。具體大概是以下步驟:
- 將時間划分為固定的窗口大小,例如 1 s;
- 在窗口時間段內,每來一個請求,對計數器加 1;
- 當計數器達到設定限制后,該窗口時間內的后續請求都將被丟棄;
- 該窗口時間結束后,計數器清零,從新開始計數。
這種算法的弊端是,在開始的時間,訪問量被使用完后,1 s 內會有很長時間的真空期是處於接口不可用的狀態的,同時也有可能在一秒內出現兩倍的訪問量。
- T窗口的前1/2時間 無流量進入,后1/2時間通過5個請求;
- T+1窗口的前 1/2時間 通過5個請求,后1/2時間因達到限制丟棄請求。
- 因此在 T的后1/2和(T+1)的前1/2時間組成的完整窗口內,通過了10個請求。
2. 實現方式
實現方式和擴展方式很多,這里以 Redis 舉例簡單的實現,計數器主要思路就是在單位時間內,有且僅有 N 數量的請求能夠訪問我的代碼程序。所以可以利用 Redis 的 setnx
來實現這方面的功能。
比如現在需要在 10 秒內限定 20 個請求,那么可以在 setnx
的時候設置過期時間 10,當請求的 setnx
數量達到 20 的時候即達到了限流效果。
二、滑動窗口計數器
1. 設計思路
滑動窗口計數法的思路是:
- 將時間划分為細粒度的區間,每個區間維持一個計數器,每進入一個請求則將計數器加一;
- 多個區間組成一個時間窗口,每流逝一個區間時間后,則拋棄最老的一個區間,納入新區間。如圖中示例的窗口 T1 變為窗口 T2;
- 若當前窗口的區間計數器總和超過設定的限制數量,則本窗口內的后續請求都被丟棄。
2. 實現方式
利用 Redis 的 list 數據結構可以輕而易舉地實現該功能。我們可以將請求打造成一個 zset 數組,當每一次請求進來的時候,key 保持唯一,value 可以用 UUID 生成,而 score 可以用當前時間戳表示,因為 score 我們可以用來計算當前時間戳之內有多少的請求數量。而 zset 數據結構也提供了 range 方法讓我們可以很輕易地獲取到兩個時間戳內有多少請求。
public Response limitFlow() {
Long currentTime = new Date().getTime();
if (redisTemplate.hasKey("limit")) {
Integer count = redisTemplate.opsForZset().rangeByScore("limit", currentTime - intervalTime, currentTime).size();
if (count != null && count > 5) {
return Response.ok("每分鍾最多只能訪問 5 次!");
}
}
redisTemplate.opsForZSet().add("limit", UUID.randomUUID().toString(), currentTime);
return Response.ok("訪問成功");
}
通過上述代碼可以做到滑動窗口的效果,並且能保證每 N 秒內至多 M 個請求,實現方式相對來說也是比較簡單的,但是所帶來的缺點就是 zset 的數據結構會越來越大。
三、漏斗
1. 設計思路
在計數器算法中我們看到,當使用了所有的訪問量后,接口會完全處於不可用狀態,有些系統不能接受這樣的處理方式,對此可以使用漏斗算法進行限流,漏斗算法的原理就像名字,訪問量從漏斗的大口進入,從漏斗的小口進入系統。這樣不管是多大的訪問量進入漏斗,最后進入系統的訪問量都是固定的。漏斗的好處就是,大批量訪問進入時,漏斗有容量,不超過容量(容量的設計=固定處理的訪問量 * 可接受等待時長)的數據都可以排隊等待處理,超過的才會丟棄。
2. 實現方式
實現方式可以使用隊列,隊列設置容量,訪問可以大批量塞入隊列,滿隊列后丟棄后續訪問量。隊列的出口以固定速率拿去訪問量處理。
這種方案由於出口速率是固定的,所以並沒有辦法應對短時間的突發流量。
四、令牌桶
1. 設計思路
令牌桶算法是漏斗算法的改進版,為了處理短時間的突發流量而做了優化,令牌桶算法主要由三部分組成:令牌流
、數據流
、令牌桶
。
名詞釋義:
- 令牌桶:流通令牌的管道,用於生成的令牌的流通,放入令牌桶中。
- 數據流:進入系統的數據流量。
- 令牌桶:保存令牌的區域,可以理解為一個緩沖區,令牌保存在這里用於使用。
令牌桶會按照一定的速率生成令牌放入令牌桶,訪問要進入系統時,需要從令牌桶中獲取令牌,有令牌的可以進入,沒有的被拋棄,由於令牌桶的令牌是源源不斷生成的,當訪問量小時,可以留存令牌達到令牌桶的上限,這樣當短時間的突發訪問量時,積累的令牌數可以處理這個問題。當訪問量持續大量流入時,由於生成令牌的速率是固定的,最后也就變成了類似漏斗算法的固定流量處理。
2. 實現方式
實現方式和漏斗也比較類似,可以使用一個隊列保存令牌,一個定時任務用等速率生成令牌放入隊列,訪問量進入系統時,從隊列獲取令牌再進入系統。
google
開源的 guava
包中的 RateLimiter
類實現了令牌桶算法,不同其實現方式是單機的,集群可以按照上面的實現方式,隊列使用中間件 MQ 實現,配合負載均衡算法,考慮集群各個服務器的承壓情況做對應服務器的隊列是較好的做法。
這里簡單用 Redis 以及定時任務模擬大概的過程:
首先依靠 List 的 leftPop 來獲取令牌:
// 輸出令牌
public Response limitFlow() {
Object result = redisTemplate.opsForList().leftPop("limit_list");
if (result == null) {
return Response.ok("當前令牌桶中無令牌!");
}
return Response.ok("訪問成功!");
}
再依靠 Java 的定時任務,定時往 List 中 rightPush 令牌,當然令牌也需要保證唯一性,所以這里利用 UUID 生成:
// 10S的速率往令牌桶中添加UUID,只為保證唯一性
@Scheduled(fixedDelay = 10_000,initialDelay = 0)
public void setIntervalTimeTask(){
redisTemplate.opsForList().rightPush("limit_list",UUID.randomUUID().toString());
}
五、限流進階
單點應用下,對應用進行限流,既能滿足本服務的需求,又可以很好地保護好下游資源。在選型上,可以采用上面提及的 Google Guava 的 RateLimiter。
而在多機部署的場景下,對單點的限流,並不能達到我們想要的最好效果,需要引入分布式限流。分布式限流的算法,依然可以采用令牌桶算法,只不過將令牌桶的發放、存儲改為全局的模式。
在真實應用場景,可以采用 redis + lua 的方式,通過把邏輯放在 redis 端,來減少調用次數。
lua 的邏輯如下:
- redis 中存儲剩余令牌的數量 cur_token,和上次獲取令牌的時間 last_time;
- 在每次申請令牌時,可以根據(當前時間 cur_time - last_time) 的時間差乘以令牌發放速率,算出當前可用令牌數;
- 如果有剩余令牌,則准許請求通過,否則不通過。
文章內容收集於網絡。