【限流算法】常見的限流算法及其實現方式

在高併發的分佈式系統,如大型電商系統中,因爲接口 API 沒法控制上游調用方的行爲,所以當瞬間請求量突增時,會致使服務器佔用過多資源,發生響應速度下降、超時乃至宕機,甚至引起雪崩形成整個系統不可用。java

面對這種狀況,一方面咱們會提高 API 的吞吐量和 QPS(Query Per Second 每秒查詢量),但總歸會有上限,因此另外一方面爲了應對巨大流量的瞬間提交,咱們須要作對應的限流處理,也就是對請求量進行限制,對於超出限制部分的請求做出快速拒絕、快速失敗、丟棄處理,以保證本服務以及下游資源系統的穩定。redis

常見的限流算法有計數器、漏斗、令牌桶。算法

1、計數器

1. 設計思路

計數器限流方式比較粗暴,一次訪問就增長一次計數,在系統內設置每 N 秒的訪問量,超過訪問量的訪問直接丟棄,從而實現限流訪問。具體大概是如下步驟:數組

  1. 將時間劃分爲固定的窗口大小,例如 1 s;
  2. 在窗口時間段內,每來一個請求,對計數器加 1;
  3. 當計數器達到設定限制後,該窗口時間內的後續請求都將被丟棄;
  4. 該窗口時間結束後,計數器清零,重新開始計數。

這種算法的弊端是,在開始的時間,訪問量被使用完後,1 s 內會有很長時間的真空期是處於接口不可用的狀態的,同時也有可能在一秒內出現兩倍的訪問量。服務器

  1. T窗口的前1/2時間 無流量進入,後1/2時間經過5個請求;
  2. T+1窗口的前 1/2時間 經過5個請求,後1/2時間因達到限制丟棄請求。
  3. 所以在 T的後1/2和(T+1)的前1/2時間組成的完整窗口內,經過了10個請求。

2. 實現方式

實現方式和擴展方式不少,這裏以 Redis 舉例簡單的實現,計數器主要思路就是在單位時間內,有且僅有 N 數量的請求可以訪問個人代碼程序。因此能夠利用 Redis 的 setnx來實現這方面的功能。網絡

好比如今須要在 10 秒內限定 20 個請求,那麼能夠在 setnx 的時候設置過時時間 10,當請求的 setnx 數量達到 20 的時候即達到了限流效果。數據結構

2、滑動窗口計數器

1. 設計思路

滑動窗口計數法的思路是:併發

  1. 將時間劃分爲細粒度的區間,每一個區間維持一個計數器,每進入一個請求則將計數器加一;
  2. 多個區間組成一個時間窗口,每流逝一個區間時間後,則拋棄最老的一個區間,歸入新區間。如圖中示例的窗口 T1 變爲窗口 T2;
  3. 若當前窗口的區間計數器總和超過設定的限制數量,則本窗口內的後續請求都被丟棄。

2. 實現方式

利用 Redis 的 list 數據結構能夠垂手可得地實現該功能。咱們能夠將請求打形成一個 zset 數組,當每一次請求進來的時候,key 保持惟一,value 能夠用 UUID 生成,而 score 能夠用當前時間戳表示,由於 score 咱們能夠用來計算當前時間戳以內有多少的請求數量。而 zset 數據結構也提供了 range 方法讓咱們能夠很輕易地獲取到兩個時間戳內有多少請求。負載均衡

public Response limitFlow() {
    Long  currentTime = new Date().getTime();
    if (redisTemplate.hasKey("limit")) {
        Integer count = redisTemplate.opsForZset().rangeByScore("limit", currentTime - intervalTime, currentTime).size();
        if (count != null && count > 5) {
            return Response.ok("每分鐘最多隻能訪問 5 次!");
        }
    }
    redisTemplate.opsForZSet().add("limit", UUID.randomUUID().toString(), currentTime);
    return Response.ok("訪問成功");
}

經過上述代碼能夠作到滑動窗口的效果,而且能保證每 N 秒內至多 M 個請求,實現方式相對來講也是比較簡單的,可是所帶來的缺點就是 zset 的數據結構會愈來愈大。dom

3、漏斗

1. 設計思路

在計數器算法中咱們看到,當使用了全部的訪問量後,接口會徹底處於不可用狀態,有些系統不能接受這樣的處理方式,對此可使用漏斗算法進行限流,漏斗算法的原理就像名字,訪問量從漏斗的大口進入,從漏斗的小口進入系統。這樣不論是多大的訪問量進入漏斗,最後進入系統的訪問量都是固定的。漏斗的好處就是,大批量訪問進入時,漏斗有容量,不超過容量(容量的設計=固定處理的訪問量 * 可接受等待時長)的數據均可以排隊等待處理,超過的纔會丟棄。

2. 實現方式

實現方式可使用隊列,隊列設置容量,訪問能夠大批量塞入隊列,滿隊列後丟棄後續訪問量。隊列的出口以固定速率拿去訪問量處理。

這種方案因爲出口速率是固定的,因此並無辦法應對短期的突發流量。

4、令牌桶

1. 設計思路

令牌桶算法是漏斗算法的改進版,爲了處理短期的突發流量而作了優化,令牌桶算法主要由三部分組成:令牌流數據流令牌桶

名詞釋義:

  • 令牌桶:流通令牌的管道,用於生成的令牌的流通,放入令牌桶中。
  • 數據流:進入系統的數據流量。
  • 令牌桶:保存令牌的區域,能夠理解爲一個緩衝區,令牌保存在這裏用於使用。

令牌桶會按照必定的速率生成令牌放入令牌桶,訪問要進入系統時,須要從令牌桶中獲取令牌,有令牌的能夠進入,沒有的被拋棄,因爲令牌桶的令牌是源源不斷生成的,當訪問量小時,能夠留存令牌達到令牌桶的上限,這樣當短期的突發訪問量時,積累的令牌數能夠處理這個問題。當訪問量持續大量流入時,因爲生成令牌的速率是固定的,最後也就變成了相似漏斗算法的固定流量處理。

2. 實現方式

實現方式和漏斗也比較相似,可使用一個隊列保存令牌,一個定時任務用等速率生成令牌放入隊列,訪問量進入系統時,從隊列獲取令牌再進入系統。

google 開源的 guava 包中的 RateLimiter 類實現了令牌桶算法,不一樣其實現方式是單機的,集羣能夠按照上面的實現方式,隊列使用中間件 MQ 實現,配合負載均衡算法,考慮集羣各個服務器的承壓狀況作對應服務器的隊列是較好的作法。

這裏簡單用 Redis 以及定時任務模擬大概的過程:

首先依靠 List 的 leftPop 來獲取令牌:

// 輸出令牌
public Response limitFlow() {
    Object result = redisTemplate.opsForList().leftPop("limit_list");
    if (result == null) {
        return Response.ok("當前令牌桶中無令牌!");
    }
    return Response.ok("訪問成功!");
}

再依靠 Java 的定時任務,定時往 List 中 rightPush 令牌,固然令牌也須要保證惟一性,因此這裏利用 UUID 生成:

// 10S的速率往令牌桶中添加UUID,只爲保證惟一性
@Scheduled(fixedDelay = 10_000,initialDelay = 0)
public void setIntervalTimeTask(){
    redisTemplate.opsForList().rightPush("limit_list",UUID.randomUUID().toString());
}

5、限流進階

單點應用下,對應用進行限流,既能知足本服務的需求,又能夠很好地保護好下游資源。在選型上,能夠採用上面說起的 Google Guava 的 RateLimiter。

而在多機部署的場景下,對單點的限流,並不能達到咱們想要的最好效果,須要引入分佈式限流。分佈式限流的算法,依然能夠採用令牌桶算法,只不過將令牌桶的發放、存儲改成全局的模式。

在真實應用場景,能夠採用 redis + lua 的方式,經過把邏輯放在 redis 端,來減小調用次數。

lua 的邏輯以下:

  1. redis 中存儲剩餘令牌的數量 cur_token,和上次獲取令牌的時間 last_time;
  2. 在每次申請令牌時,能夠根據(當前時間 cur_time - last_time) 的時間差乘以令牌發放速率,算出當前可用令牌數;
  3. 若是有剩餘令牌,則准許請求經過,不然不經過。

文章內容收集於網絡。

相關文章
相關標籤/搜索