使用Redis實現令牌桶演算法
在限流演算法中有一種令牌桶演算法,該演算法可以應對短暫的突發流量,這對於現實環境中流量不怎麼均勻的情況特別有用,不會頻繁的觸發限流,對調用方比較友好。
例如,當前限制10qps,大多數情況下不會超過此數量,但偶爾會達到30qps,然後很快就會恢復正常,假設這種突發流量不會對系統穩定性產生影響,我們可以在一定程度上允許這種瞬時突發流量,從而為用戶帶來更好的可用性體驗。這就是使用令牌桶演算法的地方。
令牌桶演算法原理
如下圖所示,該演算法的基本原理是:有一個容量為X的令牌桶,每Y單位時間內將Z個令牌放入該桶。如果桶中的令牌數量超過X,那麼它將被丟棄。處理請求時,需要先從令牌桶中取出令牌,如果拿到了令牌,則繼續處理;如果拿不到令牌,則拒絕請求。
可以看出,在令牌桶演算法中設置X,Y和Z的數量尤為重要。Z應該比每Y單位時間內的請求數稍大,系統將長時間處於此狀態;X是系統允許的瞬時最大請求數,並且系統不應該長時間處於此狀態,否則就會頻繁觸發限流,此時表明流量出現了超預期的情況,需要及時調查原因並採取相應措施。
Redis實現令牌桶演算法
之前看過有些程式實現的令牌桶,其向桶中放入令牌的方法是啟動一個執行緒,每隔Y單位時間增加一次令牌數量,或者在Timer中定時執行這一過程。我不太滿意這種方法, 原因有二,一是浪費執行緒資源,二是因為調度的問題執行時間不精確。
這裡確定令牌桶中令牌數量的方法是通過計算得出,首先算出從上次請求到這次請求經過了多長時間,是否達到發令牌的時間閾值,然後增加的令牌數是多少,這些令牌能夠放到桶中的是多少。
Talk is cheap!
下邊就來看看Redis中怎麼實現的,因為涉及到多次與Redis的交互,這裡為了提高限流處理的吞吐量,減少程式與Redis的交互次數,採用了Redis支援的Lua script,Lua script的執行是原子的,所以也不用擔心出現臟數據的問題。
程式碼節選自 FireflySoft.RateLimit ,它不僅支援普通主從部署Redis,還支援集群Redis,所以吞吐量可以通過水平擴展的方式進行提升。為了方便閱讀,這裡增加一些注釋,實際是沒有的。
-- 定義返回值,是個數組,包含:是否觸發限流(1限流 0通過)、當前桶中的令牌數
local ret={}
ret[1]=0
-- Redis集群分片Key,KEYS[1]是限流目標
local cl_key = '{' .. KEYS[1] .. '}'
-- 獲取限流懲罰的當前設置,觸發限流懲罰時會寫一個有過期時間的KV
-- 如果存在限流懲罰,則返回結果[1,-1]
local lock_key=cl_key .. '-lock'
local lock_val=redis.call('get',lock_key)
if lock_val == '1' then
ret[1]=1
ret[2]=-1
return ret;
end
-- 這裡省略部分程式碼
-- 獲取[上次向桶中投放令牌的時間],如果沒有設置過這個投放時間,則令牌桶也不存在,此時:
-- 一種情況是:首次執行,此時定義令牌桶就是滿的。
-- 另一種情況是:較長時間沒有執行過限流處理,導致承載這個時間的KV被釋放了,
-- 這個過期時間會超過自然投放令牌到桶中直到桶滿的時間,所以令牌桶也應該是滿的。
local last_time=redis.call('get',st_key)
if(last_time==false)
then
-- 本次執行後剩餘令牌數量:桶的容量- 本次執行消耗的令牌數量
bucket_amount = capacity - amount;
-- 將這個令牌數量更新到令牌桶中,同時這裡有個過期時間,如果長時間不執行這個程式,令牌桶KV會被回收
redis.call('set',KEYS[1],bucket_amount,'PX',key_expire_time)
-- 設置[上次向桶中放入令牌的時間],後邊計算應放入桶中的令牌數量時會用到
redis.call('set',st_key,start_time,'PX',key_expire_time)
-- 返回值[當前桶中的令牌數]
ret[2]=bucket_amount
-- 無需其它處理
return ret
end
-- 令牌桶存在,獲取令牌桶中的當前令牌數
local current_value = redis.call('get',KEYS[1])
current_value = tonumber(current_value)
-- 判斷是不是該放入新令牌到桶中了:當前時間-上次投放的時間 >= 投放的時間間隔
last_time=tonumber(last_time)
local last_time_changed=0
local past_time=current_time-last_time
if(past_time<inflow_unit)
then
-- 不到投放的時候,直接從令牌桶中取走令牌
bucket_amount=current_value-amount
else
-- 需要放入一些令牌, 預計投放數量 = (距上次投放過去的時間/投放的時間間隔)*每單位時間投放的數量
local past_inflow_unit_quantity = past_time/inflow_unit
past_inflow_unit_quantity=math.floor(past_inflow_unit_quantity)
last_time=last_time+past_inflow_unit_quantity*inflow_unit
last_time_changed=1
local past_inflow_quantity=past_inflow_unit_quantity*inflow_quantity_per_unit
bucket_amount=current_value+past_inflow_quantity-amount
end
-- 這裡省略部分程式碼
ret[2]=bucket_amount
-- 如果桶中剩餘數量小於0,則看看是否需要限流懲罰,如果需要則寫入一個懲罰KV,過期時間為懲罰的秒數
if(bucket_amount<0)
then
if lock_seconds>0 then
redis.call('set',lock_key,'1','EX',lock_seconds,'NX')
end
ret[1]=1
return ret
end
-- 來到這裡,代表可以成功扣減令牌,則需要更新令牌桶KV
if last_time_changed==1 then
redis.call('set',KEYS[1],bucket_amount,'PX',key_expire_time)
-- 有新投放,更新[上次投放時間]為本次投放時間
redis.call('set',st_key,last_time,'PX',key_expire_time)
else
redis.call('set',KEYS[1],bucket_amount,'PX',key_expire_time)
end
return ret
通過以上程式碼,可以看出,其主要處理過程是:
1、判斷有沒有被限流懲罰,有則直接返回,無則進入下一步。
2、判斷令牌桶是否存在,不存在則先創建令牌桶,然後扣減令牌返回,存在則進入下一步。
3、判斷是否需要投放令牌,不需要則直接扣減令牌,需要則先投放令牌再扣減令牌。
4、判斷扣減後的令牌數,如果小於0則返回限流,同時設置限流懲罰,如果大於等於0則進入下一步。
5、更新桶中的令牌數到Redis。
你可以在任何一種開發語言的Redis庫中提交並運行這段Lua script腳本,如果你使用的是.NET平台,可以參考這篇文章:ASP.NET Core中使用令牌桶限流 。
關於FireflySoft.RateLimit
FireflySoft.RateLimit 是一個基於 .NET Standard 的限流類庫,其內核簡單輕巧,能夠靈活應對各種需求的限流場景。
其主要特點包括:
- 多種限流演算法:內置固定窗口、滑動窗口、漏桶、令牌桶四種演算法,還可自定義擴展。
- 多種計數存儲:目前支援記憶體、Redis兩種存儲方式。
- 分散式友好:通過Redis存儲支援分散式程式統一計數。
- 限流目標靈活:可以從請求中提取各種數據用於設置限流目標。
- 支援限流懲罰:可以在客戶端觸發限流後鎖定一段時間不允許其訪問。
- 動態更改規則:支援程式運行時動態更改限流規則。
- 自定義錯誤:可以自定義觸發限流後的錯誤碼和錯誤消息。
- 普適性:原則上可以滿足任何需要限流的場景。
Github開源地址://github.com/bosima/FireflySoft.RateLimit/blob/master/README.zh-CN.md
收穫更多架構知識,請關注公眾號 FireflySoft 。原創內容,轉載請註明出處。