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

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

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

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

一、計數器

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 的時候即達到了限流效果。

二、滑動窗口計數器

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 的數據結構會越來越大。

三、漏斗

1. 設計思路

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

2. 實現方式

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

這種方案由於出口速率是固定的,所以並沒有辦法應對短時間的突發流量。

四、令牌桶

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());
}

五、限流進階

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

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

在真實應用場景,可以採用 redis + lua 的方式,通過把邏輯放在 redis 端,來減少調用次數。

lua 的邏輯如下:

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

文章內容收集於網絡。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章