likes
comments
collection
share

服务限流策略、实现方式及Guava Limit的实现

作者站长头像
站长
· 阅读数 20

什么是服务限流?

随着微服务、分布式系统的发展,各个服务之间的相互调用越来越复杂。 为了保证自身服务的稳定性与高可用,当面对超过自身服务能力的请求调用时,要做一定的限流措施。

如同五一、国庆期间的旅游出行、景区爆满,游客限流。我们的服务面对诸如秒杀、大促、618、双十一以及可能的恶意攻击、爬虫等高并发、大流量的场景也需要做服务限流。

限流的策略

限流的目的是通过对并发访问进行限速,相关的策略一般是,一旦达到限制的速率,那么就会触发相应的限流行为。一般来说,触发的限流行为如下。

  • 拒绝服务。把多出来的请求拒绝掉。系统在受到流量暴增时,会统计当前哪个客户端来的请求最多,直接拒掉这个客户端,这种行为可以把一些不正常的或者是带有恶意的高并发访问挡在门外。
  • 服务降级。关闭或是把后端服务做降级处理。这样可以让服务有足够的资源来处理更多的请求。降级有很多方式,一种是把一些不重要的服务给停掉,把 CPU、内存或是数据的资源让给更重要的功能;一种是不再返回全量数据,只返回部分数据。

限流的实现方式

计数器方式

最简单的限流算法就是维护一个计数器 Counter,当一个请求来时,就做加一操作,当一个请求处理完后就做减一操作。如果这个 Counter 大于某个数了(我们设定的限流阈值),那么就开始拒绝请求以保护系统的负载了。这个算法足够得简单粗暴。

队列算法

在这个算法下,请求的速度可以是波动的,而处理的速度则是非常均速的。这个算法其实有点像一个 FIFO 的算法。

服务限流策略、实现方式及Guava Limit的实现

在上面这个 FIFO 的队列上,我们可以扩展出一些别的玩法。

一个是有优先级的队列,处理时先处理高优先级的队列,然后再处理低优先级的队列。 如下图所示,只有高优先级的队列被处理完成后,才会处理低优先级的队列。

服务限流策略、实现方式及Guava Limit的实现

有优先级的队列可能会导致低优先级队列长时间得不到处理。为了避免低优先级的队列被饿死,一般来说是分配不同比例的处理时间到不同的队列上,于是我们有了带权重的队列。如下图所示。有三个队列的权重分布是 3:2:1,这意味着我们需要在权重为 3 的这个队列上处理 3 个请求后,再去权重为 2 的队列上处理 2 个请求,最后再去权重为 1 的队列上处理 1 个请求,如此反复。

服务限流策略、实现方式及Guava Limit的实现

队列流控是以队列的方式来处理请求。如果处理过慢,那么就会导致队列满,而开始触发限流。

但是,这样的算法需要用队列长度来控制流量,在配置上比较难操作。如果队列过长,导致后端服务在队列没有满时就挂掉了。一般来说,这样的模型不能做 push,而是 pull 方式会好一些。

漏斗算法

Leaky Bucket漏斗算法可以参考 Wikipedia 的相关词条 Leaky Bucket。下图是一个漏斗算法的示意图 。

服务限流策略、实现方式及Guava Limit的实现

我们可以看到,就像一个漏斗一样,进来的水量就好像访问流量一样,而出去的水量就像是我们的系统处理请求一样。当访问流量过大时这个漏斗中就会积水,如果水太多了就会溢出。一般来说,这个“漏斗”是用一个队列来实现的,当请求过多时,队列就会开始积压请求,如果队列满了,就会开始拒绝请求。

服务限流策略、实现方式及Guava Limit的实现

我们可以看到,漏斗算法其实就是在队列请求中加上一个限流器,来让 Processor 以一个均匀的速度处理请求。

令牌桶算法 Token Bucket

关于令牌桶算法,主要是有一个中间人。在一个桶内按照一定的速率放入一些 token,然后,处理程序要处理请求时,需要拿到 token,才能处理;如果拿不到,则不处理。下面这个图很清楚地说明了这个算法。

服务限流策略、实现方式及Guava Limit的实现

从理论上来说,令牌桶的算法和漏斗算法不一样的是,漏斗算法中,处理请求是以一个常量和恒定的速度处理的,而令牌桶算法则是在流量小的时候“攒钱”,流量大的时候,可以快速处理。

然而,我们可能会问,Processor 的处理速度因为有队列的存在,所以其总是能以最大处理能力来处理请求,这也是我们所希望的方式。因此,令牌桶和漏斗都是受制于 Processor 的最大处理能力。无论令牌桶里有多少令牌,也无论队列中还有多少请求。总之,Processor 在大流量来临时总是按照自己最大的处理能力来处理的。

但是,试想一下,如果我们的 Processor 只是一个非常简单的任务分配器,比如像 Nginx 这样的基本没有什么业务逻辑的网关,那么它的处理速度一定很快,不会有什么瓶颈,而其用来把请求转发给后端服务,那么在这种情况下,这两个算法就有不一样的情况了。

漏斗算法会以一个稳定的速度转发,而令牌桶算法平时流量不大时在“攒钱”,流量大时,可以一次发出队列里有的请求,而后就受到令牌桶的流控限制。

另外,令牌桶还可能做成第三方的一个服务,这样可以在分布式的系统中对全局进行流控,这也是一个很好的方式。

限流的设计要点

限流主要是有四个目的。

  • 为了向用户承诺 SLA。我们保证我们的系统在某个速度下的响应时间以及可用性。
  • 同时,也可以用来阻止在多租户的情况下,某一用户把资源耗尽而让所有的用户都无法访问的问题。
  • 为了应对突发的流量。
  • 节约成本。我们不会为了一个不常见的尖峰来把我们的系统扩容到最大的尺寸。而是在有限的资源下能够承受比较高的流量。

在设计上,我们还要有以下的考量。

  • 限流应该是在架构的早期考虑。当架构形成后,限流不是很容易加入。
  • 限流模块性能必须好,而且对流量的变化也是非常灵敏的,否则太过迟钝的限流,系统早因为过载而挂掉了。
  • 限流应该有个手动的开关,这样在应急的时候,可以手动操作。
  • 当限流发生时,应该有个监控事件通知。让我们知道有限流事件发生,这样,运维人员可以及时跟进。而且还可以自动化触发扩容或降级,以缓解系统压力。
  • 当限流发生时,对于拒掉的请求,我们应该返回一个特定的限流错误码。这样,可以和其它错误区分开来。而客户端看到限流,可以调整发送速度,或是走重试机制。
  • 限流应该让后端的服务感知到。限流发生时,我们应该在协议头中塞进一个标识,比如 HTTP Header 中,放入一个限流的级别,告诉后端服务目前正在限流中。这样,后端服务可以根据这个标识决定是否做降级。

Guava Limit限流的实现

Guava限流器介绍

谷歌Guava工具包中的限流器在当今业界内被广泛使用,它的设计与实现非常经典,开源社区中间件Resilience4jSentinel在限流模块的实现上都有参考Guava限流器。下面我们看下Guava限流器的算法原理以及实现过程。

Guava的 RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。

服务限流策略、实现方式及Guava Limit的实现

Guava限流器的使用非常简单。例如我们需要将一个队列中的任务以不超过每秒两个的速度提交执行,那么我们可以通过下面中的代码来实现这个需求。

// rate = 2 permits per second
final RateLimiter rateLimiter = RateLimiter.create(2.0); 

void submitTasks(List<Runnable> tasks, Executor executor) {
    for (Runnable task : tasks) {
        rateLimiter.acquire(); // may wait
        executor.execute(task);
    }
}

// rate = 5000 permits per second
final RateLimiter rateLimiter = RateLimiter.create(5000.0); 

void submitPacket(byte[] packet) {
    rateLimiter.acquire(packet.length);
    networkService.send(packet);
}

下面这段代码是 Guava RateLimiter 类的核心逻辑,用于实现限流的令牌桶算法

final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
    //1.计算令牌桶中的令牌数量
    resync(nowMicros);
    
    //2.发放令牌
    long returnValue = nextFreeTicketMicros;
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
    double freshPermits = requiredPermits - storedPermitsToSpend;
    //预计等待时间 = 桶内令牌发放时间 + 后续生成令牌所需时间
    long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend) + (long) (freshPermits * stableIntervalMicros);
    this.nextFreeTicketMicros = nextFreeTicketMicros + waitMicros;
    this.storedPermits -= storedPermitsToSpend;
    return returnValue;
}

private void resync(long nowMicros) {
    if (nowMicros > nextFreeTicketMicros) {
      //桶内令牌数 = min(桶容量, 剩余令牌数 + 空闲时间内生成的令牌数)
      storedPermits = min(maxPermits, storedPermits + (nowMicros - nextFreeTicketMicros) / stableIntervalMicros);
      nextFreeTicketMicros = nowMicros;
    }
}

阻塞请求时间的计算

在上面令牌桶算法的实现代码中,请求阻塞时间(waitMicros)的计算除了包含了生成新令牌所需时间(freshPermits)之外,还包含了以storedPermitsToWaitTime方法计算的发放桶内令牌(storedPermitsToSpend)的时间。为什么发放桶内已有令牌还需要计算耗时呢?

实际上,桶内令牌发放时间的计算是由限流器的模式决定的。上文中有提过,Guava限流器包含突发和预热两种模式,这两种模式分别建立了不同的桶内令牌发放模型,并基于各自的模型来计算方法耗时。下面将详细介绍这两种令牌发放模型。

突发模式

在突发模式中,Guava限流器的桶中令牌是有一个有效期的,有效期的作用是让限流器具有一定的“弹性”,可以根据空闲情况临时超额放行一些请求用于平滑处理突发流量。

因此,突发模式下的桶内令牌发放模型非常简单,可以直接发放,不需要计算开销。实现代码如下所示。

static final class SmoothBursty extends SmoothRateLimiter {    
    @Overridelong storedPermitsToWaitTime(double storedPermits, double permitsToTake) { 
        return 0L;
    }
}`

预热模式

这段代码实现了 SmoothWarmingUp 类,使用了预热模型来在限流期内逐步增加访问速率。在 storedPermitsToWaitTime 方法中,使用了积分的方式计算预热期和稳定期内的发放令牌时间,以实现合适的限流逻辑。在 permitsToTime 方法中,通过计算预热斜率来计算许可对应的时间。

static final class SmoothWarmingUp extends SmoothRateLimiter {
  private final long warmupPeriodMicros;
  private double slope;       //预热模型中,梯形斜边的斜率
  private double halfPermits; // 预热期内的一半许可数

  @Override
  long storedPermitsToWaitTime(double storedPermits, double permitsToTake) {
    double availablePermitsAboveHalf = storedPermits - halfPermits;

    long micros = 0;

    // measuring the integral on the right part of the function (the climbing line)
    // 预热期发放令牌的时间,即梯形的面积
    if (availablePermitsAboveHalf > 0.0) {
      double permitsAboveHalfToTake = min(availablePermitsAboveHalf, permitsToTake);
      micros = (long) (permitsAboveHalfToTake * (permitsToTime(availablePermitsAboveHalf)
          + permitsToTime(availablePermitsAboveHalf - permitsAboveHalfToTake)) / 2.0);
      permitsToTake -= permitsAboveHalfToTake;
    }

    // measuring the integral on the left part of the function (the horizontal line)
    // 稳定期发放令牌的时间,即矩形的面积
    micros += (stableIntervalMicros * permitsToTake);
    return micros;
  }

  private double permitsToTime(double permits) {
      return stableIntervalMicros + permits * slope;
  }
}