「預熱桶」限流算法詳解(附 Node.js 實現)

文/金禪
「預熱桶」是我本身取的名字,它來源於 Google 的 Guava 工具包裏的 SmoothWarmingUp 類,表示帶預熱的令牌桶算法。

限流是在高併發場景下,保證系統穩定性的一把利器,在以前的文章中我介紹了集中基礎的限流算法,本文重點介紹一個更高級的限流算法——『預熱桶算法』的原理和實現;java

「預熱桶」的由來

在使用「限流器」的時候,咱們須要給設置一個合適的 閾值 ,這個閾值一般是一個系統可以正常工做所能承受的最大 QPS 。當系統的請求量達到閾值時,不一樣的限流器主要有兩種處理方式,一種是對超過閾值的請求直接返回失敗,另外一種是讓超過閾值的請求排隊等待。node

要控制系統的 QPS 不超過閾值最簡單的方式就是控制每一個請求的間隔;例如,若是一個系統的 QPS 閾值是 100,那咱們只要保證每一個請求的間隔不要低於 1(s)/100 = 10ms 就能夠了;每一個請求進來時,限流器會計算出當前時間與上一個請求的間隔(interval),若是大於interval >= 10ms 則表示當前 QPS 小於 100 直接放行,若是 Interval < 10ms,例如 Interval = 9ms 時,則須要讓這個請求等待 1ms 再執行。git

咱們發現,上面這種限流器對系統狀態的記錄是很是粗略的,它只是記錄了上次請求的時間。這種方式對請求的控制粒度太細了,沒有 buffer,其實咱們但願的粒度是 QPS;試想一下若是如今有兩個請求相隔 1ms 發送過來了,系統也一段時間內也只接收到這兩個請求,遠低於閾值定義的 100,可是按照上面的算法,晚到的請求要等待 9ms 後再執行,這顯然是不合理的。github

一個系統的利用率低說明這個系統有多餘的資源可被利用。這樣的話,「限流器」應該適當提速一下子,讓這些資源可以被好好利用起來。算法

然而從另外一個角度來看,系統利用率一直處於低下狀態也有可能意味着系統還沒準備好應對更多的請求,由於長時間處於低利用率下,系統因爲所依賴資源的限制,並不能立馬達到它正常的服務水平;例如系統依賴的緩存過時致使新的請求會直接請求 db,再好比不少系統使用了鏈接池,長時間的 idle 狀況下鏈接池只會保持少許的鏈接,新的請求會從新建立鏈接,這些都是耗時操做,完成這些操做以後,系統才能到達正常的服務水平;緩存

經過上面的狀況能夠看出,只給限流器設置一個系統正常狀況下可以處理請求的 QPS 閾值是不夠的,系統在預熱階段就算是低於閾值的請求量進來也可能會把系統壓垮,因此咱們須要一個可以應對系統預熱期的限流算法,這就是「預熱桶」算法的由來。bash

「預熱桶」算法原理

咱們先重溫一下令牌桶算法的原理:併發

  1. 令牌以固定速率生成;
  2. 生成的令牌放入令牌桶中存放,若是令牌桶滿了則多餘的令牌會直接丟棄,當請求到達時,會嘗試從令牌桶中取令牌,取到了令牌的請求能夠執行;
  3. 若是桶空了,那麼嘗試取令牌的請求會被直接丟棄。



「預熱桶」其實就是令牌桶的升級版,主要區別在於:咱們假設系統的 閾值 QPS 爲 count,在「令牌桶」中獲取單個令牌的時間是固定的:1 / count ,而從「預熱桶」中獲取單個令牌的時間是隨着存量令牌的數量 storedPermits 而變化的;函數

咱們假設系統剛啓動或者長時間沒有收到請求處於冷卻狀態,這個時候令牌達到飽和數量:maxPermits;當有慢慢有請求開始消耗令牌時,存在一個預熱期,在預熱期間內獲取單個令牌的時間(Interval)會比平穩期獲取單個令牌的時間要長(想一想這意味着什麼?),隨着令牌的減小,獲取單個令牌的時間會慢慢變短,最終到達一個穩定值 stableInterval;在穩按期獲取單個令牌的時間是 stableInterval;高併發

咱們知道 qps 全稱 query per second,表示一秒鐘的請求量,這和獲取單個令牌的時間恰好是倒數關係;假設系統的 閾值 QPS 爲 count,這意味着從「預熱桶」中獲取單個令牌的時長不能短於 1 / count 即上文中的 `stableInterval`,在「預熱桶」中,隨着令牌數的減小,獲取單個令牌的時長會變短直到 1 / count,從另外一個角度來看就是:隨着令牌數的減小,「預熱桶」放行的請求 QPS 會組件增長直到 count,這正是咱們所指望達到的效果。

前面說到在預熱期獲取單個令牌的時間要比穩按期獲取單個令牌的時間 stableInterval 長一些,那麼具體要比 stableInterval 長多少呢?

咱們能夠定義一個冷卻因子(coldFactor) ,令系統處於最冷的狀態下獲取一個令牌的時長 coldInterval = stableInterval * coldFactor ;「預熱桶」從最冷狀態到完成預熱進入穩按期有個轉折點,到達這個轉折點時的令牌數量咱們用 thresholdPermits 表示;這樣,咱們就得到了一個獲取(一個)令牌的時長隨着令牌數量變化的連續函數 f(storedPermits) :

  • 0 <= storedPermits <= thresholdPermits 時;f(storedPermits) = stableInterval; // 常數函數,函數值始終爲 stableInterval ;
  • thresholdPermits <= storedPermits <= maxPermits 時,f(storedPermits) = (coldInterval - stableInterval) * storedPermits / (maxPermits - thresholdPermits); // 正比例函數,比例常數爲 (coldInterval - stableInterval) / (maxPermits - thresholdPermits) ;

該函數繪製成圖以下所示:

^ throttling
             |        
       cold  +              |   /
    interval |              |  /.
             |              | / .  
             |              |/  .   
             |              +   .   ← "f(storedPermits)"  
             |             /|   .
             |            / |   .
             |           /  |   .
      stable +----------/   |   .   ← "warmup period"  
    interval |          .   |   .      is the area of the trapezoid between thresholdPermits and maxPermits
             |          .   |   .
             |          .   |   .
           0 +----------+---|---+--------------→ storedPermits
             0 thresholdPermits maxPermits複製代碼

「預熱桶算法原理圖」

在上面這張圖中,咱們畫一條與 x 軸垂直的線 n,這條線與函數曲線的交點的縱座標當前 storedPermits 數量下獲取單個令牌所需的時間;

  • 當咱們從右向左移動 n 時,表示系統接收到請求,令牌正在被消耗,假設系統連續接收到 k 個請求,獲取對應令牌所須要的時間爲:

t = f(maxPermits) + f(maxPermits - 1) + f(maxPermits - 2) + ... + f(maxPermits - k),經過微積分的知識能夠看出來這是在求函數 f 在 maxPermits - k 到 maxPermits 區間的定積分,能夠用這個區間的函數圖形的面積表示;

  • 相反,當咱們從左向右移動 n 時,表示有令牌新增,這個過程被稱爲冷卻,因爲這個過程跟概算法對系統預熱的支持沒有直接影響,所以冷卻過程在 Guava 和 Sentinel 中的實現方式有些差別,後文會講到;

「預熱桶」算法的實現

Guava 跟 Sentinel 的實現方式略有不一樣,感興趣的同窗能夠分別看看實現的源碼:SmoothRateLimiter(Guava)WarmUpController(Sentinel)

上面已經把「預熱桶」算法的原理講得很清楚了,咱們如今嘗試着用 node.js 實現一下;

首先咱們定義一個類 WarmupRateLimit,咱們在系統中通常會這樣來使用限流器:

constuctor() {
    this.warmupRatelimit = new WarmupRateLimit(/** QPS 閾值 */1000, /** 預熱時間 */ 10, /** 冷卻因子 */ 3);
}

// function bizFunction()
    // node 裏記錄了當前接口/資源的 qps 等信息
    if(!warmupRatelimit.canPass(node, acquireCount)) {
        // 返回請求被限流的錯誤
    }
      // 業務代碼
// }複製代碼

經過上面的使用方式,咱們大概可以寫出 WarmupRateLimit 的主要結構:

class WarmupRateLimit {
  constructor(count, warmupPeriod, coldFactor) {
    this.count = count;
    this.warmupPeriod = warmupPeriod;
    this.coldFactor = coldFactor;
  }
    // 判斷當前請求是否能經過
  canPass(node, acquireCount) { 
  }
}複製代碼

根據「預熱桶」算法的原理,咱們還須要記錄桶裏當前存儲的令牌數 storedPermits,根據請求所須要消耗的令牌數(默認是 1 )來計算獲取令牌所須要的時間 currentRequestCost,而此時限流器限制的 QPS 閾值則是 `1/currentRequestCost`;判斷 1/currentRequestCost 與 node 中記錄的當前 qps 值來決定是否讓該請求經過,代碼實現以下:

class WarmUpController extends Controller {
  constructor(count, warmUpPeriod, coldFactor) {
    super();
    this.count = count;
    this.coldFactor = coldFactor;
    this.lastFilledTime = Date.now();

    // 假設系統從開始進入穩按期到徹底穩定(令牌的獲取速度和令牌的加入速度持平,storedPermits = 0) 所需的時間佔令牌徹底消耗的時間的 1/coldFactor,
    // 即 thresholdPermits*stableInterval/(thresholdPermits*stableInterval + warmUpPeriod) = 1/coldFactor,
    // 而從上面的函數圖形中咱們知道預熱時間爲梯形面積 warmUpPeriod = 0.5*(stableInterval + coldInterval)*(maxPermits - thresholdPermits);

    this.thresholdPermits = (warmUpPeriod * count) / (coldFactor - 1);
    this.maxPermits = this.thresholdPermits + (2 * warmUpPeriod * count / (1 + coldFactor));

    // 預熱期比例常數
    this.slope = ((coldFactor - 1) * (this.maxPermits - this.thresholdPermits)) / count;
    // 令牌初始值爲令牌最大值
    this.storedPermits = this.maxPermits;
  }

  // 判斷當前請求是否能經過
  canPass(node, acquireCount) {
    const currentQps = node.passQps();
    this.resync(node.previousPassQps());
    let cost;
    if (this.storedPermits > this.thresholdPermits) {
      // 處於預熱期的令牌數
      const warmUpPermits = this.storedPermits - this.thresholdPermits;

      if (acquireCount < warmUpPermits) {
        cost = this.slope * acquireCount;
      } else {
        cost = this.slope * warmUpPermits + (1 / this.count) * (acquireCount - warmUpPermits);
      }
      if (currentQps + acquireCount < 1 / cost) {
        return true;
      }
    } else if (currentQps + acquireCount < this.count) {
      return true;
    }

    return false;
  }

  resync(passQps) {
    let currentTime = Date.now();
    currentTime = currentTime - currentTime % 1000;
    const oldLastFillTime = this.lastFilledTime;
    if (currentTime <= oldLastFillTime) {
      return;
    }
    this.storedPermits = this.coolDownTokens(currentTime, passQps);

    const currentValue = this.storedPermits - passQps;
    this.storedPermits = Math.max(currentValue, 0);
    this.lastFilledTime = currentTime;
  }

  coolDownTokens(currentTime, passQps) {
    const oldValue = this.storedPermits;
    let newValue = oldValue;

    // 添加令牌的判斷前提條件:
    // 當令牌的消耗程度遠遠低於警惕線的時候
    if (oldValue < this.thresholdPermits) {
      newValue = (oldValue + (currentTime - this.lastFilledTime) * this.count / 1000);
    } else if (oldValue > this.thresholdPermits) {
      if (passQps < (this.count / this.coldFactor)) {
        newValue = (oldValue + (currentTime - this.lastFilledTime) * this.count / 1000);
      }
    }
    return Math.min(newValue, this.maxPermits);
  }

}複製代碼

小結

能夠看到,「預熱桶」的核心思想是在系統提供的預熱時間內讓閾值 QPS 線性增加,最終達到穩按期的閾值 QPS,提及來比較簡單,但實現起來仍是有些複雜;本文從「預熱桶」的使用場景到原理分析再到代碼實現,比較全面的講解了「預熱桶」算法,但願對想要了解該算法的同窗有些幫助;若是有同窗發現文中有什麼不對的地方也歡迎指正、互相交流一下。

相關文章
相關標籤/搜索