目录
- 概览:限流模块 RateLimiter
- 常见限流算法
- Resilience4j 限流算法
- 小结
一、概览:限流模块 RateLimiter
我们通常都会有疑问:
什么是速率限制?什么时候有用?什么时候没用?
看上去,速率限制(又叫流量控制)对应用系统稳定性的影响那么明显,而实际上,不管是大厂还是小团队,都应该考虑流量处理时的请求峰值的能力。
因为,业务总会发展起来,系统总会变的越来越复杂。
比如,在某些情况下,当我们继承某些旧版API时,因为它没有任何速率限制器,又恰好无意中受到DoS攻击(不一定都是外部的恶意攻击,也有可能是内部的高并发量接口访问调用)。在这种情况下,能限制总是比不能限制更好。
速率限制可为扩展API做好准备,并确保服务的高可用性和系统稳定性。
前面我们已经说过,Resilience4j框架很好的实现了事件驱动架构,从上面思维导图中,能详细的看到,限流模块加入了事件、注册和性能统计,有两种算法实现。
限流器最重要的就是实现限流算法,我们先来看看常见的限流算法有哪些?
二、常见限流算法
1.漏水桶算法 Leaky Bucket
漏水桶算法,是基于一个类比:如果倒水的平均速度超过漏水桶的速度,或者如果倒水的水量超过倒水桶的容量,则漏水的桶将如何溢出的一种算法。
简单说就是,把请求比作是水,水来了都先放进桶里,并以限定的速度放水,当水来得过大过猛而放水不够快时就会导致水直接溢出,即拒绝服务。
漏水桶有一个进水口和一个出水口,出水口以一定速率出水,并且有一个最大出水速率:
在漏水桶中没有水的时候:
- 如果进水速率小于等于最大出水速率,那么,出水速率等于进水速率,此时,不会积水
- 如果进水速率大于最大出水速率,那么,漏斗以最大速率出水,此时,多余的水会积在漏斗中
在漏水桶中有水的时候:
- 出水口以最大速率出水
- 如果漏斗未满,且有进水的话,那么这些水会积在漏斗中
- 如果漏斗已满,且有进水的话,那么这些水会溢出到漏斗之外
一般来说,这个“漏水桶”是用一个队列来实现的,当请求过多时,队列就会开始积压请求,如果队列满了,就会开拒绝请求。很多系统都有这样的设计,比如TCP。当请求的数量过多时,就会有一个syncbacklog的队列来缓冲请求,或是TCP的滑动窗口也是用于流控的队列。
2.令牌桶算法 Token Bucket
关于令牌桶算法,主要是有一个中间人。在一个桶内按照一定的速率放入一些token,然后,处理程序要处理请求时,需要拿到token,才能处理;如果拿不到,则不处理。
下面这个图很清楚地说明了这个算法。
令牌桶算法:
- 令牌以固定速率生成;
- 生成的令牌放入令牌桶中存放,如果令牌桶满了则多余的令牌会直接丢弃,当请求到达时,会尝试从令牌桶中取令牌,取到了令牌的请求可以执行;
- 如果桶空了,那么尝试取令牌的请求会被直接丢弃。
直观上,漏斗和令牌桶算法很相似,不过,漏桶算法稍微不同与令牌桶算法的一点是:对于取令牌的频率也有限制,要按照 t/n 固定的速度来取令牌,所以可以看出漏桶算法对流量的整形效果更加好,流量更加平滑,任何突发流量都会被限流。因为令牌桶大小为 b,所以是可以应对突发流量的。当然,对于令牌桶算法,还有很多其他改进算法,比如:
- 预热桶
- 一次性放入多个令牌
- 支持一次性取多个令牌
所以令牌桶和漏桶算法比较适合阻塞式限流,比如一些后台 job 类的限流,超过了最大访问频率之后,请求并不会被拒绝,而是会被阻塞到有令牌后再继续执行。
3.Google guava 预热算法
利特尔定律(Little’s law),基于排队论,是由约翰·利特尔在1954年提出。利特尔法则可用于一个稳定的、非占先式的系统中。其内容为:
在一个稳定的系统(L)中,长期的平均顾客人数,等于长期的有效抵达率(λ),乘以顾客在这个系统中平均的等待时间(W);可以用一个代数式来表达:
guava利用这个定理设计了限流预热算法 SmoothRateLimiter.
当流量突然增大的时候,我们常常会希望系统从空闲状态到繁忙状态的切换的时间长一些。即如果系统在此之前长期处于空闲的状态,我们希望处理请求的数量是缓步的增多,经过预期的时间以后,到达系统处理请求个数的最大值。Warm Up(冷启动,预热)模式就是为了实现这个目的的。
4.固定窗口计数器算法
固定窗口计数器算法,是限流算法里最简单也是最容易实现的一种算法。比如我们规定,对于A接口来说,我们1分钟的访问次数不能超过100个。那么我们可以这么做:在一开始的时候,我们可以设置一个计数器counter,每当一个请求过来的时候,counter就加1,如果counter的值大于100并且该请求与第一个请求的间隔时间还在1分钟之内,那么说明请求数过多;如果该请求与第一个请求的间隔时间大于1分钟,且counter的值还在限流范围内,那么就重置counter,具体算法的示意图如下:
这种基于固定时间窗口的限流算法的缺点在于:限流策略过于粗略,无法应对两个时间窗口临界时间内的突发流量。
假设有一个恶意用户,他在0:59时,瞬间发送了100个请求,并且1:00又瞬间发送了100个请求,那么其实这个用户在1秒里面,瞬间发送了200个请求。我们刚才规定的是1分钟最多100个请求,也就是每秒钟最多1.7个请求,用户通过在时间窗口的重置节点处突发请求,可以瞬间超过我们的速率限制。用户有可能通过算法的这个漏洞,瞬间压垮我们的应用。
5.滑动窗口计数器算法
滑动窗口计数器算法如下:
- 将时间划分为多个区间;
- 在每个区间内每有一次请求就将计数器加一维持一个时间窗口,占据多个区间;
- 每经过一个区间的时间,则抛弃最老的一个区间,并纳入最新的一个区间;
- 如果当前窗口内区间的请求计数总和超过了限制数量,则本窗口内所有的请求都被丢弃。
滑动窗口计数器是通过将窗口再细分,并且按照时间 " 滑动 ",这种算法避免了固定窗口计数器带来的双倍突发请求,但时间区间的精度越高,算法所需的空间容量就越大。
三、Resilience4j 限流算法
先来看官网的这张图:
Resilience4j 总共有两种实现:
- 基于Java信号量(Semaphore-Based Rate Limiter)
- 基于原子计数器(Atomic Rate Limiter)
我们先来看:基于原子计数器(Atomic Rate Limiter)的实现。
上图就是AtomicRateLimiter的实现示意图,它通过AtomicReference管理其状态。 其中,AtomicRateLimiter.State是不可变的,并且具有以下字段:
- activeCycle:上一次调用使用的周期号
- activePermissions:上次调用后的可用权限计数。如果保留某些权限,则可以为负
- nanosToWait:等待上一次呼叫的等待许可的纳秒数
主要逻辑是:
- 1.将时间分成相等的部分,称为循环。在任何时候,我们都可以通过计算currentTime / cyclePeriod来确定当前周期。
- 2.如果我们知道限制器最后一次使用的当前周期数和周期,那么我们实际上可以计算出应该在限制器中出现多少个权限。
- 3.经过此计算后,如果可用权限还不够,我们可以通过减少当前权限并计算我们等待它出现的时间来执行权限保留。
- 4.经过所有计算后,我们可以产生一个新的限制器状态并将其存储在AtomicReference中,以在所有用户线程中传播这些更改。
我们再来看:基于Java信号量(Semaphore-Based Rate Limiter)的实现。
这里需要稍微复习一下「信号量」的知识:
信号量机制是Edsger Dijkstra于1962年提出的,用于控制对一个或多个共享资源的访问。
该机制基于一个内部计数器以及两个名为wait()
和signal()
的方法。
- 当一个线程调用了
wait()
方法时,如果内部计数器的值大于0,那么信号量对内部计数器做递减操作,并且该线程获得对该共享资源的访问。如果内部计数器的值为0,那么线程将被阻塞,直到某个线程调用singal()
方法为止。 - 当一个线程调用了
signal()
方法时,信号量将会检查是否有某些线程处于等待状态(它们已经调用了wait()方法)。如果没有线程等待,它将对内部计数器做递增操作。如果有线程在等待信号量,就获取这其中的一个线程,该线程的wait()方法结束返回并且访问共享资源。其他线程将继续等待,直到轮到自己为止。
在Java中,信号量是在 Semaphore
类中实现。wait()方法被称作acquire(),而signal()方法被称作release()。
在 Resilience4j 中,就是使用java.util.concurrent.Semaphore来存储当前限流权限,所有用户线程都调用semaphore.tryAcquire方法,而我们将拥有一个附加的内部线程,当新的limitRefreshPeriod(限流刷新周期)启动时,新建并调用semaphore.release(),符合上面信号量的流程,如下图:
但是,此实现也有一个缺点:会需要维持内部线程,其实会有一些开销。
四、小结
本文介绍了几种常用的限流算法和Resilience4j的两种限流算法实现,可以明显看到Resilience4j的实现是基于单机高性能实现的,遗憾的是官方并没有实现基于分布式或集群限流的实现,需要使用者自行实现。