分佈式限流算法以及實戰

限流的作用

由於API接口無法控制調用方的行爲,因此當遇到瞬時請求量激增時,會導致接口占用過多服務器資源,使得其他請求響應速度降低或是超時,更有甚者可能導致服務器宕機。 限流(Rate limiting)指對應用服務的請求進行限制,例如某一接口的請求限制爲100個每秒,對超過限制的請求則進行快速失敗或丟棄。

限流可以應對:
(1)熱點業務帶來的突發請求;
(2) 調用方bug導致的突發請求;
(3) 惡意攻擊請求。

因此,對於公開的接口最好採取限流措施。

分佈式限流的優勢

在這裏插入圖片描述

當應用爲單點應用時,只要應用進行了限流,那麼應用所依賴的各種服務也都得到了保護。
在這裏插入圖片描述
但線上業務出於各種原因考慮,多是分佈式系統,單節點的限流僅能保護自身節點,但無法保護應用依賴的各種服務,並且在進行節點擴容、縮容時也無法準確控制整個服務的請求限制。
在這裏插入圖片描述
而如果實現了分佈式限流,那麼就可以方便地控制整個服務集羣的請求限制,且由於整個集羣的請求數量得到了限制,因此服務依賴的各種資源也得到了限流的保護。

限流算法

本文介紹幾種最常用的限流算法:固定窗口計數器;滑動窗口計數器;漏桶;令牌桶。

固定窗口計數器算法

在這裏插入圖片描述
固定窗口計數器算法過程如下:
(1)將時間劃分爲多個窗口;
(2)在每個窗口內每有一次請求就將計數器加一;
(3)如果計數器超過了限制數量,則本窗口內所有超過限制的請求都被丟棄,當時間到達下一個窗口時計數器重置。

固定窗口計數器是最爲簡單的算法,但這個算法有時會讓通過請求量允許爲限制的兩倍。考慮如下情況:限制1秒內最多通過5個請求,在第一個窗口的最後半秒內通過了5個請求,第二個窗口的前半秒內又通過了5個請求。這樣看來就是在1秒內通過了10個請求。
在這裏插入圖片描述

滑動窗口計數器算法

在這裏插入圖片描述
滑動窗口計數器算法概念如下:
(1)將時間劃分爲多個區間;
(2)在每個區間內每有一次請求就將計數器加一,維持一個佔據多個區間的窗口;
(3)每經過一個區間的時間,則拋棄最老的一個區間,並納入最新的一個區間;
(4) 如果當前窗口內區間的請求計數總和超過了限制數量,則超過限制的請求被丟棄。

例如將10s設置爲一個區間,規定每分鐘不能限制爲3000,則窗口爲6個區間,每隔10秒窗口向前進一次,前進時拋棄最前面一個區間,納入一個新區間。前進過程中,窗口內的請求總數不能超過3000。

滑動窗口計數器是通過將窗口再細分,並且按照時間"滑動",這種算法避免了固定窗口計數器帶來的雙倍突發請求,但時間區間的精度越高,算法所需的空間容量就越大。

漏桶算法

在這裏插入圖片描述
漏桶算法過程如下:
(1)將每個請求視作"水滴"放入"漏桶"進行存儲;
(2)“漏桶"以固定速率向外"漏"出請求來執行如果"漏桶"空了則停止"漏水”;
(3)如果"漏桶"滿了則多餘的"水滴"會被直接丟棄。

漏桶算法多使用隊列實現,服務的請求會存到隊列中,服務的提供方則按照固定的速率從隊列中取出請求並執行,過多的請求則放在隊列中排隊或直接拒絕。

漏桶算法的缺陷也很明顯,當短時間內有大量的突發請求時,即便此時服務器沒有任何負載,每個請求也都得在隊列中等待一段時間才能被響應。

令牌桶算法

在這裏插入圖片描述
令牌桶算法過程如下:
(1) 令牌以固定速率生成;
(2)生成的令牌放入令牌桶中存放,如果令牌桶滿了則多餘的令牌會直接丟棄,當請求到達時,會嘗試從令牌桶中取令牌,取到了令牌的請求可以執行;
(3) 如果桶空了,那麼嘗試取令牌的請求會被直接丟棄。

令牌桶算法既能夠將所有的請求平均分佈到時間區間內,又能接受服務器能夠承受範圍內的突發請求,因此是目前使用較爲廣泛的一種限流算法。

實戰

作爲如此重要的功能,在Java中自然有很多實現限流的類庫,例如Google的開源項目guava提供了RateLimiter類,實現了單點的令牌桶限流。

而分佈式限流常用的則有Hystrix、resilience4j、Sentinel等框架,但這些框架都需引入第三方的類庫,對於國企等一些保守的企業,引入外部類庫都需要經過層層審批,較爲麻煩。

分佈式限流本質上是一個集羣併發問題,而Redis作爲一個應用廣泛的中間件,又擁有單進程單線程的特性,天然可以解決分佈式集羣的併發問題。本文簡單介紹一個通過Redis實現單次請求判斷限流的功能。

腳本編寫

經過上面的對比,最適合的限流算法就是令牌桶算法。而爲實現限流算法,需要反覆調用Redis查詢與計算,一次限流判斷需要多次請求較爲耗時。因此我們採用編寫Lua腳本運行的方式,將運算過程放在Redis端,使得對Redis進行一次請求就能完成限流的判斷。

令牌桶算法需要在Redis中存儲桶的大小、當前令牌數量,並且實現每隔一段時間添加新的令牌。最簡單的辦法當然是每隔一段時間請求一次Redis,將存儲的令牌數量遞增。

但實際上我們可以通過對限流兩次請求之間的時間和令牌添加速度來計算得出上次請求之後到本次請求時,令牌桶應添加的令牌數量。因此我們在Redis中只需要存儲上次請求的時間和令牌桶中的令牌數量,而桶的大小和令牌的添加速度可以通過參數傳入實現動態修改。

由於第一次運行腳本時默認令牌桶是滿的,因此可以將數據的過期時間設置爲令牌桶恢復到滿所需的時間,及時釋放資源。
編寫完成的Lua腳本如下:

local ratelimit_info = redis.pcall('HMGET',KEYS[1],'last_time','current_token')

local last_time = ratelimit_info[1]

local current_token = tonumber(ratelimit_info[2])

local max_token = tonumber(ARGV[1])

local token_rate = tonumber(ARGV[2])

local current_time = tonumber(ARGV[3])

local reverse_time = 1000/token_rate

if current_token == nil then

  current_token = max_token

  last_time = current_time

else

  local past_time = current_time-last_time

  local reverse_token = math.floor(past_time/reverse_time)

  current_token = current_token+reverse_token

  last_time = reverse_time*reverse_token+last_time

  if current_token>max_token then

    current_token = max_token

  end

end

local result = 0

if(current_token>0) then

  result = 1

  current_token = current_token-1

end 

redis.call('HMSET',KEYS[1],'last_time',last_time,'current_token',current_token)

redis.call('pexpire',KEYS[1],math.ceil(reverse_time*(max_token-current_token)+(current_time-last_time)))

return result

執行限流

這裏使用 SpringDataRedis 來進行 Redis 腳本的調用。
編寫 Redis 腳本類:

public class RedisReteLimitScript implements RedisScript<String> {
   private static final String SCRIPT =
      "local ratelimit_info = redis.pcall('HMGET',KEYS[1],'last_time','current_token') local last_time = ratelimit_info[1] local current_token = tonumber(ratelimit_info[2]) local max_token = tonumber(ARGV[1]) local token_rate = tonumber(ARGV[2]) local current_time = tonumber(ARGV[3]) local reverse_time = 1000/token_rate if current_token == nil then current_token = max_token last_time = current_time else local past_time = current_time-last_time; local reverse_token = math.floor(past_time/reverse_time) current_token = current_token+reverse_token; last_time = reverse_time*reverse_token+last_time if current_token>max_token then current_token = max_token end end local result = '0' if(current_token>0) then result = '1' current_token = current_token-1 end redis.call('HMSET',KEYS[1],'last_time',last_time,'current_token',current_toke  redis.call('pexpire',KEYS[1],math.ceil(reverse_time*(max_tokencurrent_token)+(current_time-last_time))) return result";

  @Override   public String getSha1() {
    return DigestUtils.sha1Hex(SCRIPT);
  }

  @Override   public Class<String> getResultType() {     return String.class;
  }

  @Override   public String getScriptAsString() {     return SCRIPT;
  }
}

通過 RedisTemplate 對象執行腳本:

public boolean rateLimit(String key, int max, int rate) {

    List<String> keyList = new ArrayList<>(1);

    keyList.add(key);

    return "1".equals(stringRedisTemplate

        .execute(new RedisReteLimitScript(), keyList, Integer.toString(max), Integer.toString(rate),

            Long.toString(System.currentTimeMillis())));

  } 

rateLimit方法傳入的key爲限流接口的ID,max爲令牌桶的最大大小,rate爲每秒鐘恢復的令牌數量,返回的boolean即爲此次請求是否通過了限流。爲了測試Redis腳本限流是否可以正常工作,我們編寫一個單元測試進行測試看看。

@Autowired

  private RedisManager redisManager;



  @Test

  public void rateLimitTest() throws InterruptedException {

    String key = "test_rateLimit_key";

    int max = 10;  //令牌桶大小

    int rate = 10; //令牌每秒恢復速度

    AtomicInteger successCount = new AtomicInteger(0);

    Executor executor = Executors.newFixedThreadPool(10);

    CountDownLatch countDownLatch = new CountDownLatch(30);

    for (int i = 0; i < 30; i++) {

      executor.execute(() -> {

        boolean isAllow = redisManager.rateLimit(key, max, rate);

        if (isAllow) {

          successCount.addAndGet(1);

        }

        log.info(Boolean.toString(isAllow));

        countDownLatch.countDown();

      });

    }

    countDownLatch.await();

    log.info("請求成功{}次", successCount.get());

  }

設置令牌桶大小爲10,令牌桶每秒恢復10個,啓動10個線程在短時間內進行30次請求,並輸出每次限流查詢的結果。日誌輸出:

[19:12:50,283]true
[19:12:50,284]true
[19:12:50,284]true
[19:12:50,291]true
[19:12:50,291]true
[19:12:50,291]true
[19:12:50,297]true
[19:12:50,297]true
[19:12:50,298]true
[19:12:50,305]true
[19:12:50,305]false
[19:12:50,305]true
[19:12:50,312]false
[19:12:50,312]false
[19:12:50,312]false
[19:12:50,319]false
[19:12:50,319]false
[19:12:50,319]false
[19:12:50,325]false
[19:12:50,325]false
[19:12:50,326]false
[19:12:50,380]false
[19:12:50,380]false
[19:12:50,380]false
[19:12:50,387]false
[19:12:50,387]false
[19:12:50,387]false
[19:12:50,392]false
[19:12:50,392]false
[19:12:50,392]false
[19:12:50,393]請求成功11次

可以看到,在0.1秒內請求的30次請求中,除了初始的10個令牌以及隨時間恢復的1個令牌外,剩下19個沒有取得令牌的請求均返回了false,限流腳本正確的將超過限制的請求給判斷出來了,業務中此時就可以直接返回系統繁忙或接口請求太過頻繁等提示。

開發中遇到的問題

1)Lua變量格式
Lua中的String和Number需要通過tonumber()和tostring()進行轉換。

2)Redis入參
Redis的pexpire等命令不支持小數,但Lua的Number類型可以存放小數,因此Number類型傳遞給 Redis時最好通過math.ceil()等方式轉換以避免存在小數導致命令失敗。

3)Time命令
由於Redis在集羣下是通過複製腳本及參數到所有節點上,因此無法在具有不確定性的命令後面執行寫入命令,因此只能請求時傳入時間而無法使用Redis的Time命令獲取時間。
3.2版本之後的Redis腳本支持redis.replicate_commands(),可以改爲使用Time命令獲取當前時間。

4)潛在的隱患
由於此Lua腳本是通過請求時傳入的時間做計算,因此務必保證分佈式節點上獲取的時間同步,如果時間不同步會導致限流無法正常運作。

轉載自–https://mp.weixin.qq.com/s/qb3rg_ZpcMcvyaIRsvc1fw

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