Java單機限流可使用AtomicInteger,RateLimiter或Semaphore來實現,可是上述方案都不支持集羣限流。集羣限流的應用場景有兩個,一個是網關,經常使用的方案有Nginx限流和Spring Cloud Gateway,另外一個場景是與外部或者下游服務接口的交互,由於接口限制必須進行限流。html
本文的主要內容爲:redis
在上篇Guava RateLimiter的分析文章中,咱們學習了令牌桶限流算法的原理,下面咱們就探討一下,若是將RateLimiter
擴展,讓它支持集羣限流,會遇到哪些問題。算法
RateLimiter
會維護兩個關鍵的參數nextFreeTicketMicros
和storedPermits
,它們分別是下一次填充時間和當前存儲的令牌數。當RateLimiter
的acquire
函數被調用時,也就是有線程但願獲取令牌時,RateLimiter
會對比當前時間和nextFreeTicketMicros
,根據兩者差距,刷新storedPermits
,而後再判斷更新後的storedPermits
是否足夠,足夠則直接返回,不然須要等待直到令牌足夠(Guava RateLimiter的實現比較特殊,並非當前獲取令牌的線程等待,而是下一個獲取令牌的線程等待)。數組
因爲要支持集羣限流,因此nextFreeTicketMicros
和storedPermits
這兩個參數不能只存在JVM的內存中,必須有一個集中式存儲的地方。並且,因爲算法要先獲取兩個參數的值,計算後在更新兩個數值,這裏涉及到競態限制,必需要處理併發問題。安全
集羣限流因爲會面對相比單機更大的流量衝擊,因此通常不會進行線程等待,而是直接進行丟棄,由於若是讓拿不到令牌的線程進行睡眠,會致使大量的線程堆積,線程持有的資源也不會釋放,反而容易拖垮服務器。服務器
分佈式限流本質上是一個集羣併發問題,Redis單進程單線程的特性,自然能夠解決分佈式集羣的併發問題。因此不少分佈式限流都基於Redis,好比說Spring Cloud的網關組件Gateway。網絡
Redis執行Lua腳本會以原子性方式進行,單線程的方式執行腳本,在執行腳本時不會再執行其餘腳本或命令。而且,Redis只要開始執行Lua腳本,就會一直執行完該腳本再進行其餘操做,因此Lua腳本中不能進行耗時操做。使用Lua腳本,還能夠減小與Redis的交互,減小網絡請求的次數。架構
Redis中使用Lua腳本的場景有不少,好比說分佈式鎖,限流,秒殺等,總結起來,下面兩種狀況下可使用Lua腳本:併發
可是使用Lua腳本也有一些注意事項:app
redis.call()
Redis雖然以單進程單線程模型進行操做,可是它的性能卻十分優秀。總結來講,主要是由於:
因此,在集羣限流時使用Redis和Lua的組合並不會引入過多的性能損耗。咱們下面就簡單的測試一下,順便熟悉一下涉及的Redis命令。
# test.lua腳本的內容 local test = redis.call("get", "test") local time = redis.call("get", "time") redis.call("setex", "test", 10, "xx") redis.call("setex", "time", 10, "xx") return {test, time} # 將腳本導入redis,以後調用不需再傳遞腳本內容 redis-cli -a 082203 script load "$(cat test.lua)" "b978c97518ae7c1e30f246d920f8e3c321c76907" # 使用redis-benchmark和evalsha來執行lua腳本 redis-benchmark -a 082203 -n 1000000 evalsha b978c97518ae7c1e30f246d920f8e3c321c76907 0 ====== 1000000 requests completed in 20.00 seconds 50 parallel clients 3 bytes payload keep alive: 1 93.54% <= 1 milliseconds 99.90% <= 2 milliseconds 99.97% <= 3 milliseconds 99.98% <= 4 milliseconds 99.99% <= 5 milliseconds 100.00% <= 6 milliseconds 100.00% <= 7 milliseconds 100.00% <= 7 milliseconds 49997.50 requests per second
經過上述簡單的測試,咱們能夠發現本機狀況下,使用Redis執行Lua腳本的性能極其優秀,一百萬次執行,99.99%在5毫秒如下。
原本想找一下官方的性能數據,可是針對Redis + Lua的性能數據較少,只找到了幾篇我的博客,感興趣的同窗能夠去探索。這篇文章有Lua和zadd的性能比較(具體數據請看原文,連接缺失的話,請看文末)。
以上lua腳本的性能大概是zadd的70%-80%,可是在可接受的範圍內,在生產環境可使用。負載大概是zadd的1.5-2倍,網絡流量相差不大,IO是zadd的3倍,多是開啓了AOF,執行了三次操做。
Gateway
是微服務架構Spring Cloud
的網關組件,它基於Redis和Lua實現了令牌桶算法的限流功能,下面咱們就來看一下它的原理和細節吧。
Gateway
基於Filter模式,提供了限流過濾器RequestRateLimiterGatewayFilterFactory
。只需在其配置文件中進行配置,就可使用。具體的配置感興趣的同窗自行學習,咱們直接來看它的實現。
RequestRateLimiterGatewayFilterFactory
依賴RedisRateLimiter
的isAllowed
函數來判斷一個請求是否要被限流拋棄。
public Mono<Response> isAllowed(String routeId, String id) { //routeId是ip地址,id是使用KeyResolver獲取的限流維度id,好比說基於uri,IP或者用戶等等。 Config routeConfig = loadConfiguration(routeId); // 每秒可以經過的請求數 int replenishRate = routeConfig.getReplenishRate(); // 最大流量 int burstCapacity = routeConfig.getBurstCapacity(); try { // 組裝Lua腳本的KEY List<String> keys = getKeys(id); // 組裝Lua腳本須要的參數,1是指一次獲取一個令牌 List<String> scriptArgs = Arrays.asList(replenishRate + "", burstCapacity + "", Instant.now().getEpochSecond() + "", "1"); // 調用Redis,tokens_left = redis.eval(SCRIPT, keys, args) Flux<List<Long>> flux = this.redisTemplate.execute(this.script, keys, scriptArgs); ..... // 省略 } static List<String> getKeys(String id) { String prefix = "request_rate_limiter.{" + id; String tokenKey = prefix + "}.tokens"; String timestampKey = prefix + "}.timestamp"; return Arrays.asList(tokenKey, timestampKey); }
須要注意的是getKeys
函數的prefix包含了"{id}",這是爲了解決Redis集羣鍵值映射問題。Redis的KeySlot算法中,若是key包含{},就會使用第一個{}內部的字符串做爲hash key,這樣就能夠保證擁有一樣{}內部字符串的key就會擁有相同slot。Redis要求單個Lua腳本操做的key必須在同一個節點上,可是Cluster會將數據自動分佈到不一樣的節點,使用這種方法就解決了上述的問題。
而後咱們來看一下Lua腳本的實現,該腳本就在Gateway項目的resource文件夾下。它就是如同Guava
的RateLimiter
同樣,實現了令牌桶算法,只不過不在須要進行線程休眠,而是直接返回是否可以獲取。
local tokens_key = KEYS[1] -- request_rate_limiter.${id}.tokens 令牌桶剩餘令牌數的KEY值 local timestamp_key = KEYS[2] -- 令牌桶最後填充令牌時間的KEY值 local rate = tonumber(ARGV[1]) -- replenishRate 令令牌桶填充平均速率 local capacity = tonumber(ARGV[2]) -- burstCapacity 令牌桶上限 local now = tonumber(ARGV[3]) -- 獲得從 1970-01-01 00:00:00 開始的秒數 local requested = tonumber(ARGV[4]) -- 消耗令牌數量,默認 1 local fill_time = capacity/rate -- 計算令牌桶填充滿令牌須要多久時間 local ttl = math.floor(fill_time*2) -- *2 保證時間充足 local last_tokens = tonumber(redis.call("get", tokens_key)) -- 得到令牌桶剩餘令牌數 if last_tokens == nil then -- 第一次時,沒有數值,因此桶時滿的 last_tokens = capacity end local last_refreshed = tonumber(redis.call("get", timestamp_key)) -- 令牌桶最後填充令牌時間 if last_refreshed == nil then last_refreshed = 0 end local delta = math.max(0, now-last_refreshed) -- 獲取距離上一次刷新的時間間隔 local filled_tokens = math.min(capacity, last_tokens+(delta*rate)) -- 填充令牌,計算新的令牌桶剩餘令牌數 填充不超過令牌桶令牌上限。 local allowed = filled_tokens >= requested local new_tokens = filled_tokens local allowed_num = 0 if allowed then -- 若成功,令牌桶剩餘令牌數(new_tokens) 減消耗令牌數( requested ),並設置獲取成功( allowed_num = 1 ) 。 new_tokens = filled_tokens - requested allowed_num = 1 end -- 設置令牌桶剩餘令牌數( new_tokens ) ,令牌桶最後填充令牌時間(now) ttl是超時時間? redis.call("setex", tokens_key, ttl, new_tokens) redis.call("setex", timestamp_key, ttl, now) -- 返回數組結果 return { allowed_num, new_tokens }
Redis的主從異步複製機制可能丟失數據,出現限流流量計算不許確的狀況,固然限流畢竟不一樣於分佈式鎖這種場景,對於結果的精確性要求不是很高,即便多流入一些流量,也不會影響太大。
正如Martin在他質疑Redis分佈式鎖RedLock文章中說的,Redis的數據丟棄了也無所謂時再使用Redis存儲數據。
I think it’s a good fit in situations where you want to share some transient, approximate, fast-changing data between servers, and where it’s not a big deal if you occasionally lose that data for whatever reason
接下來咱們回來學習阿里開源的分佈式限流組件sentinel
,但願你們持續關注。
我的博客: Remcarpediem