基於Redis的限流系統的設計
本文講述 基於Redis的限流系統的設計 ,主要會談及限流系統中 限流策略 這個功能的設計;在實現方面,演算法使用的是 令牌桶 演算法來,訪問Redis使用lua指令碼。
1、概念
In computer networks, rate limiting is used to control the rate of traffic sent or received by a network interface controller and is used to prevent DoS attacks
用我的理解翻譯一下:限流是對系統的 出入流量 進行 控制 ,防止大流量出入,導致 資源 不足,系統不穩定。
限流系統是對資源訪問的控制組件,控制主要的兩個功能: 限流策略 和 熔斷策略 ,對於熔斷策略,不同的系統有不同的熔斷策略訴求,有的系統希望直接拒絕、有的系統希望排隊等待、有的系統希望服務降級、有的系統會定製自己的熔斷策略,很難一一列舉,所以本文只針對 限流策略 這個功能做詳細的設計。
針對 限流策略 這個功能,限流系統中有兩個基礎概念:資源和策略。
資源 :或者叫稀缺資源,被流量控制的物件;比如寫介面、外部商戶介面、大流量下的讀介面
策略 :限流策略由限流演算法和可調節的引數兩部分組成
熔斷策略: 超出速率閾值的請求 的 處理策略 ,是我自己理解的一個叫法,不是業界主流的說法。
2、限流演算法
限制瞬時併發數
限制時間窗最大請求數
令牌桶
2.1、限制瞬時併發數
定義 :瞬時 併發數 ,系統同時處理的請求/事務數量
優點 :這個演算法能夠實現控制併發數的效果
缺點 :使用場景比較單一,一般用來對入流量進行控制
java虛擬碼實現 :
AtomicInteger atomic = new AtomicInteger(1)
try {
if(atomic.incrementAndGet() > 限流數) {
//熔斷邏輯
} else {
//處理邏輯
}
} finally {
atomic.decrementAndGet();
}
2.2、限制時間窗最大請求數
定義 :時間窗最大請求數,指定的時間範圍內允許的最大請求數
優點 :這個演算法能夠滿足絕大多數的流控需求,通過時間窗最大請求數可以直接換算出最大的QPS(QPS = 請求數/時間窗)
缺點 :這種方式可能會出現流量不平滑的情況,時間窗內一小段流量佔比特別大
lua程式碼實現 :
--- 資源唯一標識
local key = KEYS[1]
--- 時間窗最大併發數
local max_window_concurrency = tonumber(ARGV[1])
--- 時間窗
local window = tonumber(ARGV[2])
--- 時間窗內當前併發數
local curr_window_concurrency = tonumber(redis.call('get', key) or 0)
if current + 1 > limit then
return false
else
redis.call("INCRBY", key,1)
if window > -1 then
redis.call("expire", key,window)
end
return true
end
2.3、令牌桶

演算法描述
假如使用者配置的平均傳送速率為r,則每隔1/r秒一個令牌被加入到桶中
假設桶中最多可以存放b個令牌。如果令牌到達時令牌桶已經滿了,那麼這個令牌會被丟棄
當流量以速率v進入,從桶中以速率v取令牌,拿到令牌的流量通過,拿不到令牌流量不通過,執行熔斷邏輯
屬性
長期來看,符合流量的速率是受到令牌新增速率的影響,被穩定為:r
因為令牌桶有一定的儲存量,可以抵擋一定的流量突發情況
M是以位元組/秒為單位的最大可能傳輸速率:M>r
T max = b/(M-r) 承受最大傳輸速率的時間
B max = T max * M 承受最大傳輸速率的時間內傳輸的流量
優點 :流量比較平滑,並且可以抵擋一定的流量突發情況
因為我們限流系統的實現就是基於令牌桶這個演算法,具體的程式碼實現參考下文。
3、工程實現
3.1、技術選型
mysql:儲存限流策略的引數等元資料
redis+lua:令牌桶演算法實現
說明:因為我們把redis 定位為:快取、計算媒介,所以元資料都是存在db中
3.2、架構圖

3.3、 資料結構
欄位描述name令牌桶的唯一標示apps能夠使用令牌桶的應用列表max_permits令牌桶的最大令牌數rate向令牌桶中新增令牌的速率created_by建立人updated_by更新人
限流系統的實現是基於redis的,本可以和應用無關,但是為了做限流元資料配置的統一管理,按應用維度管理和使用,在資料結構中加入了 apps 這個欄位,出現問題,排查起來也比較方便。
3.4、程式碼實現
3.4.1、程式碼實現遇到的問題
參考令牌桶的演算法描述,一般思路是在RateLimiter-client放一個重複執行的執行緒,執行緒根據配置往令牌桶裡新增令牌,這樣的實現由如下缺點:
需要為每個令牌桶配置新增一個重複執行的執行緒
重複的間隔精度不夠精確:執行緒需要每1/r秒向桶裡新增一個令牌,當r>1000 時間執行緒執行的時間間隔根本沒辦法設定(從後面效能測試的變現來看RateLimiter-client 是可以承擔 QPS > 5000 的請求速率)
3.4.2、解決方案
基於上面的缺點,參考了google的guava中RateLimiter中的實現,我們使用了觸發式新增令牌的方式。

演算法描述
基於上述的令牌桶演算法
將新增令牌改成觸發式的方式,取令牌的是做新增令牌的動作
在去令牌的時候,通過計算上一次新增令牌和當前的時間差,計算出這段間應該新增的令牌數,然後往桶裡新增
curr_mill_second = 當前毫秒數
last_mill_second = 上一次新增令牌的毫秒數
r = 新增令牌的速率
reserve_permits = (curr_mill_second-last_mill_second)/1000 * r
新增完令牌之後再執行取令牌邏輯
3.4.3、 lua程式碼實現
--- 獲取令牌
--- 返回碼
--- 0 沒有令牌桶配置
--- -1 表示取令牌失敗,也就是桶裡沒有令牌
--- 1 表示取令牌成功
--- @param key 令牌(資源)的唯一標識
--- @param permits 請求令牌數量
--- @param curr_mill_second 當前毫秒數
--- @param context 使用令牌的應用標識
local function acquire(key, permits, curr_mill_second, context)
local rate_limit_info = redis.pcall("HMGET", key, "last_mill_second", "curr_permits", "max_permits", "rate", "apps")
local last_mill_second = rate_limit_info[1]
local curr_permits = tonumber(rate_limit_info[2])
local max_permits = tonumber(rate_limit_info[3])
local rate = rate_limit_info[4]
local apps = rate_limit_info[5]
--- 標識沒有配置令牌桶
if type(apps) == 'boolean' or apps == nil or not contains(apps, context) then
return 0
end
local local_curr_permits = max_permits;
--- 令牌桶剛剛建立,上一次獲取令牌的毫秒數為空
--- 根據和上一次向桶裡新增令牌的時間和當前時間差,觸發式往桶裡新增令牌
--- 並且更新上一次向桶裡新增令牌的時間
--- 如果向桶裡新增的令牌數不足一個,則不更新上一次向桶裡新增令牌的時間
if (type(last_mill_second) ~= 'boolean' and last_mill_second ~= false and last_mill_second ~= nil) then
local reverse_permits = math.floor(((curr_mill_second - last_mill_second) / 1000) * rate)
local expect_curr_permits = reverse_permits + curr_permits;
local_curr_permits = math.min(expect_curr_permits, max_permits);
--- 大於0表示不是第一次獲取令牌,也沒有向桶裡新增令牌
if (reverse_permits > 0) then
redis.pcall("HSET", key, "last_mill_second", curr_mill_second)
end
else
redis.pcall("HSET", key, "last_mill_second", curr_mill_second)
end
local result = -1
if (local_curr_permits - permits >= 0) then
result = 1
redis.pcall("HSET", key, "curr_permits", local_curr_permits - permits)
else
redis.pcall("HSET", key, "curr_permits", local_curr_permits)
end
return result
end
關於限流系統的所有實現細節,我都已經放到github上,gitbub地址:https://github.com/wukq/rate-limiter,有興趣的同學可以前往檢視,由於筆者經驗與知識有限,程式碼中如有錯誤或偏頗,歡迎探討和指正。
3.4.4、管理介面
前面的設計中,限流的配置是和應用關聯的,為了更夠更好的管理配置,需要一個統一的管理頁面去對配置進行管控:
按應用對限流配置進行管理
不同的人分配不同的許可權;相關人員有檢視配置的許可權,負責人有修改和刪除配置的許可權

3.5、效能測試
配置 :aws-elasticcache-redis 2核4g
因為Ratelimiter-client的功能比較簡單,基本上是redis的效能打個折扣。
單執行緒取令牌:Ratelimiter-client的 QPS = 250/s
10個執行緒取令牌:Ratelimiter-client的 QPS = 2000/s
100個執行緒取令牌:Ratelimiter-client的 QPS = 5000/s
4、總結
限流系統從設計到實現都比較簡單,但是確實很實用,用四個字來形容就是: 短小強悍 ,其中比較重要的是結合公司的許可權體系和系統結構,設計出符合自己公司規範的限流系統。
不足 :
redis 我們用的是單點redis,只做了主從,沒有使用redis高可用叢集(可能使用redis高可用叢集,會帶來新的問題)
限流系統目前只做了應用層面的實現,沒有做介面閘道器上的實現
熔斷策略需要自己定製,如果實現的好一點,可以給一些常用的熔斷策略模板
參考書籍:
1.《 Redis 設計與實現》
2.《 Lua 程式設計指南》
參考文章:
1. redis官網
2. lua編碼規範
3. 聊聊高併發系統之限流特技
4. guava Ratelimiter 實現
5. Token_bucket wiki 詞條
那麼我們怎麼來學習Redis 或者說需要掌握哪些Redis的技術呢,這裡為大家準備了一個Redis的學習腦圖,思路如下

當然在Java架構師的路上,僅僅學完Redis是不夠的,我這邊還為大家準備好了Java架構師的學習腦圖,加群即可獲取Java工程化、高效能及分散式、高效能、高架構、zookeeper、效能調優、Spring、MyBatis、Netty原始碼分析和大資料等多個知識點高階進階乾貨的直播免費學習許可權及相關視訊資料,群號:835638062 點選連結加入群聊【Java高階架構學習交流】:https://jq.qq.com/?_wv=1027&k=5S3kL3v