簡介
前一篇文章:為了友善開發,我打算實作一個Redis 工具集 主要介紹了開發 Redis 工具集的 MQ(Stream資料結構做消息隊列)、delay(延遲隊列)功能,這篇檔案主要分享一下使用 redis 如何做分布式限流的設計方案。
限流訴求
我希望有一個限流工具,它具備以下功能:
- 分布式限流,不是單機限流;
- 盡量少的開發;
- 使用靈活,可以對API(請求)限流,也可以對某一個方法限流;
- 能夠有多種次元限流,比如按照 IP限流、接口限流等;
- 友善使用者拓展,也就是限流的次元可以由使用者确定;
我列舉了一個限流工具應該具備的功能,我參考了 spring gateway 的限流方式,設計了 redis 限流工具集;
使用示範
如何使用呢?
1.引入 Maven 依賴(目前可以下載下傳代碼上傳到自己的私服或者本地倉庫,後面會推到 Maven 中央倉庫)
xml複制代碼 <dependency>
<groupId>cn.org.wangchangjiu</groupId>
<artifactId>redis-util-spring-boot-starter</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
2.配置檔案(application.yaml)開啟限流功能
yaml複制代碼redis:
util:
limit:
enable: true
3.假如我想以 IP 限流,那麼在 配置檔案(application.yaml)中如下配置
yaml複制代碼redis:
util:
limit:
configs:
- path: "/redis/*" --- 攔截請求以/redis/*開頭的這些url
replenishRate: 1 --- 每秒中增加的令牌數量
burstCapacity: 5 --- 桶容量
keyResolver: "ipKeyResolver" --- 限流次元
enable: true
解釋一下上面的配置:
configs :是每一個限流的配置,你可以配置多個,也就是可以對不同的請求給以不同的限流次元;
path: 表示需要攔截比對哪些 URL,假如說我需要對redis開頭(/redis/*)的請求url,使用IP限流,那麼對于所有以 redis開頭的url都會被以IP次元限流了。 那如果我還配置了一個 /** 的限流配置,即如下配置: yaml複制代碼 redis: util: limit: configs: - path: "/**" replenishRate: 1 burstCapacity: 10 keyResolver: "apiKeyResolver" --- 限流次元 - path: "/redis/*" --- 攔截請求以/redis/*開頭的這些url replenishRate: 1 --- 每秒中增加的令牌數量 burstCapacity: 5 --- 桶容量 keyResolver: "ipKeyResolver" --- 限流次元 enable: true 對于這種情況,如果請求 url:/redis/limit,該被哪個限流方案生效呢?答案是,生效的是 ipKeyResolver, 因為 url:/redis/limit 最比對path: "/redis/*";
以上就是所有限流的開發了,是不是很簡單,也就說隻要簡單配置 yaml 檔案不需要開發其他代碼就可以實作以 ip 限流或者以 請求API限流(apiKeyResolver)。
以IP限流為例,示範效果
yaml中對于限流配置如下:
yaml複制代碼redis:
util:
limit:
configs:
- path: "/redis/*" --- 攔截請求以/redis/*開頭的這些url
replenishRate: 1 --- 每秒中增加的令牌數量
burstCapacity: 5 --- 桶容量
keyResolver: "ipKeyResolver" --- 限流次元
enable: true
請求接口實作:
jMeter 壓測工具配置:
jMeter 壓測結果:
分析一下: 我們配置的桶容量是5個,也就是說初始化會有5個令牌(最高也隻有5個,每秒中生産1個,到達5個後會丢棄),由上圖 jMeter 我配置的是20個線程,1秒内跑完,1秒内沒跑完,沒有産生新的令牌,是以隻有初始化的5個令牌可以使用,是以隻能有5個請求通過。
如何擴充限流次元
如果你隻是想 以ip限流或者以接口限流,那使用内置的 ipKeyResolver 和 apiKeyResolver 就可以了,不需要額外的開發,經過上面的配置就行。
但是如果你有别的限流次元如何簡單擴充呢?
案例:假如我有這樣的一個需求:針對首頁接口(/api/service/home),每個使用者每秒限流10次,那該怎麼做呢?
- 第一步,你需要開發一個keyResolver(即限流的次元)
- typescript複制代碼
- @Bean public KeyResolver tokenKeyResolver(){ return request -> request.getHeader("token"); }
- 然後,你需要把自己實作的 keyResolver 配置在 yaml
- yaml複制代碼
- redis: util: limit: configs: - path: "/api/service/home" --- 攔截請求以/redis/*開頭的這些url replenishRate: 1 --- 每秒中增加的令牌數量 burstCapacity: 10 --- 桶容量 keyResolver: "tokenKeyResolver" --- 限流次元 enable: true
就上面這樣兩步就可以擴充限流次元啦。
自定義 token(使用者)次元限流示範
jMeter 壓測工具配置:
jMeter 壓測結果:
分析: burstCapacity = 10 ,初始化10個令牌,3秒鐘産生 3 個令牌,共記 13個令牌。
如果我隻想對方法限流如何使用?
案例1:我想對某個方法以 IP次元 限流,如何實作?
如下圖,在需要被限流的方法上增加注解 @RedisRateLimitConfig 即可;
是以上面的案例意思是,令牌桶容量是10個,每秒中産生1個令牌,也就是說,每分鐘可以調用這個方法 70 次;
案例2:我想對某個方法限流,但是限流的次元(限流key)是與方法參數相關的,如何實作?
如上圖配置,keyResolver 支援配置某個Bean,也支援 配置SPEL。上面的配置是對于方法參數key為次元限流。
代碼分析
代碼結構
詳細分析
自動裝配做了些啥?
自動裝配 RedisLimitAutoConfiguration 主要是建立了一下Bean;
- limitRedisTemplate: 類型為 RedisTemplate<String, Object> 的Bean,用于發送redis指令,注意修改序列化方式;
- redisRequestRateLimiterScript:類型為 RedisScript,用于加載 redis 腳本;
- RedisRateLimitAspect:aop 切面Bean,攔截被 @RedisRateLimitConfig 修飾的方法,用于基于方法的限流;
- RedisLimitHandlerInterceptor:請求攔截器,攔截配置的請求,以及對請求限流;
- ApiConfigResolver:請求配置解析器,把 yaml 配置檔案裡的配置解析成目标對象,用于對請求的限流;
- RedisRateLimiter:redis核心限流器,核心是将lua腳本發到redis中執行,判斷是否能拿到請求令牌,其方法 isAllowed 是限流的結果;
- ipKeyResolver:内置基于IP限流政策;
- apiKeyResolver:内置基于api限流政策;
基于請求配置限流
具體流程如下圖:
ApiConfigResolver 在容器啟動之後,會解析 yaml 配置檔案,生成限流配置;
- 使用者發起請求;
- 請求被攔截器攔截,從請求配置解析器中擷取配置;
- 攔截器擷取目前請求的限流配置;
- 攔截器通過限流配置調用 redisRateLimiter 的 isAllowed 方法判斷是否被限流;
- redisRateLimiter 通過redisTemplate向redis發送指令,執行lua腳本;
- redisRateLimiter 擷取傳回令牌,判斷lua傳回值是否為1(表示允許請求) 7.攔截器拿到redisRateLimiter傳回的是否允許,允許則通過,否則傳回 429;
基于方法配置限流
基于方法和基于請求的類似,隻是觸發點不一樣,核心切面代碼:
核心lua腳本
這也是 springcloud gateway 限流的lua腳本。
lua複制代碼--生産速率,每秒生産多少個令牌
local rate = tonumber(ARGV[1])
--令牌桶容量
local capacity = tonumber(ARGV[2])
--目前時間(秒級時間戳)
local now = tonumber(ARGV[3])
--每個請求消耗的令牌個數 固定為 1
local requested = tonumber(ARGV[4])
--填充時間=容量/生産速率
local fill_time = capacity/rate
--key過期時間設定為填充時間的2倍
local ttl = math.floor(fill_time*2)
-- 擷取剩餘令牌數量 , KEYS[1] redis key名,用于儲存限流次元下剩餘令牌數量,request_rate_limiter.{id}.tokens
local last_tokens = tonumber(redis.call("get", KEYS[1]))
--不存在key,則初始化令牌數量為最大容量,也就是說的 初始化時令牌數為桶容量
if last_tokens == nil then
last_tokens = capacity
end
--最近擷取令牌秒級時間戳
local last_refreshed = tonumber(redis.call("get", KEYS[2]))
if last_refreshed == nil then
last_refreshed = 0
end
--距離上次擷取令牌時間相差多少秒
local delta = math.max(0, now-last_refreshed)
--計算目前令牌數量(考慮delta時間内生成的令牌個數=delta*速率),取 容量和 剩餘令牌+生成令牌數 的最小值,也就是說,達到容量後,就令牌就丢棄了
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
--目前令牌數量是否大于1
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
local allowed_num = 0
--允許通路,新令牌數量-1,allowed_num=1
if allowed then
new_tokens = filled_tokens - requested
allowed_num = 1
end
--儲存令牌個數和最近擷取令牌時間
redis.call("setex", KEYS[1], ttl, new_tokens)
redis.call("setex", KEYS[2], ttl, now)
return allowed_num
後記
其他子產品的設計細節後面再說,歡迎大家使用,也請大家多多提建議以及好的功能點,我都可以整合上去。
作者:程式員中的廢物
連結:https://juejin.cn/post/7259686809906118693