概要
为了对系统资源的保护或者在网关限制流量,我们一般用到限流算法。Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法实现流量限制,使用十分方便。
RateLimiter原理分析
令牌桶算法
令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
实现原理
RateLimiter有两种限流模式,一种为稳定模式(SmoothBursty:令牌生成速度恒定),一种为渐进模式(SmoothWarmingUp:令牌生成速度缓慢提升直到维持在一个稳定值)。
以下代码基于基本 guava:26.0-jre。
看看关键类的继承图
SmoothBursty
限流效果
先看看效果,对这个工具有一个感性认识
RateLimiter.create(5.0) 表示每秒产生5个令牌。
输出的意思是这次获取令牌所需要等待的时间。
属性
继承自SmoothRateLimiter的有以下属性
1 | /** The currently stored permits. */ |
storedPermits - 当前桶里有多少令牌。
maxPermits - 桶可以最大存储多少令牌。
stableIntervalMicros - 生成一个令牌的间隔,单位微秒。
nextFreeTicketMicros - 这个比较难理解,也是关键,意思是下一个请求允许获取到令牌的微秒数。
初始化
1 | public static RateLimiter create(double permitsPerSecond) { |
SmoothBursty的两个构造参数,一个是stopwatch,这个类的作用是能够获取从初始化时到现在的时间,另一个参数 maxBurstSeconds是 hard code 为 1。
1 | public final void setRate(double permitsPerSecond) { |
setRate方法用来初始化令牌生成速率;
1 |
|
doSetRate 是模版方法,我们先看 SmoothBursty 的,等下讲到 SmoothWarmingUp 时会讲它的 doSetRate。
这个方法有两个地方用到,一是初始化时,二是调用 RateLimiter 的实例方法 setRate 动态调整速率时。
延迟计算
初始化就这么简单了。可能有人在想既然是令牌桶算法,应该有个类似定时器的东东来持续往桶放令牌才对啊,我刚开始也是这么想的,看了代码觉得自己还是太嫩了,如果开启一个定时器无可厚非,但如果系统需要N个不同速率的桶来针对不同的场景或用户,就会极大的消耗系统资源。
RateLimiter用了一种类似于延迟计算的方法,把桶里令牌数量的计算放在下一个请求中计算,即桶里的令牌数 storedPermits 不是实时更新的,而是等到下一个请求过来时才更新的,具体我们来看看消费令牌的过程。
获取令牌acquire
主要有两个方法,一是 acquire,一是 tryAcquire。区别是如果桶里没有令牌,前者会阻塞,后者会直接返回 false。
我们先看看 acquire 方法
1 |
|
reserveEarliestAvailable 是整个 RateLimiter 的核心方法,它是 SmoothRateLimite 的一个模板方法。
1 |
|
reserveEarliestAvailable的返回值,注意了,这里返回的是更新前的 nextFreeTicketMicros,也就是上一个请求更新的 nextFreeTicketMicros。那么这个 waitMicros 等待时间也不是当前请求需要等待的时间,而是下一个请求需要等待的时间,这个涉及到 RateLimiter 一个很重要的设计理念,就是“预消费”,通俗点理解即“前人消费,后人买单”,理解好这点,是使用和理解 RateLimiter 的关键。
我举一个例子来助于理解,桶的速率为每秒产生5个令牌,现在桶里有4个令牌,现在过来一个请求需要10个令牌,那么这个请求会被无阻塞允许,不需要等待,同时又过来一个请求,现在桶里已经没有令牌了,而且上一个请求还“欠下”6个令牌,那么这个请求需要等待 (10 - 4) / 5 秒的时间,才被允许执行。
1 | //刚刚说的延迟计算令牌数就在这里。这个方法是用来计算 storedPermits (桶里的令牌数),nowMicros 是当前的微秒数,nextFreeTicketMicros 上面说过了。 |
所以resync的意思就是如果当前时间大于 nextFreeTicketMicros,就用当前时间 - nextFreeTicketMicros / 每 stableIntervalMicros 生成一个令牌,即这个时间差可以生成多少个令牌;
我用一个图来表示会更加清晰
为什么要“预消费”
RateLimiter 它是这样想的:
Last, but not least: consider a RateLimiter with rate of 1 permit per second, currently completely unused, and an expensive acquire(100) request comes. It would be nonsensical to just wait for 100 seconds, and /then/ start the actual task. Why wait without doing anything? A much better approach is to /allow/ the request right away (as if it was an acquire(1) request instead), and postpone /subsequent/ requests as needed. In this version, we allow starting the task immediately, and postpone by 100 seconds future requests, thus we allow for work to get done in the meantime instead of waiting idly.
大概意思是,假设令牌产生的速率为1秒一个,系统平时是很空闲的,突然来了一个 expensive acquire(100) 的请求,难道我要瞎等100秒才执行吗?这毫无意义,不能充分利用资源啊,所以干脆可以直接允许好了,不要做无谓的等待。
简单来说就是为了突发性。
消费场景分析
我们分情况分析一下就清楚了:
nowMicros > nextFreeTicketMicros
这种场景发生在刚初始化时,或者桶里的令牌还有剩余。如果请求所需令牌 < 桶里的
即桶里令牌满足这次消费的话,那么 nextFreeTicketMicros 会移动到 nowMicros 的位置
令牌数 storedPermits = 原来 - 消费的 + 这段时间增加的。如果请求所需令牌 >= 桶里的
这时会优先把桶里的令牌全部拿走,那么 storedPermits 就等于0了。
如果还不够,就会发生预消费,那么 nextFreeTicketMicros 会后移,移动多少?就是需要产生“溢出”令牌数的时间。
nowMicros < nextFreeTicketMicros
在上面有一个场景 nextFreeTicketMicros 会后移,移动了多少不知道,要看上一个请求,那么如果这段时间内有请求过来呢?
这时当前的请求就要为上一个请求“买单”了,它需要等待到 nextFreeTicketMicros 这个时刻才能允许执行,但此时桶里令牌数是 0 的,所以这个请求也是会预消费的。
SmoothWarmingUp
SmoothBursty 是以一个固定的速率来产生令牌的,它具有突发性,这个可能适用大多数场景。
而 SmoothWarmingUp 考虑的是譬如一个系统刚启动,但如果这时有大量请求过来,因为突发性,这些请求都会被允许,但此时系统可能没有那么多资源去响应,所以需要一个“热身”时间,SmoothWarmingUp 就派上用场了。
它跟 SmoothBursty 的大概思路都是差不多的,只是个别地方有差别,主要就是之前提到几个模板方法,我们来看看。
限流效果
SmoothWarmingUp 的效果是刚开始产生令牌的速率比较慢,随着请求过来,会进入“热身”期,速率逐渐提升到 permitsPerSecond 这个速度;但是如果没有请求了,又会“冷却”下去,请求过来又要从“热身”开始。
初始化
初始化也是调用 create,不过参数列表有点不同
1 | //permitsPerSecond 是“热身”后的稳定速率; |
“热身”速率函数及说明
由于接下来涉及到一些计算,我们先看看“热身”函数的定义及图像
1 | /** |
首先不要被吓到,还是很简单的,我来说明一下。
x 轴是 storedPermits,即桶里的令牌数。轴上主要刻有两个值,一是thresholdPermits,这个等下会讲到;一个是maxPermits;
y 轴是生成一个令牌的间隔,单位微秒。轴上主要刻有两个值,一是stable interval;一个是 cold interval,coldInterval = coldFactor * stableInterval,由于 coldFactor hard code 为 3,所以 coldInterval 等于3倍的 stable interval。
warmup period 是入参的“热身”时间。
由这几个值构成的左边的长方形和右边的梯形。
由于 x 轴是令牌数,y 轴是生成令牌的间隔,所以它们的乘积是一个时间。
doSetRate方法
1 |
|
这里对几个参数的计算说明一下:
thresholdPermits
为什么 thresholdPermits = 0.5 * warmupPeriodMicros / stableIntervalMicros?
先看看官方的注释Assuming we have saturated demand, the time to go from maxPermits to thresholdPermits is
equal to warmupPeriod. And the time to go from thresholdPermits to 0 is warmupPeriod/2. (The
reason that this is warmupPeriod/2 is to maintain the behavior of the original implementation
where coldFactor was hard coded as 3.)根据官方的注释,说“热身”的时间是稳定时间的2倍(我这里表述不准确),即梯形面积为长方形面积的2倍,要保持跟 coldFactor 写死为3一样,原因是希望令牌速率提升的幅度跟它所需要的时间的比例保持一致(这点我不知道理解的对不对,希望有人帮我佐证)
因为梯形面积是已知的,又知道长方形的面积和一条边长,容易求得 thresholdPermits。maxPermits
为什么 maxPermits = thresholdPermits + 2.0 * warmupPeriodMicros / (stableIntervalMicros + coldIntervalMicros) ?
这个简单,利用梯形面积公式求出高,然后再加上 thresholdPermits。
消费令牌的主要逻辑在 reserveEarliestAvailable 方法,里面有一个模板方法 storedPermitsToWaitTime,我们看看 SmoothWarmingUp 的实现。
1 |
|
看一下图像就清楚了
从 storedPermitsToWaitTime 看出,SmoothWarmingUp 会优先取出超过 thresholdPermits 的令牌,但即使有令牌可用,还是会阻塞请求,以这样来防止启动时的突发性。随着请求增加,令牌的减少,桶的令牌会达到 thresholdPermits,这时就相当于“热身”完了,跟 SmoothBursty 一样。但如果一直没有请求来消费令牌,令牌数增加,就会从新进去“热身”期了。
coolDownIntervalMicros
在 resync 方法中,还有一个模板方法 coolDownIntervalMicros,在 SmoothWarmingUp 的实现中为
1 |
|
这个方法是用于得出从上一个请求到当请求的时间内,可以生成令牌的时间间隔,在 SmoothBursty 的实现中它就是 stableIntervalMicros。
但在这里我不明白为什么要这样计算(梯形面积 / maxPermits 得出是什么???),如果有人知道,希望你留言告知我这个数学渣。
setRate的公平性考虑
RateLimiter 可以动态调整产生令牌的速率,但是这里涉及一个问题,如何处理当前被阻塞的请求以及后续请求?
先看看官方的注释:
1 | /** |
注释的意思说了当前被阻塞的线程不会因此醒过来,它们对速率的改变没有感知,接下来的请求才会适应新的速率。
Note though that, since each request repays (by waiting, if necessary) the cost of the previous request, this means that the very next request after an invocation to {@code
setRate} will not be affected by the new rate; it will pay the cost of the previous request, which is in terms of the previous rate.
其中这句话不好理解,我的理解是,假设速率降低了,如果需要对当前被阻塞的请求做调整的话,那么它们的阻塞时间会增加(这里假设的结果是增加),由于连锁反应,最后导致 nextFreeTicketMicros 会后移,这就对于改变速率后的请求不公平了。
所以 RateLimiter 的做法是当前阻塞的请求还是按照原来时间等待,后续的请求用新的速率,这样实现也比较简单,对后续的请求也公平。
tryAcquire
补充说明一下tryAcquire,这方法实际应用比acquire 方法还要实用。
1 | public boolean tryAcquire(int permits, long timeout, TimeUnit unit) { |
tryAcquire 会先去判断是否能够在 timeout 的等待时间内能够获取到令牌,如果可以就阻塞等待,如果不能则直接返回false。
总结
Guava 的 RateLimiter 是一个高效低耗,简单易用,优秀的限流工具,它基于令牌桶算法,并且提供了一个很好的实现参考。
参考资料
https://blog.wangqi.love/articles/Java/Guava%20RateLimiter%E5%88%86%E6%9E%90.html
https://segmentfault.com/a/1190000012875897
https://www.jianshu.com/p/3dfae5c15eb9