【限流算法】常见的限流算法及其实现方式

在高并发的分布式系统,如大型电商系统中,由于接口 API 无法控制上游调用方的行为,因此当瞬间请求量突增时,会导致服务器占用过多资源,发生响应速度降低、超时乃至宕机,甚至引发雪崩造成整个系统不可用。

面对这种情况,一方面我们会提升 API 的吞吐量和 QPS(Query Per Second 每秒查询量),但总归会有上限,所以另一方面为了应对巨大流量的瞬间提交,我们需要做对应的限流处理,也就是对请求量进行限制,对于超出限制部分的请求作出快速拒绝、快速失败、丢弃处理,以保证本服务以及下游资源系统的稳定。

常见的限流算法有计数器、漏斗、令牌桶。

一、计数器

1. 设计思路

计数器限流方式比较粗暴,一次访问就增加一次计数,在系统内设置每 N 秒的访问量,超过访问量的访问直接丢弃,从而实现限流访问。具体大概是以下步骤:

  1. 将时间划分为固定的窗口大小,例如 1 s;
  2. 在窗口时间段内,每来一个请求,对计数器加 1;
  3. 当计数器达到设定限制后,该窗口时间内的后续请求都将被丢弃;
  4. 该窗口时间结束后,计数器清零,从新开始计数。

这种算法的弊端是,在开始的时间,访问量被使用完后,1 s 内会有很长时间的真空期是处于接口不可用的状态的,同时也有可能在一秒内出现两倍的访问量。

  1. T窗口的前1/2时间 无流量进入,后1/2时间通过5个请求;
  2. T+1窗口的前 1/2时间 通过5个请求,后1/2时间因达到限制丢弃请求。
  3. 因此在 T的后1/2和(T+1)的前1/2时间组成的完整窗口内,通过了10个请求。

2. 实现方式

实现方式和扩展方式很多,这里以 Redis 举例简单的实现,计数器主要思路就是在单位时间内,有且仅有 N 数量的请求能够访问我的代码程序。所以可以利用 Redis 的 setnx来实现这方面的功能。

比如现在需要在 10 秒内限定 20 个请求,那么可以在 setnx 的时候设置过期时间 10,当请求的 setnx 数量达到 20 的时候即达到了限流效果。

二、滑动窗口计数器

1. 设计思路

滑动窗口计数法的思路是:

  1. 将时间划分为细粒度的区间,每个区间维持一个计数器,每进入一个请求则将计数器加一;
  2. 多个区间组成一个时间窗口,每流逝一个区间时间后,则抛弃最老的一个区间,纳入新区间。如图中示例的窗口 T1 变为窗口 T2;
  3. 若当前窗口的区间计数器总和超过设定的限制数量,则本窗口内的后续请求都被丢弃。

2. 实现方式

利用 Redis 的 list 数据结构可以轻而易举地实现该功能。我们可以将请求打造成一个 zset 数组,当每一次请求进来的时候,key 保持唯一,value 可以用 UUID 生成,而 score 可以用当前时间戳表示,因为 score 我们可以用来计算当前时间戳之内有多少的请求数量。而 zset 数据结构也提供了 range 方法让我们可以很轻易地获取到两个时间戳内有多少请求。

public Response limitFlow() {
    Long  currentTime = new Date().getTime();
    if (redisTemplate.hasKey("limit")) {
        Integer count = redisTemplate.opsForZset().rangeByScore("limit", currentTime - intervalTime, currentTime).size();
        if (count != null && count > 5) {
            return Response.ok("每分钟最多只能访问 5 次!");
        }
    }
    redisTemplate.opsForZSet().add("limit", UUID.randomUUID().toString(), currentTime);
    return Response.ok("访问成功");
}

通过上述代码可以做到滑动窗口的效果,并且能保证每 N 秒内至多 M 个请求,实现方式相对来说也是比较简单的,但是所带来的缺点就是 zset 的数据结构会越来越大。

三、漏斗

1. 设计思路

在计数器算法中我们看到,当使用了所有的访问量后,接口会完全处于不可用状态,有些系统不能接受这样的处理方式,对此可以使用漏斗算法进行限流,漏斗算法的原理就像名字,访问量从漏斗的大口进入,从漏斗的小口进入系统。这样不管是多大的访问量进入漏斗,最后进入系统的访问量都是固定的。漏斗的好处就是,大批量访问进入时,漏斗有容量,不超过容量(容量的设计=固定处理的访问量 * 可接受等待时长)的数据都可以排队等待处理,超过的才会丢弃。

2. 实现方式

实现方式可以使用队列,队列设置容量,访问可以大批量塞入队列,满队列后丢弃后续访问量。队列的出口以固定速率拿去访问量处理。

这种方案由于出口速率是固定的,所以并没有办法应对短时间的突发流量。

四、令牌桶

1. 设计思路

令牌桶算法是漏斗算法的改进版,为了处理短时间的突发流量而做了优化,令牌桶算法主要由三部分组成:令牌流数据流令牌桶

名词释义:

  • 令牌桶:流通令牌的管道,用于生成的令牌的流通,放入令牌桶中。
  • 数据流:进入系统的数据流量。
  • 令牌桶:保存令牌的区域,可以理解为一个缓冲区,令牌保存在这里用于使用。

令牌桶会按照一定的速率生成令牌放入令牌桶,访问要进入系统时,需要从令牌桶中获取令牌,有令牌的可以进入,没有的被抛弃,由于令牌桶的令牌是源源不断生成的,当访问量小时,可以留存令牌达到令牌桶的上限,这样当短时间的突发访问量时,积累的令牌数可以处理这个问题。当访问量持续大量流入时,由于生成令牌的速率是固定的,最后也就变成了类似漏斗算法的固定流量处理。

2. 实现方式

实现方式和漏斗也比较类似,可以使用一个队列保存令牌,一个定时任务用等速率生成令牌放入队列,访问量进入系统时,从队列获取令牌再进入系统。

google 开源的 guava 包中的 RateLimiter 类实现了令牌桶算法,不同其实现方式是单机的,集群可以按照上面的实现方式,队列使用中间件 MQ 实现,配合负载均衡算法,考虑集群各个服务器的承压情况做对应服务器的队列是较好的做法。

这里简单用 Redis 以及定时任务模拟大概的过程:

首先依靠 List 的 leftPop 来获取令牌:

// 输出令牌
public Response limitFlow() {
    Object result = redisTemplate.opsForList().leftPop("limit_list");
    if (result == null) {
        return Response.ok("当前令牌桶中无令牌!");
    }
    return Response.ok("访问成功!");
}

再依靠 Java 的定时任务,定时往 List 中 rightPush 令牌,当然令牌也需要保证唯一性,所以这里利用 UUID 生成:

// 10S的速率往令牌桶中添加UUID,只为保证唯一性
@Scheduled(fixedDelay = 10_000,initialDelay = 0)
public void setIntervalTimeTask(){
    redisTemplate.opsForList().rightPush("limit_list",UUID.randomUUID().toString());
}

五、限流进阶

单点应用下,对应用进行限流,既能满足本服务的需求,又可以很好地保护好下游资源。在选型上,可以采用上面提及的 Google Guava 的 RateLimiter。

而在多机部署的场景下,对单点的限流,并不能达到我们想要的最好效果,需要引入分布式限流。分布式限流的算法,依然可以采用令牌桶算法,只不过将令牌桶的发放、存储改为全局的模式。

在真实应用场景,可以采用 redis + lua 的方式,通过把逻辑放在 redis 端,来减少调用次数。

lua 的逻辑如下:

  1. redis 中存储剩余令牌的数量 cur_token,和上次获取令牌的时间 last_time;
  2. 在每次申请令牌时,可以根据(当前时间 cur_time - last_time) 的时间差乘以令牌发放速率,算出当前可用令牌数;
  3. 如果有剩余令牌,则准许请求通过,否则不通过。

文章内容收集于网络。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章