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

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

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

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

1、计数器

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 的时候即达到了限流效果。数据结构

2、滑动窗口计数器

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 的数据结构会愈来愈大。dom

3、漏斗

1. 设计思路

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

2. 实现方式

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

这种方案因为出口速率是固定的,因此并无办法应对短期的突发流量。

4、令牌桶

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());
}

5、限流进阶

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

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

在真实应用场景,能够采用 redis + lua 的方式,经过把逻辑放在 redis 端,来减小调用次数。

lua 的逻辑以下:

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

文章内容收集于网络。

相关文章
相关标签/搜索