基於Redis和Lua的分佈式限流

 Java單機限流可使用AtomicInteger,RateLimiter或Semaphore來實現,可是上述方案都不支持集羣限流。集羣限流的應用場景有兩個,一個是網關,經常使用的方案有Nginx限流和Spring Cloud Gateway,另外一個場景是與外部或者下游服務接口的交互,由於接口限制必須進行限流。html

 本文的主要內容爲:redis

  • Redis和Lua的使用場景和注意事項,好比說KEY映射的問題。
  • Spring Cloud Gateway中限流的實現。

集羣限流的難點

 在上篇Guava RateLimiter的分析文章中,咱們學習了令牌桶限流算法的原理,下面咱們就探討一下,若是將RateLimiter擴展,讓它支持集羣限流,會遇到哪些問題。算法

RateLimiter會維護兩個關鍵的參數nextFreeTicketMicrosstoredPermits,它們分別是下一次填充時間和當前存儲的令牌數。當RateLimiteracquire函數被調用時,也就是有線程但願獲取令牌時,RateLimiter會對比當前時間和nextFreeTicketMicros,根據兩者差距,刷新storedPermits,而後再判斷更新後的storedPermits是否足夠,足夠則直接返回,不然須要等待直到令牌足夠(Guava RateLimiter的實現比較特殊,並非當前獲取令牌的線程等待,而是下一個獲取令牌的線程等待)。數組

 因爲要支持集羣限流,因此nextFreeTicketMicrosstoredPermits這兩個參數不能只存在JVM的內存中,必須有一個集中式存儲的地方。並且,因爲算法要先獲取兩個參數的值,計算後在更新兩個數值,這裏涉及到競態限制,必需要處理併發問題。安全

 集羣限流因爲會面對相比單機更大的流量衝擊,因此通常不會進行線程等待,而是直接進行丟棄,由於若是讓拿不到令牌的線程進行睡眠,會致使大量的線程堆積,線程持有的資源也不會釋放,反而容易拖垮服務器。服務器

Redis和Lua

 分佈式限流本質上是一個集羣併發問題,Redis單進程單線程的特性,自然能夠解決分佈式集羣的併發問題。因此不少分佈式限流都基於Redis,好比說Spring Cloud的網關組件Gateway。網絡

 Redis執行Lua腳本會以原子性方式進行,單線程的方式執行腳本,在執行腳本時不會再執行其餘腳本或命令。而且,Redis只要開始執行Lua腳本,就會一直執行完該腳本再進行其餘操做,因此Lua腳本中不能進行耗時操做。使用Lua腳本,還能夠減小與Redis的交互,減小網絡請求的次數。架構

 Redis中使用Lua腳本的場景有不少,好比說分佈式鎖,限流,秒殺等,總結起來,下面兩種狀況下可使用Lua腳本:併發

  • 使用 Lua 腳本實現原子性操做的CAS,避免不一樣客戶端先讀Redis數據,通過計算後再寫數據形成的併發問題。
  • 先後屢次請求的結果有依賴時,使用 Lua 腳本將多個請求整合爲一個請求。

 可是使用Lua腳本也有一些注意事項:app

  • 要保證安全性,在 Lua 腳本中不要定義本身的全局變量,以避免污染 Redis內嵌的Lua環境。由於Lua腳本中你會使用一些預製的全局變量,好比說redis.call()
  • 要注意 Lua 腳本的時間複雜度,Redis 的單線程一樣會阻塞在 Lua 腳本的執行中。
  • 使用 Lua 腳本實現原子操做時,要注意若是 Lua 腳本報錯,以前的命令沒法回滾,這和Redis所謂的事務機制是相同的。
  • 一次發出多個 Redis 請求,但請求先後無依賴時,使用 pipeline,比 Lua 腳本方便。
  • Redis要求單個Lua腳本操做的key必須在同一個Redis節點上。解決方案能夠看下文對Gateway原理的解析。

性能測試

 Redis雖然以單進程單線程模型進行操做,可是它的性能卻十分優秀。總結來講,主要是由於:

  • 絕大部分請求是純粹的內存操做
  • 採用單線程,避免了沒必要要的上下文切換和競爭條件
  • 內部實現採用非阻塞IO和epoll,基於epoll本身實現的簡單的事件框架。epoll中的讀、寫、關閉、鏈接都轉化成了事件,而後利用epoll的多路複用特性,毫不在io上浪費一點時間。

 因此,在集羣限流時使用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,執行了三次操做。

Spring Cloud Gateway的限流實現

Spring Cloud

Spring Cloud

Gateway是微服務架構Spring Cloud的網關組件,它基於Redis和Lua實現了令牌桶算法的限流功能,下面咱們就來看一下它的原理和細節吧。

Gateway基於Filter模式,提供了限流過濾器RequestRateLimiterGatewayFilterFactory。只需在其配置文件中進行配置,就可使用。具體的配置感興趣的同窗自行學習,咱們直接來看它的實現。

RequestRateLimiterGatewayFilterFactory依賴RedisRateLimiterisAllowed函數來判斷一個請求是否要被限流拋棄。

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文件夾下。它就是如同GuavaRateLimiter同樣,實現了令牌桶算法,只不過不在須要進行線程休眠,而是直接返回是否可以獲取。

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

參考

相關文章
相關標籤/搜索