写点什么

【高并发】别闹了,要实现亿级流量下的分布式限流,这些算法你必须掌握!!

作者:冰河
  • 2022 年 8 月 08 日
  • 本文字数:3352 字

    阅读完需:约 11 分钟

【高并发】别闹了,要实现亿级流量下的分布式限流,这些算法你必须掌握!!

大家好,我是冰河~~


在互联网应用中,高并发系统会面临一个重大的挑战,那就是大量流高并发访问,比如:天猫的双十一、京东 618、秒杀、抢购促销等,这些都是典型的大流量高并发场景。


注意:由于原文篇幅比较长,所以被拆分为:理论、算法、实战(HTTP 接口实战+分布式限流实战)三大部分。理论篇参见《【高并发】如何实现亿级流量下的分布式限流?这些理论你必须掌握!!

计数器

计数器法


限流算法中最简单粗暴的一种算法,例如,某一个接口 1 分钟内的请求不超过 60 次,我们可以在开始时设置一个计数器,每次请求时,这个计数器的值加 1,如果这个这个计数器的值大于 60 并且与第一次请求的时间间隔在 1 分钟之内,那么说明请求过多;如果该请求与第一次请求的时间间隔大于 1 分钟,并且该计数器的值还在限流范围内,那么重置该计数器。


使用计数器还可以用来限制一定时间内的总并发数,比如数据库连接池、线程池、秒杀的并发数;计数器限流只要一定时间内的总请求数超过设定的阀值则进行限流,是一种简单粗暴的总数量限流,而不是平均速率限流。



这个方法有一个致命问题:临界问题——当遇到恶意请求,在 0:59 时,瞬间请求 100 次,并且在 1:00 请求 100 次,那么这个用户在 1 秒内请求了 200 次,用户可以在重置节点突发请求,而瞬间超过我们设置的速率限制,用户可能通过算法漏洞击垮我们的应用。



这个问题我们可以使用滑动窗口解决。


滑动窗口



在上图中,整个红色矩形框是一个时间窗口,在我们的例子中,一个时间窗口就是 1 分钟,然后我们将时间窗口进行划分,如上图我们把滑动窗口划分为 6 格,所以每一格代表 10 秒,每超过 10 秒,我们的时间窗口就会向右滑动一格,每一格都有自己独立的计数器,例如:一个请求在 0:35 到达, 那么 0:30 到 0:39 的计数器会+1,那么滑动窗口是怎么解决临界点的问题呢?如上图,0:59 到达的 100 个请求会在灰色区域格子中,而 1:00 到达的请求会在红色格子中,窗口会向右滑动一格,那么此时间窗口内的总请求数共 200 个,超过了限定的 100,所以此时能够检测出来触发了限流。回头看看计数器算法,会发现,其实计数器算法就是窗口滑动算法,只不过计数器算法没有对时间窗口进行划分,所以是一格。


由此可见,当滑动窗口的格子划分越多,限流的统计就会越精确。

漏桶算法

算法的思路就是水(请求)先进入到漏桶里面,漏桶以恒定的速度流出,当水流的速度过大就会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。如下图所示。



漏桶算法不支持突发流量。

令牌桶算法


从上图中可以看出,令牌算法有点复杂,桶里存放着令牌 token。桶一开始是空的,token 以固定的速率 r 往桶里面填充,直到达到桶的容量,多余的 token 会被丢弃。每当一个请求过来时,就会尝试着移除一个 token,如果没有 token,请求无法通过。


令牌桶算法支持突发流量。

令牌桶算法实现

Guava 框架提供了令牌桶算法的实现,可直接使用这个框架的 RateLimiter 类创建一个令牌桶限流器,比如:每秒放置的令牌桶的数量为 5,那么 RateLimiter 对象可以保证 1 秒内不会放入超过 5 个令牌,并且以固定速率进行放置令牌,达到平滑输出的效果。


平滑流量示例


这里,我写了一个使用 Guava 框架实现令牌桶算法的示例,如下所示。


package io.binghe.limit.guava;
import com.google.common.util.concurrent.RateLimiter;
/** * @author binghe * @version 1.0.0 * @description 令牌桶算法 */public class TokenBucketLimiter { public static void main(String[] args){ //每秒钟生成5个令牌 RateLimiter limiter = RateLimiter.create(5);
//返回值表示从令牌桶中获取一个令牌所花费的时间,单位是秒 System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); System.out.println(limiter.acquire(1)); }}
复制代码


代码的实现非常简单,就是使用 Guava 框架的 RateLimiter 类生成了一个每秒向桶中放入 5 个令牌的对象,然后不断从桶中获取令牌。我们先来运行下这段代码,输出的结果信息如下所示。


0.00.1972940.1912780.199970.1993050.2004720.2001840.1994170.2001110.199759
复制代码


从输出结果可以看出:第一次从桶中获取令牌时,返回的时间为 0.0,也就是没耗费时间。之后每次从桶中获取令牌时,都会耗费一定的时间,这是为什么呢?按理说,向桶中放入了 5 个令牌后,再从桶中获取令牌也应该和第一次一样并不会花费时间啊!


因为在 Guava 的实现是这样的:我们使用RateLimiter.create(5)创建令牌桶对象时,表示每秒新增 5 个令牌,1 秒等于 1000 毫秒,也就是每隔 200 毫秒向桶中放入一个令牌。


当我们运行程序时,程序运行到RateLimiter limiter = RateLimiter.create(5);时,就会向桶中放入一个令牌,当程序运行到第一个System.out.println(limiter.acquire(1));时,由于桶中已经存在一个令牌,直接获取这个令牌,并没有花费时间。然而程序继续向下执行时,由于程序会每隔 200 毫秒向桶中放入一个令牌,所以,获取令牌时,花费的时间几乎都是 200 毫秒左右。


突发流量示例


我们再来看一个突发流量的示例,代码示例如下所示。


package io.binghe.limit.guava;
import com.google.common.util.concurrent.RateLimiter;
/** * @author binghe * @version 1.0.0 * @description 令牌桶算法 */public class TokenBucketLimiter { public static void main(String[] args){ //每秒钟生成5个令牌 RateLimiter limiter = RateLimiter.create(5);
//返回值表示从令牌桶中获取一个令牌所花费的时间,单位是秒 System.out.println(limiter.acquire(50)); System.out.println(limiter.acquire(5)); System.out.println(limiter.acquire(5)); System.out.println(limiter.acquire(5)); System.out.println(limiter.acquire(5)); }}
复制代码


上述代码表示的含义为:每秒向桶中放入 5 个令牌,第一次从桶中获取 50 个令牌,也就是我们说的突发流量,后续每次从桶中获取 5 个令牌。接下来,我们运行上述代码看下效果。


0.09.9984090.991091.0001480.999752
复制代码


运行代码时,会发现当命令行打印出 0.0 后,会等很久才会打印出后面的输出结果。


程序每秒钟向桶中放入 5 个令牌,当程序运行到 RateLimiter limiter = RateLimiter.create(5); 时,就会向桶中放入令牌。当运行到 System.out.println(limiter.acquire(50)); 时,发现很快就会获取到令牌,花费了 0.0 秒。接下来,运行到第一个System.out.println(limiter.acquire(5));时,花费了 9.998409 秒。小伙们可以思考下,为什么这里会花费 10 秒中的时间呢?


这是因为我们使用RateLimiter limiter = RateLimiter.create(5);代码向桶中放入令牌时,一秒钟放入 5 个,而System.out.println(limiter.acquire(50));需要获取 50 个令牌,也就是获取 50 个令牌需要花费 10 秒钟时间,这是因为程序向桶中放入 50 个令牌需要 10 秒钟。程序第一次从桶中获取令牌时,很快就获取到了。而第二次获取令牌时,花费了将近 10 秒的时间。


Guava 框架支持突发流量,但是在突发流量之后再次请求时,会被限速,也就是说:在突发流量之后,再次请求时,会弥补处理突发请求所花费的时间。所以,我们的突发示例程序中,在一次从桶中获取 50 个令牌后,再次从桶中获取令牌,则会花费 10 秒左右的时间。

Guava 令牌桶算法的特点

  • RateLimiter 使用令牌桶算法,会进行令牌的累积,如果获取令牌的频率比较低,则不会导致等待,直接获取令牌。

  • RateLimiter 由于会累积令牌,所以可以应对突发流量。也就是说如果同时请求 5 个令牌,由于此时令牌桶中有累积的令牌,能够快速响应请求。

  • RateLimiter 在没有足够的令牌发放时,采用的是滞后的方式进行处理,也就是前一个请求获取令牌所需要等待的时间由下一次请求来承受和弥补,也就是代替前一个请求进行等待。(这里,小伙伴们要好好理解下)


好了,今天就到这儿吧,我是冰河,我们下期见~~

发布于: 刚刚阅读数: 3
用户头像

冰河

关注

公众号:冰河技术,专注写硬核技术专栏。 2020.05.29 加入

互联网资深技术专家,《深入理解高并发编程:核心原理与案例实战》《深入理解分布式事务:原理与实战》《海量数据处理与大数据技术实战》《MySQL技术大全》作者,mykit-data框架作者。【冰河技术】作者。

评论

发布
暂无评论
【高并发】别闹了,要实现亿级流量下的分布式限流,这些算法你必须掌握!!_并发编程_冰河_InfoQ写作社区