如何实现高并发下系统的限流

限流是在高并发大流量的场景下经常提到的一个问题,那么为什么要做限流操作呢?假设有如下的服务之间的调用关系:

假设现在“服务8”的由于调用量大,导致服务的响应慢甚至崩溃了,那么就可能出现一系列的雪崩效应,如下:

到了T2时刻之后,整个应用都不可以使用了。为了防止服务的雪崩效应的出现,对系统增加限流保护是必不可少的。

此时在网关层和服务层都添加了限流操作,在网关层做第一层的限流来阻挡一部分流量,在服务层添加限流是因为防止在极端的情况下,网关的请求都转发到某个服务上,导致某个服务由于流量大而崩溃,如下图:

所以限流是必要的, 限流的目的是对请求进行降级(常见的降级策略包括延迟处理、拒绝服务等)达到保护系统不被流量冲垮。那么是否可以通过某种算法做到,当外部请求接近或者达到系统的最大阈值时,系统触发限流保护呢?下面介绍几种常见的限流算法:

1、计数器法

核心思想:统计每个时间窗口内请求的数量,当请求数超过设定的阈值时触发限流。本算法虽然比较简单,但是存在一个致命的问题,如下:

在0.9秒的时刻来了3个请求,在0-1秒这个时间段中无请求;

在1.1秒的时刻来了3个请求,在1-2秒这个时间段中无请求。

在0-1秒这个窗口满足只有3个请求的要求,同样在1-2秒这个窗口中也是满足只有3个请求的要求。但是0.9-1.1秒作为一个窗口来分析的时候,发现请求就有6个,此时已经超过系统规定的阈值。

2、滑动时间窗口

由于计数器法存在致命的一个缺陷,所以在其基础上做一些改进,使用滑动时间窗口来代替固定的时间窗口。这样有效地避免了固定时间窗口的问题从而达到限流的目的。

3、漏桶算法

核心思想:使用一个固定大小的桶用来存放请求,请求如果超过桶容量,则丢弃请求,并且约定固定的速度从桶中取出请求来处理。

使用固定的速率处理请求可以很好的保护下游系统不会被大流量冲垮,但是对于突发的大批量请求处理场景来说,由于处理的线程数量是固定,即使现在下游系统可以承受的住更多的压力,依然会被限流了。

4、令牌桶算法

核心思想:指定一个固定的大小的桶,隔一段时间向桶中存放若干个令牌,请求访问服务的时候必须从通过拿到令牌才可以请求服务器,如果没有拿到令牌则拒绝访问服务。令牌桶算法应对突发的大批流量可以很好的适应,如下图所示:

如果现在有20个请求,我们放20个令牌,请求的并发量取决于桶中的令牌量,这样可以很好地应对突发流量。令牌桶算法可以使本系统性能发挥到最大,但是对下游系统就不友好了,因为下游系统可能瞬间承受不了大的流量而崩溃。

5、Redis落地限流算法

5.1 使用Zset数据实现滑动时间窗口

使用Zset的score作为时间戳,然后滑动时间窗口的范围[当前的时间-窗口大小,当前时间] 来获取满足条件的score的数据,判断这个数据的量是否达到阈值,如果到了就限流,未到阈值放行。

核心的代码:

#创建Zset队列 
ZSet<String> zset = redisTemplate.opsForZSet().create("userLimiter"); 
#添加用户请求 
long currentTimeMillis = System.currentTimeMillis(); 
String userId = "12345789"; 
redisTemplate.opsForZSet().add("userLimiter", userId, currentTimeMillis); 
#监测是否达到阈值 
int limitCount = 100;  
long size = redisTemplate.opsForZSet().size("userLimiter"); 
if (size >= limitCount) { 
    // 超过限制,拒绝请求 

} else { 
    //放行请求  执行业务逻辑 

}

5.2 使用List数据结构实现漏桶算法

设置一个固定大小的List集合,当请求没有超过List集合的容量时候,记录请求并允许请求执行相关的业务逻辑,如果请求到了List容量的阈值,那么拒绝请求执行业务逻辑。

5.3 使用Hash数据结构实现令牌桶算法

开启一个定时任务,设置一个执行的频率来向hash中添加固定个数的令牌,然后用户的请求下到redis的hash中获取令牌,如果获取成功就执行业务逻辑,如果获取失败就执行降级的逻辑。

总结:

(1)限流常见的算法有计数器法、滑动时间窗口法、漏桶算法、令牌桶算法,每种算法都有自己的实际应用场景。

(2)漏桶算法适用于需要平滑处理突发的大批请求并限制请求处理速率的场景;令牌桶算法适用于需要精确控制请求频率的场景。

(3)通过Redis的提供的数据结构,我们可以是将几种算法落地并实现限流功能。

1