談談限流演算法,以及Redisson實現

KerryWu發表於2023-02-26

1. 限流的意義

今天談談限流。很早之前,在接觸像 hystrix、resilience4j、sentinel 這類的熔斷器元件時,就瞭解過其關於限流的功能。在實際開發應用中,超時、錯誤熔斷用的挺多,但限流熔斷用的到不多。

究其原因,在公司內部微服務呼叫時,就算服務呼叫的上下游服務,不是同一個專案團隊的服務,但至少是同一個公司的研發團隊。當防止下游方被頻繁呼叫,完全可以和上游方約定好協同方案,而不是透過限流的策略給上游方拋錯。

但如果上下游方比較獨立,則有必要透過限流來進行約束,和自我保護。

例如:我們產品對外提供的服務端開放API,如果不在檔案中約定好呼叫頻率限制,並做好自我保護,很容易就被人惡意攻擊。

再例如:我們在對接釘釘、微信等生態服務時,也需要呼叫它們在開放平臺的API,同樣也有限流要求。可參考 釘釘服務端API限流檔案。因為一旦呼叫釘釘API頻率超限,會觸發至少5分鐘的限流熔斷,這5分鐘內任何API呼叫都會報錯。所以我們作為服務呼叫方,更要將呼叫服務的請求限流。

2. 限流演演算法

在網上看了些限流演演算法,主要有4種。

2.1. 固定視窗演演算法

首先維護一個計數器,將單位時間段當做一個視窗,計數器記錄這個視窗接收請求的次數。

  • 當次數少於限流閥值,就允許訪問,並且計數器+1
  • 當次數大於限流閥值,就拒絕訪問。
  • 當前的時間視窗過去之後,計數器清零。

假設單位時間是1秒,限流閥值為3。在單位時間1秒內,每來一個請求,計數器就加1,如果計數器累加的次數超過限流閥值3,後續的請求全部拒絕。等到1s結束後,計數器清0,重新開始計數。

問題 1:視窗臨界值,導致雙倍閾值

假設限流閥值為5個請求,單位時間視窗是1s,如果我們在單位時間內的前0.8-1s和1-1.2s,分別併發5個請求。雖然都沒有超過閥值,但是如果算0.8-1.2s,則併發數高達10,已經超過單位時間1s不超過5閥值的定義啦,透過的請求達到了閾值的兩倍。

為瞭解決問題2中視窗臨界值的問題,引入了滑動視窗限流。滑動視窗限流解決固定視窗臨界值的問題,可以保證在任意時間視窗內都不會超過閾值。

問題 2(兩面性):集中流量,打滿閾值,後續服務不可用

比如視窗大小為1s,限流大小為100,然後恰好在某個視窗的第1ms來了100個請求,然後第2ms-999ms的請求就都會被拒絕,這段時間使用者會感覺系統服務不可用。

但這是兩面性的問題,有缺點,也有優點,後面會說。

2.2. 滑動視窗演演算法

相對於固定視窗,滑動視窗除了需要引入計數器之外,還需要記錄時間視窗內每個請求到達的時間點,因此對記憶體的佔用會比較多。

規則如下,假設時間視窗為 1 秒:

  • 記錄每次請求的時間。
  • 統計每次請求的時間 至 往前推1秒這個時間視窗內請求數,並且 1 秒前的資料可以刪除。
  • 統計的請求數小於閾值就記錄這個請求的時間,並允許透過,反之拒絕。

滑動視窗演演算法就是固定視窗的升級版。將計時視窗劃分成一個小視窗,滑動視窗演演算法就退化成了固定視窗演演算法。而滑動視窗演演算法其實就是對請求數進行了更細粒度的限流,視窗劃分的越多,則限流越精準。

但是滑動視窗和固定視窗都無法解決短時間之內集中流量的突擊,就和前面介紹的一樣。

接下來再說說漏桶,它可以解決時間視窗類的痛點,使得流量更加的平滑。

2.3. 漏桶演演算法

漏桶演演算法面對限流,就更加的柔性,不存在直接的粗暴拒絕。

它的原理很簡單,可以認為就是注水漏水的過程。往漏桶中以任意速率流入水,以固定的速率流出水。當水超過桶的容量時,會被溢位,也就是被丟棄。因為桶容量是不變的,保證了整體的速率。

  • 流入的水滴,可以看作是訪問系統的請求,這個流入速率是不確定的。
  • 桶的容量一般表示系統所能處理的請求數。
  • 如果桶的容量滿了,就達到限流的閥值,就會丟棄水滴(拒絕請求)
  • 流出的水滴,是恆定過濾的,對應服務按照固定的速率處理請求。

看到這想到啥,是不是 訊息佇列思想有點像,削峰填谷。經過漏洞這麼一過濾,請求就能平滑的流出,看起來很像很挺完美的?實際上它的優點也即缺點。

問題 3: 無法應對流量突發

面對突發請求,服務的處理速度和平時是一樣的,這其實不是我們想要的,在面對突發流量我們希望在系統平穩的同時,提升使用者體驗即能更快的處理請求,而不是和正常流量一樣,循規蹈矩的處理(看看,之前滑動視窗說流量不夠平滑,現在太平滑了又不行,難搞啊)。

而接下來我們要談的令牌桶演演算法能夠在一定程度上解決流量突發的問題。

2.4. 令牌桶演演算法

令牌桶演演算法是對漏斗演演算法的一種改進,除了能夠起到限流的作用外,還允許一定程度的流量突發。

令牌桶演演算法原理:

  • 有一個令牌管理員,根據限流大小,定速往令牌桶裡放令牌。
  • 如果令牌數量滿了,超過令牌桶容量的限制,那就丟棄。
  • 系統在接受到一個使用者請求時,都會先去令牌桶要一個令牌。如果拿到令牌,那麼就處理這個請求的業務邏輯;
  • 如果拿不到令牌,就直接拒絕這個請求。

可以看出令牌桶在應對突發流量的時候,桶內假如有 100 個令牌,那麼這 100 個令牌可以馬上被取走,而不像漏桶那樣勻速的消費。所以在應對突發流量的時候令牌桶表現的更佳。

2.5. 個人理解

按照我的理解,對這4種演演算法做下面的分類比較。

2.5.1. 滑動視窗演演算法 > 固定視窗演演算法

固定視窗演演算法實現簡單,效能高。但是會有臨界突發流量問題,瞬時流量最大可以達到閾值的2倍。

為瞭解決臨界突發流量,可以將視窗劃分為多個更細粒度的單元,每次視窗向右移動一個單元,於是便有了滑動視窗演演算法。

從演演算法效果上來講

滑動視窗演演算法要優於固定視窗演演算法,畢竟能避免視窗臨界值問題。

從實施效能上來講

固定視窗演演算法實現起來要更簡單,對效能資源要求更低。滑動視窗只需要引入計數器,但滑動視窗還需要記錄時間視窗內每個請求到達的時間點,因此對記憶體的佔用會比較多。

總結:滑動視窗演演算法優先

不過限流演演算法就是為了保護線上伺服器資源,避免被流量擊潰。與這代價相比,滑動視窗演演算法的那些效能資源消耗算得了什麼。所以目前市場上,幾乎看不到以固定視窗演演算法實現的限流元件。

2.5.2. 漏桶演演算法(MQ訊息佇列)

想要達到限流的目的,又不會掐斷流量,使得流量更加平滑?可以考慮漏桶演演算法。

我為啥在漏桶演演算法這節加上 MQ 消費佇列呢?因為在我的理解中,這種限流演演算法,就是 MQ 消費佇列的應用方法。無論生產訊息的頻率如何,MQ的消費者的消費頻率上限是固定的。

有差別嗎?有。漏桶演演算法中定義的是“桶容量固定。當水超過桶的容量時,會被溢位丟棄”。而 MQ 的常規用法是“削峰填谷”,訊息可以在佇列中積壓,然後滿滿消費,但不會輕易丟棄。其實這也符合通常的實際應用場景。真要實現漏桶演演算法的要求也行,完全給佇列設定為固定長度。

總結,如果要用漏桶演演算法限流,用 MQ 訊息佇列就是了。

2.5.3. 令牌桶演演算法、滑動視窗演演算法 相似

1. 相似

在我看來,這兩種演演算法很相似。

滑動視窗演演算法,是在使用時,按速率(視窗單位時間內的最大透過數量)計算計數器,沒超過就放行。

令牌桶演演算法,是按照速率往固定容量桶內投放令牌,在使用時,只要桶內還有令牌就可以放行。

從這個角度來看,令牌桶演演算法是將統計令牌數(計數器),和判斷是否可以放行,這兩個環節 “解耦” 了。

2. 共同的優點
  • 都沒有視窗臨界值問題。
  • (兩面性問題)都能應對流量突發。像滑動視窗演演算法,突發流量進來時,視窗時間內不超過計數器閾值即可。
3. 共同的缺點
  • (兩面性問題)突發流量會佔據大量令牌(計數器計數),導致後續流量進入受限。

2.5.4. 按照需求選型

在我看來,限流演演算法選型有兩種:

  • 滑動視窗演演算法、令牌桶演演算法,二者屬於同一類
  • 漏桶演演算法

而二者的區別就在於前面一直提到的兩面性問題。

兩面性問題:突發流量

限流演演算法中,對於應對突發流量,在我看來是個兩面性問題。

  • 優點:針對突發場景也有有效響應。
  • 缺點:當突發流量進來後,必然會對後續進來的其他流量造成影響,流量不夠平滑。

並沒有一種最好的限流演演算法,到底選擇哪種限流演演算法,還是要看實際需求場景,結合已有的資源,綜合考慮。

3. 限流元件探索

3.1. Ratelimiter

在做對呼叫釘釘API限流時,有看到釘釘檔案上推薦的限流方式,就是 Guava 的 RateLimiter。

RateLimiter 是基於令牌桶演演算法限流的。但 RateLimiter 對於持續生成令牌,採用的不是定時任務的方式(過於耗費資源,不適合高併發),而是使用延遲計算的方式。即在獲取令牌時計算上一次時間 nextFreeTicketMicros 和當前時間之間的差值,計算這段時間之內按照使用者設定的速率可以生產多少令牌。

void resync(long nowMicros) {
    // if nextFreeTicket is in the past, resync to now
    if (nowMicros > nextFreeTicketMicros) {
      double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
      storedPermits = min(maxPermits, storedPermits + newPermits);
      nextFreeTicketMicros = nowMicros;
    }
}

針對令牌桶演演算法的這種實現方式比較常見,後面在 Redisson 中也會見到。

我不理解為啥釘釘檔案裡面只官方推薦 RateLimiter,其實它是無法滿足需求的。因為它是基於 Java 執行緒實現的,是基於單機的限流。我們的服務基本都是多節點伺服器的,明顯無法實現總體限流。因此需要找一個能失效分散式限流的元件。

3.2. sentinel

再接著,我就想到了阿里自己的限流元件 sentinel,我們部門也有現成的 sentinel 服務可以用。

我記得在 sentinel dashboard 上配置限流規則時,可以基於叢集配置,於是我就動手試了一下。發現的確可以基於多個機器節點建立叢集,然後基於叢集來建立配置限流規則,以實現在整個叢集的維度實現限流。

但問題來了,dashboard 上叢集是需要手動選擇機器(ip+port)建立的。我們單個微服務的伺服器節點ip、埠非固定的,而且支援彈性伸縮。一旦伺服器發生變化,如何自動同步到 sentinel 叢集資訊上,就又是一個需要攻克的問題。

限流的演演算法不復雜,要不乾脆自己寫一個吧。基於 redis 儲存,這樣就能滿足分散式限流。

3.3. Redisson

可當我開始基於 redis 自己寫限流方法時,無意中發現 Redisson 自己就提供了封裝好的限流方法 RRateLimiter

之前一直在用 Redisson 封裝的分散式鎖方法,都忘了看看其他的功能了。下面就詳細介紹自己的使用,以及原始碼邏輯。

4. Redisson限流演演算法

4.1. 示例

1. pom.xml
引入 redisson 的依賴:
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.15.5</version>
        </dependency>
2. application

配置檔案中申明一些簡單的 redisson 的連線資訊。我這邊是本地起了一個 redis 庫,沒設定密碼。

spring:
  redis:
    redisson:
      config:
        singleServerConfig:
          address: redis://127.0.0.1:6379
          database: 0
3. controller
@RestController
@RequestMapping("")
@Slf4j
public class DemoController {
    private final RedissonClient redissonClient;

    public DemoController(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }


    @GetMapping("require")
    public void hello(Integer num) {
        RRateLimiter rateLimiter = redissonClient.getRateLimiter("LIMITER_NAME");
        rateLimiter.trySetRate(RateType.OVERALL, 5, 10, RateIntervalUnit.SECONDS);
        rateLimiter.tryAcquire(num,1,TimeUnit.MINUTES);
        log.info("get!");
    }
}

這邊寫了一個demo示例,定義了一個叫 "LIMITER_NAME" 的限流器,設定每10秒鐘生成5個令牌。然後根據介面傳入引數 num,看看請求多少個令牌。當請求不到時阻塞,最大阻塞時間為1分鐘。

4.2. redis 資料結構

RRateLimiter 介面的實現類幾乎都在 RedissonRateLimiter 上,我們看看前面呼叫 RRateLimier 方法時,這些方法的對應原始碼實現。

1. setRate

對應實現類中的原始碼是:

    public RFuture<Boolean> trySetRateAsync(RateType type, long rate, long rateInterval, RateIntervalUnit unit) {
        return this.commandExecutor.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "redis.call('hsetnx', KEYS[1], 'rate', ARGV[1]);redis.call('hsetnx', KEYS[1], 'interval', ARGV[2]);return redis.call('hsetnx', KEYS[1], 'type', ARGV[3]);", Collections.singletonList(this.getRawName()), new Object[]{rate, unit.toMillis(rateInterval), type.ordinal()});
    }

核心是其中的 lua 指令碼,摘出來看看:

redis.call('hsetnx', KEYS[1], 'rate', ARGV[1]);
redis.call('hsetnx', KEYS[1], 'interval', ARGV[2]);
return redis.call('hsetnx', KEYS[1], 'type', ARGV[3]);

發現基於一個 hash 型別的redis key 設定了3個值。下面就著重講講這裡限流演演算法中,一共用到的 3個 redis key。

2. key 1:Hash 結構

就是前面 setRate 設定的 hash key。按照之前限流器命名“LIMITER_NAME”,這個 redis key 的名字就是 LIMITER_NAME。一共有3個值:

  1. rate:代表速率
  2. interval:代表多少時間內產生的令牌
  3. type:代表單機還是叢集
3. key 2:ZSET 結構

ZSET 記錄獲取令牌的時間戳,用於時間對比,redis key 的名字是 {LIMITER_NAME}:permits。下面講講 ZSET 中每個元素的 member 和 score:

  • member: 包含兩個內容:(1)一段8位隨機字串,為了唯一標誌性當次獲取令牌;(2)數字,即當次獲取令牌的數量。不過這些是壓縮後儲存在 redis 中的,在工具上看時會發現亂碼。
  • score:記錄獲取令牌的時間戳,如:1667025166312(對應 2022-10-29 14:32:46)
4. key 3: string 結構

記錄的是當前令牌桶中剩餘的令牌數。redis key 的名字是 {LIMITER_NAME}:value

4.3. 演演算法原始碼分析

前面是鋪墊,下面就著重講講獲取令牌的原始碼吧。

對應方法是:

    private <T> RFuture<T> tryAcquireAsync(RedisCommand<T> command, Long value) {
        return this.commandExecutor.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, command, "local rate = redis.call('hget', KEYS[1], 'rate');local interval = redis.call('hget', KEYS[1], 'interval');local type = redis.call('hget', KEYS[1], 'type');assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')local valueName = KEYS[2];local permitsName = KEYS[4];if type == '1' then valueName = KEYS[3];permitsName = KEYS[5];end;assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate'); local currentValue = redis.call('get', valueName); if currentValue ~= false then local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval); local released = 0; for i, v in ipairs(expiredValues) do local random, permits = struct.unpack('fI', v);released = released + permits;end; if released > 0 then redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval); currentValue = tonumber(currentValue) + released; redis.call('set', valueName, currentValue);end;if tonumber(currentValue) < tonumber(ARGV[1]) then local nearest = redis.call('zrangebyscore', permitsName, '(' .. (tonumber(ARGV[2]) - interval), '+inf', 'withscores', 'limit', 0, 1); return tonumber(nearest[2]) - (tonumber(ARGV[2]) - interval);else redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1])); redis.call('decrby', valueName, ARGV[1]); return nil; end; else redis.call('set', valueName, rate); redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1])); redis.call('decrby', valueName, ARGV[1]); return nil; end;", Arrays.asList(this.getRawName(), this.getValueName(), this.getClientValueName(), this.getPermitsName(), this.getClientPermitsName()), new Object[]{value, System.currentTimeMillis(), ThreadLocalRandom.current().nextLong()});
    }

我們先看看執行 lua 指令碼時,所有要傳入的引數內容:

  • KEYS[1]:hash key name
  • KEYS[2]:全域性 string(value) key name
  • KEYS[3]:單機 string(value) key name
  • KEYS[4]:全域性 zset(permits) key name
  • KEYS[5]:單機 zset(permits) key name
  • ARGV[1]:當前請求令牌數量
  • ARGV[2]:當前時間
  • ARGV[3]:8位隨機字串

然後,我們再將其中的lua部分提取出來,我再根據自己的理解,在其中各段程式碼加上了註釋。

-- rate:間隔時間內產生令牌數量
-- interval:間隔時間
-- type:型別:0-全侷限流;1-單機限
local rate = redis.call('hget', KEYS[1], 'rate');
local interval = redis.call('hget', KEYS[1], 'interval');
local type = redis.call('hget', KEYS[1], 'type');
-- 如果3個引數存在空值,錯誤提示初始化未完成
assert(rate ~= false and interval ~= false and type ~= false, 'RateLimiter is not initialized')
local valueName = KEYS[2];
local permitsName = KEYS[4];
-- 如果是單機限流,在全域性key後拼接上機器唯一標識字元
if type == '1' then
    valueName = KEYS[3];
    permitsName = KEYS[5];
end ;
-- 如果:當前請求令牌數 < 視窗時間內令牌產生數量,錯誤提示請求令牌不能超過rate
assert(tonumber(rate) >= tonumber(ARGV[1]), 'Requested permits amount could not exceed defined rate');
-- currentValue = 當前剩餘令牌數量
local currentValue = redis.call('get', valueName);
-- 非第一次訪問,儲存剩餘令牌數量的 string(value) key 存在,有值(包括 0)
if currentValue ~= false then
    -- 當前時間戳往前推一個間隔時間,屬於時間視窗以外。時間視窗以外,簽發過的令牌,都屬於過期令牌,需要回收回來
    local expiredValues = redis.call('zrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
    -- 統計可以回收的令牌數量
    local released = 0;
    for i, v in ipairs(expiredValues) do
        -- lua struct的pack/unpack方法,可以理解為文字壓縮/解壓縮方法
        local random, permits = struct.unpack('fI', v);
        released = released + permits;
    end ;
    -- 移除 zset(permits) 中過期的令牌簽發記錄
    -- 將過期令牌回收回來,重新更新剩餘令牌數量
    if released > 0 then
        redis.call('zremrangebyscore', permitsName, 0, tonumber(ARGV[2]) - interval);
        currentValue = tonumber(currentValue) + released;
        redis.call('set', valueName, currentValue);
    end ;
    -- 如果 剩餘令牌數量 < 當前請求令牌數量,返回推測可以獲得所需令牌數量的時間
    -- (1)最近一次簽發令牌的釋放時間 = 最近一次簽發令牌的簽發時間戳 + 間隔時間(interval)
    -- (2)推測可獲得所需令牌數量的時間 = 最近一次簽發令牌的釋放時間 - 當前時間戳
    -- (3)"推測"可獲得所需令牌數量的時間,"推測",是因為不確定最近一次簽發令牌數量釋放後,加上到時候的剩餘令牌數量,是否滿足所需令牌數量
    if tonumber(currentValue) < tonumber(ARGV[1]) then
        local nearest = redis.call('zrangebyscore', permitsName, '(' .. (tonumber(ARGV[2]) - interval), '+inf', 'withscores', 'limit', 0, 1);
        return tonumber(nearest[2]) - (tonumber(ARGV[2]) - interval);
        -- 如果 剩餘令牌數量 >= 當前請求令牌數量,可直接記錄簽發令牌,並從剩餘令牌數量中減去當前簽發令牌數量
    else
        redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1]));
        redis.call('decrby', valueName, ARGV[1]);
        return nil;
    end ;
    -- 第一次訪問,儲存剩餘令牌數量的 string(value) key 不存在,為 null,走初始化邏輯
else
    redis.call('set', valueName, rate);
    redis.call('zadd', permitsName, ARGV[2], struct.pack('fI', ARGV[3], ARGV[1]));
    redis.call('decrby', valueName, ARGV[1]);
    return nil;
end ;

相關文章