/ 闲敲棋子 / 限流

限流

2019-06-24 posted in [学习笔记]

限流

限流可以认为服务降级的一种,限流就是限制系统的输入和输出流量已达到保护系统的目的。一般来说系统的吞吐量是可以被测算的,为了保证系统的稳定运行,一旦达到的需要限制的阈值,就需要限制流量并采取一些措施以完成限制流量的目的。比如:延迟处理,拒绝处理,或者部分拒绝处理等等。

集群限流保证下游服务,单机限流,保护自己的服务不在极端情况被打呲。常用的限流算法是令牌桶和漏桶,另外有时候我们还使用计数器来进行限流,主要用来限制总并发数,比如数据库连接池、线程池、秒杀的并发数。

计数器

计数器是最简单的一种限流算法,比如我们规定1分钟内请求数量不超过100个。那么可以在一开始的时候设置一个计数器,每个请求过来时计数器+1,如果计数器的值大于100,并且与第一个请求时间间隔在1分钟内,就限流。如果时间间隔大于1分钟,且计数器的值还在限流范围内,就重置计数器。

这个算法有临界值问题。比如一个用户在一分钟的59秒发送了了100个请求,然后又在第二分钟的第一秒发送了100个请求,由于计数器重置,这2秒内处理了200个请求。实际是超出了限流的阈值的,容易在瞬间压垮我们的应用。此外这种限流方式不平滑,在大流量情况下,在一分钟开始的第一秒就打满了100个请求,后续请求都会被拒绝掉,然后第二分钟又进来100个请求,就会呈现阶梯状流量。

出现这种问题的原因是精度不够引起的,可以通过滑动窗口算法解决这个问题。

滑动窗口

image

在上图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分,比如图中,我们就将滑动窗口划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。每一个格子都有自己独立的计数器,比如当一个请求在0:35秒的时候到达,那么0:30~0:39对应的计数器就会加1。

那么滑动窗口怎么解决刚才的临界问题的呢?我们可以看上图,0:59到达的100个请求会落在灰色的格子中,而1:00到达的请求会落在橘黄色的格子中。当时间到达1:00时,我们的窗口会往右移动一格,那么此时时间窗口内的总请求数量一共是200个,超过了限定的100个,所以此时能够检测出来触发了限流。

再来回顾一下刚才的计数器算法,可以发现,计数器算法其实就是滑动窗口算法。只是它没有对时间窗口做进一步地划分,所以只有1格。

由此可见,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。

计数器 VS 滑动窗口

计数器算法是最简单的算法,可以看成是滑动窗口的低精度实现。滑动窗口由于需要存储多份的计数器(每一个格子存一份),所以滑动窗口在实现上需要更多的存储空间。也就是说,如果滑动窗口的精度越高,需要的存储空间就越大。

令牌桶(Token Bucket)

image

令牌桶算法是一个存放固定容量令牌的桶,按照固定速率往桶里添加令牌。令牌桶算法的描述如下:

漏桶(Leaky Bucket)

image

漏桶有两种实现,一种是 as a meter,另一种是 as a queue。

As a meter

第一种实现是和令牌桶等价的,只是表述角度不同。

漏桶作为计量工具(The Leaky Bucket Algorithm as a Meter)时,可以用于流量整形(Traffic Shaping)和流量控制(TrafficPolicing),漏桶算法的描述如下:

As a queue

第二种实现是用一个队列实现,当请求到来时如果队列没满则加入到队列中,否则拒绝掉新的请求。同时会以恒定的速率从队列中取出请求执行。

image

对于该种算法,固定的限定了请求的速度,不允许流量突发的情况。

比如初始时桶是空的,这时1ms内来了100个请求,那只有前10个会被接受,其他的会被拒绝掉。

不过,当桶的大小等于每个ticket流出的水大小时,第二种漏桶算法和第一种漏桶算法是等价的。也就是说,as a queue是as a meter的一种特殊实现。

令牌桶 VS 漏桶

Guava的RateLimiter进行限流

Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法实现流量限制,使用十分方便。

RateLimiter有两种限流模式,一种为稳定模式(SmoothBursty 令牌生成速度恒定),一种为渐进模式(SmoothWarmingUp:令牌生成速度缓慢提升直到维持在一个稳定值)。

这里有段注释:

/**
  * Last, but not least: consider a RateLimiter with rate of 1 permit per second, currently
  * completely unused, and an expensive acquire(100) request comes. It would be nonsensical
  * to just wait for 100 seconds, and /then/ start the actual task. Why wait without doing
  * anything? A much better approach is to /allow/ the request right away (as if it was an
  * acquire(1) request instead), and postpone /subsequent/ requests as needed. In this version,
  * we allow starting the task immediately, and postpone by 100 seconds future requests,
  * thus we allow for work to get done in the meantime instead of waiting idly.
**/

以上,表明这个版本的RateLimiter会预消费后续的流量额度。比如调用acquire(100),而限制的qps是10,在未被限流情况下,RateLimiter会通过这个acquire(100),而不是被阻塞。之后的10秒内的acquire()才会被阻塞住。

SmoothBursty中几个属性的含义

/**
 * The currently stored permits.
 * 当前存储令牌数
 */
double storedPermits;
 
/**
 * The maximum number of stored permits.
 * 最大存储令牌数
 */
double maxPermits;
 
/**
 * The interval between two unit requests, at our stable rate. E.g., a stable rate of 5 permits
 * per second has a stable interval of 200ms.
 * 添加令牌时间间隔
 */
double stableIntervalMicros;
 
/**
 * The time when the next request (no matter its size) will be granted. After granting a request,
 * this is pushed further in the future. Large requests push this further than small requests.
 * 下一次请求可以获取令牌的起始时间
 * 由于RateLimiter允许预消费,上次请求预消费令牌后
 * 下次请求需要等待相应的时间到nextFreeTicketMicros时刻才可以获取令牌
 */
private long nextFreeTicketMicros = 0L; // could be either in the past or future

根据令牌桶算法,桶中的令牌是持续生成存放的,有请求时需要先从桶中拿到令牌才能开始执行,谁来持续生成令牌存放呢?

SmoothBursty采用的是触发式添加令牌的方式,实现方法为resync(long nowMicros)

/**
 * Updates {@code storedPermits} and {@code nextFreeTicketMicros} based on the current time.
 */
void resync(long nowMicros) {
    // if nextFreeTicket is in the past, resync to now
    if (nowMicros > nextFreeTicketMicros) {
      double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
      storedPermits = min(maxPermits, storedPermits + newPermits);
      nextFreeTicketMicros = nowMicros;
    }
}

该函数会在每次获取令牌之前调用,其实现思路为,若当前时间晚于nextFreeTicketMicros,则计算该段时间内可以生成多少令牌,将生成的令牌加入令牌桶中并更新数据。这样一来,只需要在获取令牌时计算一次即可。

final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
    resync(nowMicros);
    long returnValue = nextFreeTicketMicros; // 返回的是上次计算的nextFreeTicketMicros
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits); // 可以消费的令牌数
    double freshPermits = requiredPermits - storedPermitsToSpend; // 还需要的令牌数
    long waitMicros =
          storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
              + (long) (freshPermits * stableIntervalMicros); // 根据freshPermits计算需要等待的时间
	 
    this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros); // 本次计算的nextFreeTicketMicros不返回
    this.storedPermits -= storedPermitsToSpend;
    return returnValue;
}

该函数用于获取requiredPermits个令牌,并返回需要等待到的时间点。其中,storedPermitsToSpend为桶中可以消费的令牌数,freshPermits为还需要的(需要补充的)令牌数,根据该值计算需要等待的时间,追加并更新到nextFreeTicketMicros。

需要注意的是,该函数的返回是更新前的(上次请求计算的)nextFreeTicketMicros,而不是本次更新的nextFreeTicketMicros,也就是说,本次请求需要为上次请求的预消费行为埋单,这也是RateLimiter可以预消费(处理突发)的原理所在。若需要禁止预消费,则修改此处返回更新后的nextFreeTicketMicros值。

分布式限流

redis 4.0中提供了redis-cell模块(需安装),基于令牌桶算法实现。

官方wiki:https://github.com/brandur/redis-cell

命令:CL.THROTTLE

CL.THROTTLE user123 15 30 60 3

以上命令表示从一个初始值为15的令牌桶中取3个令牌,该令牌桶的速率限制为30次/60秒。

127.0.0.1:6379> CL.THROTTLE user123 15 30 60
1) (integer) 0
2) (integer) 16
3) (integer) 15
4) (integer) -1
5) (integer) 2

返回值含义:

  1. 是否成功,0:成功,1:拒绝
  2. 令牌桶的容量,大小为初始值+1
  3. 当前令牌桶中可用的令牌
  4. 若请求被拒绝,这个值表示多久后才令牌桶中会重新添加令牌,单位:秒,可以作为重试时间
  5. 表示多久后令牌桶中的令牌会存满