赞
踩
对于限流场景,一般需要考虑两个维度的信息:
限流就是在某个时间窗口对资源访问做限制,比如设定每秒最多100个访问请求。在实际的业务场景中,一般都是多种限流规则同时使用,主要的几种限流规则如下:
其中,
分布式限流,区别于单机限流,它把整个分布式环境中所有服务器当做一个整体来考量。比如说针对IP的限流,我们限制了1个IP每秒最多10个访问,不管来自这个IP的请求落在了哪台机器上,只要是访问了集群中的服务节点,那么都会受到限流规则的制约。
为了实现分布式限流,我们需要将限流信息保存在一个“中心化”的组件上,这样它就可以获取到集群中所有机器的访问状态。
基于Guava的客户端限流
Guava是一个客户端组件,在其多线程模块下提供了以RateLimiter为首的几个限流支持类。它只能对“当前”服务进行限流,即它不属于分布式限流的解决方案。
网关层限流
服务网关,作为整个分布式链路中的第一道关卡,承接了所有用户来访请求。我们在网关层进行限流,就可以达到了整体限流的目的了。目前,主流的网关层有以软件为代表的Nginx,还有Spring Cloud中的Gateway和Zuul这类网关层组件,也有以硬件为代表的F5。
中间件限流
将限流信息存储在分布式环境中某个中间件里(比如Redis缓存),每个组件都可以从这里获取到当前时刻的流量统计,从而决定是拒绝服务还是放行流量。
限流组件
目前也有一些开源组件提供了限流的功能,比如Sentinel就是一个不错的选择。Sentinel是阿里出品的开源组件,并且包含在了Spring Cloud Alibaba组件库中。Hystrix也具有限流的功能。
常见的限流算法有:令牌桶算法、漏桶算法、滑动窗口和计数器算法等。
令牌桶算法是目前应用最为广泛的限流算法。它有以下两个关键角色:
令牌桶算法实现原理
令牌桶算法算法主要包括了令牌生成和令牌获取两个步骤。其中,
漏桶算法的前半段和令牌桶类似,但是操作的对象不同,令牌桶是将令牌放入桶里,而漏桶是将访问请求的数据包放到桶里。同样的是,如果桶满了,那么后面新来的数据包将被丢弃。
漏桶算法的后半程是有鲜明特色的,它永远只会以一个恒定的速率将数据包从桶内流出。打个比方,如果我设置了漏桶可以存放100个数据包,然后流出速度是1s一个,那么不管数据包以什么速率流入桶里,也不管桶里有多少数据包,漏桶能保证这些数据包永远以1s一个的恒定速度被处理。
漏桶 vs 令牌桶的区别
根据它们各自的特点不难看出来,这两种算法都有一个“恒定”的速率和“不定”的速率。令牌桶是以恒定速率创建令牌,但是访问请求获取令牌的速率“不定”,反正有多少令牌发多少,令牌没了就干等。而漏桶是以“恒定”的速率处理请求,但是这些请求流入桶的速率是“不定”的。
从这两个特点来说,漏桶的天然特性决定了它不会发生突发流量,就算每秒1000个请求到来,那么它对后台服务输出的访问速率永远恒定。而令牌桶则不同,其特性可以“预存”一定量的令牌,因此在应对突发流量的时候可以在短时间消耗所有令牌,其突发流量处理效率会比漏桶高,但是导向后台系统的压力也会相应增多。
上图中黑色的大框就是时间窗口,我们设定窗口时间为5秒,它会随着时间推移向后滑动。我们将窗口内的时间划分为五个小格子,每个格子代表1秒钟,同时这个格子还包含一个计数器,用来计算在当前时间内访问的请求数量。那么这个时间窗口内的总访问量就是所有格子计数器累加后的数值。
比如说,我们在每一秒内有5个用户访问,第5秒内有10个用户访问,那么在0到5秒这个时间窗口内访问量就是15。如果我们的接口设置了时间窗口内访问上限是20,那么当时间到第六秒的时候,这个时间窗口内的计数总和就变成了10,因为1秒的格子已经退出了时间窗口,因此在第六秒内可以接收的访问量就是20-10=10个。
滑动窗口其实也是一种计算器算法,它有一个显著特点,当时间窗口的跨度越长时,限流效果就越平滑。打个比方,如果当前时间窗口只有两秒,而访问请求全部集中在第一秒的时候,当时间向后滑动一秒后,当前窗口的计数量将发生较大的变化,拉长时间窗口可以降低这种情况的发生概率
除了前面提到的滑动窗口这种计数器算法,一般还会采用更加简单粗暴的方式进行限流,直接限制一段时间能够通过的请求数,比如限流qps为100/min。
确定方法的最大访问量MAX,每次进入方法前计数器+1,将结果和最大并发量MAX比较,如果大于等于MAX,则直接返回;如果小于MAX,则继续执行。
优缺点:
对于访问量限流这种限流方式,实现简单,适用于大多数场景,阈值可以通过服务端来动态配置,甚至可以当做业务开关来使用
但也有一定的局限性,因为我们的阈值是通过分析单位时间段内调用量来设置的,如果它在单位时间段的前几秒就被流量突刺消耗完了,将导致该时间段内剩余的时间内该服务“拒绝服务”,可以将这种现象称为“突刺现象”。
确定方法的最大并发量MAX,每次进入方法前计数器+1,将结果和最大并发量MAX比较,如果大于等于MAX,则直接返回;如果小于MAX,则继续执行;退出方法后计数器-1。比如限制服务的并发访问数是100,而服务处理的平均耗时是10毫秒,那么1分钟内,该服务平均能提供( 1000 / 10 ) * 60 * 100 = 6000 次
优缺点:
并发量限流一般用于对于服务资源有严格限制的场景,但是某个服务在业务高峰期和低峰期的并发量很难评估,这给并发阈值的设置带来了困难,但我们可以通过线上业务的监控数据来逐步对并发阈值进行调优,只要肯花时间,我们总能找到一个即能保证一定服务质量又能保证一定服务吞吐量的合理并发阈值,从表面上看并发量限流似乎很有用,但也不可否认,它仍然可以造成流量尖刺,即每台服务器上该服务的并发量从0上升到阈值是没有任何“阻力”的,这是因为并发量考虑的只是服务能力边界的问题。
Guava的限流基于令牌桶算法实现,提供了平滑突发限流方案和平滑预热限流两种方式。
平滑突发限流,具备以下特点:
平滑预热限流,是在平滑突发限流的基础上,增加了带有预热期的一种平滑限流,即它启动后会有一段预热期,逐步将分发频率提升到配置的速率。
平滑预热限流,通过动态调整令牌发放速度,可以让流量变化更加平滑。假设,现在有一个接口,限定100QPS,当前如果没有请求到来,令牌桶就会有100个令牌,如果突发的到来100个请求,这个时候就会瞬间消耗掉令牌,并产生较大的冲击力。而平滑预热限流,则可以根据桶内的令牌数量动态控制令牌的发放速率,让忙时流量和闲时流量可以互相平滑过渡。
其中,横坐标是令牌桶的当前容量,纵坐标是令牌发放速率。在横坐标上,有两个关键坐标:“令牌桶最大容量”和“Half容量”,它们会影响令牌的发送速率。纵坐标,则有三个重要坐标点:稳定时间间隔,2倍间隔,3倍间隔,间隔表示发放两个个令牌的时间间隔。其中,稳定间隔就是一个基准时间间隔,假设,我们设置了每秒10个令牌的限流规则,那么稳定间隔也就是1s/10=0.1秒,即每隔0.1秒发一个令牌。而,3倍间隔的数值是用稳定间隔乘以系数3,即,3倍间隔就是0.3秒。
两种场景会导致横坐标的变化
这里提供了一个getTokenByNewThread()方法,是模拟了消耗令牌的方法,可以设置多线程、消耗令牌数、获取令牌次数等。
public class RateLimiterDemo { public static void main(String[] args) { //根据测试内容,调整调用方法 RateLimiterDemo.testSmoothBursty(); } /** * 模拟,多线程,消耗令牌 * @param threadNum 线程数量 * @param tokenNum 每个线程一次消耗的令牌数 * @param count 获取几次令牌 * @param r 限流对象 */ public static void getTokenByNewThread(int threadNum, int tokenNum, int count, RateLimiter r){ for(int i= 0; i < threadNum; i++){ new Thread(new Runnable() { @Override public void run() { for(int k = 0; k < count; k++){ System.out.println(Calendar.getInstance().getTime() + " " + Thread.currentThread().getName() + " get tokens: " + r.acquire()); } } }).start(); } } }
/**
* 平滑突发限流,
* 1、以固定的速率生成令牌
* 2、RateLimiter使用令牌桶算法,会进行令牌的累积,如果获取令牌的频率比较低,则不会导致等待,直接获取令牌。
* 3、RateLimiter在没有足够令牌发放时,采用滞后处理的方式,也就是前一个请求获取令牌所需等待的时间由下一次请求来承受,也就是代替前一个请求进行等待。
*/
public static void testSmoothBursty() {
RateLimiter r = RateLimiter.create(2);
RateLimiterDemo.getTokenByNewThread(1,1,5, r);
}
这里创建了一个限流器,每秒产生2个令牌,即0.5秒产生一个令牌。我们假设一个线程,一次消耗一个令牌,连续消耗五次,打印结果如下,说明限流器以每秒产生两个令牌的固定速率生成令牌。
Sat Feb 20 11:11:13 CST 2021 Thread-1 get tokens: 0.0
Sat Feb 20 11:11:13 CST 2021 Thread-1 get tokens: 0.460514
Sat Feb 20 11:11:13 CST 2021 Thread-1 get tokens: 0.497051
Sat Feb 20 11:11:14 CST 2021 Thread-1 get tokens: 0.499428
Sat Feb 20 11:11:14 CST 2021 Thread-1 get tokens: 0.498949
假设,限流器和消耗线程不变,我们在开始消耗线程前,先休眠了3秒,再开始消耗,输出如下。说明RateLimiter使用令牌桶算法,会进行令牌的累积,如果获取令牌的频率比较低,则不会导致等待,直接获取令牌。那么,三秒应该产生6个令牌,为什么后面有开始需要等待了呢?这是因为限流器,默认可以存储的令牌数为一秒产生的令牌数量,所以,超过这个数量的令牌也会被丢弃。
public static void testSmoothBursty() { RateLimiter r = RateLimiter.create(2); try { Thread.sleep(1000 * 3); } catch (Exception e) { e.printStackTrace(); } RateLimiterDemo.getTokenByNewThread(1,1,5, r); } /*打印结果: Sat Feb 20 11:31:27 CST 2021 Thread-1 get tokens: 0.0 Sat Feb 20 11:31:27 CST 2021 Thread-1 get tokens: 0.0 Sat Feb 20 11:31:27 CST 2021 Thread-1 get tokens: 0.0 Sat Feb 20 11:31:27 CST 2021 Thread-1 get tokens: 0.498489 Sat Feb 20 11:31:27 CST 2021 Thread-1 get tokens: 0.498442 */
假设,限流器不变,调整令牌消耗线程:6个线程(尽量保证同时消耗令牌),每个线程每次消耗2个令牌(限流器一秒产生一个),消耗一次,打印结果如下,说明RateLimiter在没有足够令牌发放时,采用滞后处理的方式,也就是前一个请求获取令牌所需等待的时间由下一次请求来承受,也就是代替前一个请求进行等待。
public static void testSmoothBursty() {
RateLimiter r = RateLimiter.create(1);
RateLimiterDemo.getTokenByNewThread(6,2,1, r);
}
/*打印结果:
Sat Feb 20 11:41:23 CST 2021 Thread-1 get tokens: 0.0
Sat Feb 20 11:41:23 CST 2021 Thread-4 get tokens: 0.966329
Sat Feb 20 11:41:23 CST 2021 Thread-7 get tokens: 1.964799
Sat Feb 20 11:41:23 CST 2021 Thread-2 get tokens: 2.962133
Sat Feb 20 11:41:23 CST 2021 Thread-3 get tokens: 3.962082
Sat Feb 20 11:41:23 CST 2021 Thread-6 get tokens: 4.962072
*/
/**
* 平滑预热限流,带有预热期的平滑限流,即它启动后会有一段预热期,逐步将分发频率提升到配置的速率。
*/
public static void testSmoothwarmingUp() {
RateLimiter r = RateLimiter.create(2, 3, TimeUnit.SECONDS);
RateLimiterDemo.getTokenByNewThread(1,2,8, r);
}
预热限流,这里首先是创建了一个预热限流器,每秒产生2个令牌,不过有3秒的预热时间,这个时候,创建一个消费线程,消费8次,打印结果如下,刚开始需要阻塞的实际比较长,后续逐渐趋于稳定。
Sat Feb 20 13:28:29 CST 2021 Thread-1 get tokens: 0.0
Sat Feb 20 13:28:29 CST 2021 Thread-1 get tokens: 1.33173
Sat Feb 20 13:28:31 CST 2021 Thread-1 get tokens: 0.994883
Sat Feb 20 13:28:32 CST 2021 Thread-1 get tokens: 0.66637
Sat Feb 20 13:28:32 CST 2021 Thread-1 get tokens: 0.499793
Sat Feb 20 13:28:33 CST 2021 Thread-1 get tokens: 0.49911
Sat Feb 20 13:28:33 CST 2021 Thread-1 get tokens: 0.499754
Sat Feb 20 13:28:34 CST 2021 Thread-1 get tokens: 0.499107
前面的示例,我们获取令牌的方法都是使用了r.acquire()方式,该方法是阻塞方法,即获取不到令牌会一直等到,RateLimiter还提供了一种非阻塞的方式,r.tryAcquire()方法,如果获取不到令牌,会直接返回false。
public static void testNonBlock(){
RateLimiter r = RateLimiter.create(2);
while (true) {
try {//为了避免不停的打印日志
Thread.sleep(500 * 1);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Calendar.getInstance().getTime() + "get tokens: " + r.tryAcquire(3));
}
}
通过tryAcquire()方式获取令牌,获取城管直接返回true,失败则返回false。
Sat Feb 20 13:37:34 CST 2021get tokens: true
Sat Feb 20 13:37:35 CST 2021get tokens: false
Sat Feb 20 13:37:35 CST 2021get tokens: true
Sat Feb 20 13:37:36 CST 2021get tokens: false
Sat Feb 20 13:37:36 CST 2021get tokens: false
Sat Feb 20 13:37:37 CST 2021get tokens: true
Sat Feb 20 13:37:37 CST 2021get tokens: false
Sat Feb 20 13:37:38 CST 2021get tokens: false
Sat Feb 20 13:37:38 CST 2021get tokens: true
其他限流方案,请参考《 基于Nginx的网关限流和基于Redis的中间件限流》。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。