「预热桶」限流算法详解(附 Node.js 实现)

avatar
大前端 @阿里巴巴
文/金禅
「预热桶」是我自己取的名字,它来源于 Google 的 Guava 工具包里的 SmoothWarmingUp 类,表示带预热的令牌桶算法。

限流是在高并发场景下,保证系统稳定性的一把利器,在之前的文章中我介绍了集中基础的限流算法,本文重点介绍一个更高级的限流算法——『预热桶算法』的原理和实现;

「预热桶」的由来

在使用「限流器」的时候,我们需要给设置一个合适的 阈值 ,这个阈值通常是一个系统能够正常工作所能承受的最大 QPS 。当系统的请求量达到阈值时,不同的限流器主要有两种处理方式,一种是对超过阈值的请求直接返回失败,另一种是让超过阈值的请求排队等待。

要控制系统的 QPS 不超过阈值最简单的方式就是控制每个请求的间隔;例如,如果一个系统的 QPS 阈值是 100,那我们只要保证每个请求的间隔不要低于 1(s)/100 = 10ms 就可以了;每个请求进来时,限流器会计算出当前时间与上一个请求的间隔(interval),如果大于interval >= 10ms 则表示当前 QPS 小于 100 直接放行,如果 Interval < 10ms,例如 Interval = 9ms 时,则需要让这个请求等待 1ms 再执行。

我们发现,上面这种限流器对系统状态的记录是非常粗略的,它只是记录了上次请求的时间。这种方式对请求的控制粒度太细了,没有 buffer,其实我们希望的粒度是 QPS;试想一下如果现在有两个请求相隔 1ms 发送过来了,系统也一段时间内也只接收到这两个请求,远低于阈值定义的 100,但是按照上面的算法,晚到的请求要等待 9ms 后再执行,这显然是不合理的。

一个系统的利用率低说明这个系统有多余的资源可被利用。这样的话,「限流器」应该适当提速一会儿,让这些资源能够被好好利用起来。

然而从另一个角度来看,系统利用率一直处于低下状态也有可能意味着系统还没准备好应对更多的请求,因为长时间处于低利用率下,系统由于所依赖资源的限制,并不能立马达到它正常的服务水平;例如系统依赖的缓存过期导致新的请求会直接请求 db,再比如很多系统使用了连接池,长时间的 idle 情况下连接池只会保持少量的连接,新的请求会重新创建连接,这些都是耗时操作,完成这些操作之后,系统才能到达正常的服务水平;

通过上面的情况可以看出,只给限流器设置一个系统正常情况下能够处理请求的 QPS 阈值是不够的,系统在预热阶段就算是低于阈值的请求量进来也可能会把系统压垮,所以我们需要一个能够应对系统预热期的限流算法,这就是「预热桶」算法的由来。

「预热桶」算法原理

我们先重温一下令牌桶算法的原理:

  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,说起来比较简单,但实现起来还是有些复杂;本文从「预热桶」的使用场景到原理分析再到代码实现,比较全面的讲解了「预热桶」算法,希望对想要了解该算法的同学有些帮助;如果有同学发现文中有什么不对的地方也欢迎指正、互相交流一下。