稳定性之限流

324 阅读7分钟

一、限流简介

为什么限流

一个系统的处理能力是有上限的,当服务请求量超过处理能力,通常会引起排队,造成响应时间迅速提升。为了保证系统在遭遇突发流量时,能够正常运行,需要为你的服务加上限流。

什么是限流

对请求或并发数进行限制

  • 通过对一个时间窗口内的请求量进行限制来保障系统的正常运行。
  • 如果服务资源有限、处理能力有限,需要对调用服务的上游请求进行限制,以防止自身服务由于资源耗尽而停止服务。

在限流中有两个概念:

  • 阈值:在一个单位时间内允许的请求量。如 QPS 限制为10,说明 1 秒内最多接受 10 次请求。
  • 拒绝策略:超过阈值的请求的拒绝策略,常见的拒绝策略有直接拒绝、排队等待等。

二、限流的算法

固定时间窗口算法

一种简单方便的限流算法, 又叫做计数器算法。在指定的时间周期内,累加访问的次数,达到设定的阈值时,触发限流策略。下一个时间周期进行访问时,访问次数清零。

此算法无论在单机还是分布式环境下实现都非常简单,使用redis的incr原子自增性,再结合key的过期时间,即可轻松实现,计算器算法如图所示。


算法问题

  • 统计区间太大,限流不够精确
  • 临界问题: 在第二个统计区间时没有考虑与前一个统计区间的关系与影响 第一个区间后半段 +  第二个区间前半段也是一分钟

例如:限制了 QPS 为 2,但是当遇到时间窗口的临界突变时,如 1s 中的后 500 ms 和第 2s 的前 500ms 时,虽然是加起来是 1s 时间,却可以被请求 4 次。

滑动窗口算法

滑动窗口算法是对固定窗口算法的改进,为了解决计数器算法的临界值的问题,防止遇到时间窗口临界突变的情况

滑动时间窗口是将计数器算法中的时间周期切分成多个小的时间窗口,分别在每个小的时间窗口中记录访问次数,然后根据时间将窗口往前滑动并删除过期的小时间窗口。最终只需要统计滑动窗口范围内的小时间窗口的总的请求数即可。

上图的示例中,每 500ms 滑动一次窗口,可以发现窗口滑动的间隔越短,时间窗口的临界突变问题发生的概率也就越小,不过只要有时间窗口的存在,还是有可能发生时间窗口的临界突变问题

在滑动时间窗口算法中,小窗口划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。

滑动日志算法

滑动日志算法是实现限流的另一种方法,基本逻辑就是记录下所有的请求时间点,新请求到来时先判断最近指定时间范围内的请求数量是否超过指定阈值,由此来确定是否达到限流,这种方式没有了时间窗口突变的问题,限流比较准确,但是因为要记录下每次请求的时间点,所以占用的内存较多

漏桶算法

漏桶可以用于流量整型和流量控制,算法描述如下:

  1. 一个固定容量的漏桶,会按照固定的速率流出水滴。
  2. 如果桶中无水,则不需要流出水滴。
  3. 可以以任意速率流入水滴。
  4. 如果流入的水滴超出了桶容量,则新添加的则会被丢弃。

漏桶的主要目的是来平滑流入的速率,无法处理突发请求

令牌桶算法

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

  1. 假设限制2r/s,则按照500ms的固定速率添加令牌。
  2. 桶的总容量为N,当达到总容量时,新添加的令牌则被丢弃或拒绝。
  3. 当一个n个字节大小的数据包到达,则从桶中删除n个令牌,然后处理数据包。
  4. 如果桶中的令牌不足n个,则不会删除令牌,但是数据包将会被限流。

令牌桶允许一定程度的突发请求(有令牌就可以处理)

三、限流策略

单机限流主要为了应对突发流量,全局限流主要为了给有限资源进行流量匹配。

常见的限流方式

  • 限制总并发数(数据库连接池、线程池)。
  • 限制瞬时并发数(如Nginx的limit_conn模块)。
  • 限制时间窗口的平均速率(如Guava的RateLimiter、Nginx的limit_req模块)。
  • 限制远程接口的调用速率、限制MQ的消费速率等。

应用的层面限流

接入层限流

  • 限制总并发数/连接/请求数
  • 限制接口的总并发/请求数
  • 限制接口每秒的请求数
  • 平滑限制接口的请求数

应用层限流

  • Nginx中连接数限流模块(ngx_http_limit_conn_module)
  • Nginx中漏桶算法实现的请求限流模块(ngx_http_limit_req_module)

分布式限流

  • Redis+Lua结合实现

扩展问题

令牌桶支持突发流量,漏桶不支持

令牌桶

假如桶的容量是100个,如果现在桶已经满了,并且之后隔了很长的时间都没有请求(这个时间长度是t),那么现在来了突发流量,一下子把100个令牌拿走了,然后生成令牌数是 t * createRate,因为t很大,生成令牌数也很多,所以马上又能处理大量的请求。

漏桶

假如桶的容量是100个,如果当前桶为空,并且隔了很长一段时间没有请求(这个时间长度是t),那么现在来了突发流量,一下子就有100滴水在同里面,然而他漏水的数量不是t*rate,因为有水的时候才能漏,时间段t里面没有漏过水(没办法累积)

缓存、降级、限流区别

缓存:用来增加系统吞吐量,提升访问速度提供高并发。

降级:在系统某些服务组件不可用的时候、流量暴增、资源耗尽等情况下,暂时屏蔽掉出问题的服务,继续提供降级服务,给用户尽可能的友好提示,返回兜底数据,不会影响整体业务流程,待问题解决再重新上线服务。

限流:指在使用缓存和降级无效的场景。比如当达到阈值后限制接口调用频率,访问次数,库存个数等,在出现服务不可用之前,提前把服务降级。只服务好一部分用户。

动态限流

般情况下的限流,都需要我们手动设定限流阈值,不仅繁琐,而且容易因系统的发布升级而过时。

考虑根据系统负载来动态决定是否限流,动态计算限流阈值。可以参考的系统负载参数有:Load、CPU、接口响应时间等。

参考文章:动态限流

限流实现

guava实现

<dependency>
  <groupId>com.google.guava</groupId>
  <artifactId>guava</artifactId>
  <version>28.1-jre</version>
</dependency>
LoadingCache<Long, AtomicLong> counter = CacheBuilder.newBuilder()
	.expireAfterWrite(2, TimeUnit.SECONDS)
	.build(new CacheLoader<Long, AtomicLong>() {
        @Override
        public AtomicLong load(Long secend) throws Exception {
            // TODO Auto-generated method stub
            return new AtomicLong(0);
        }
    });
counter.get(1l).incrementAndGet();

令牌桶实现

稳定模式(SmoothBursty:令牌生成速度恒定)

public static void main(String[] args) {
    // RateLimiter.create(2)每秒产生的令牌数
    RateLimiter limiter = RateLimiter.create(2);
    // limiter.acquire() 阻塞的方式获取令牌
    System.out.println(limiter.acquire());;
    try {
        Thread.sleep(2000);
    } catch (InterruptedException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    }
    System.out.println(limiter.acquire());
    System.out.println(limiter.acquire());
}

RateLimiter.create(2) 容量和突发量,令牌桶算法允许将一段时间内没有消费的令牌暂存到令牌桶中,用来突发消费。

渐进模式(SmoothWarmingUp:令牌生成速度缓慢提升直到维持在一个稳定值)

// 平滑限流,从冷启动速率(满的)到平均消费速率的时间间隔
RateLimiter limiter = RateLimiter.create(2,1000l,TimeUnit.MILLISECONDS);
System.out.println(limiter.acquire());;
try {
    Thread.sleep(2000);
} catch (InterruptedException e) {
    // TODO Auto-generated catch block
    e.printStackTrace();
}
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());

// 超时
boolean tryAcquire = limiter.tryAcquire(Duration.ofMillis(11));