漏桶算法、令牌桶算法思路及使用场景
在介绍RateLimiter
之前我们先看下常说的漏桶算法和令牌桶算法,看下两种算法的思想和适用场景:
漏桶算法:
结合上面的图,漏桶算法就是将请求放入桶中,然后始终以一个固定的速率从桶中取出请求来处理,当桶中等待的请求数超过上限后(桶的容量固定),后续的请求就不再加入桶中,而是执行拒绝策略(比如降级)
适用于需要以固定速率的场景,而在多数业务场景中,我们并不需要严格的速率,并且需要有一定的应对突发流量的能力,所以会使用令牌桶算法限流。
令牌桶算法:
结合上面的图,令牌桶算法就是以固定速率生成令牌放入桶中,每个请求都需要从桶中获取令牌,没有获取到令牌的请求会被阻塞限流(桶中的令牌不够的时候),当令牌消耗速度小于生成的速度时,令牌桶内就会预存这些未消耗的令牌(直到桶的上限),当有突发流量进来时,可以直接从桶中取出令牌,而不会被限流。
不管是令牌桶算法还是漏桶算法都可以用延迟计算的方式来实现,延迟计算指的是不需要单独的线程来定时生成令牌或者从漏桶中定时取出请求,而是由调用限流器的线程自己去计算是否有足够的令牌以及需要sleep的时间,延迟计算的方式可以节省一个线程资源。Guava提供的RateLimiter
类就是以延迟计算的方式实现限流。
RateLimiter
实现原理
先看下RateLimiter
类图:
可以看出有两层抽象,RateLimiter
类本身是一个抽象类,子类SmoothRateLimiter
又做了层抽象,SmoothRateLimiter
有两个子类SmoothBursty
和SmoothWarmingUp
,可以说SmoothWarmingUp
是为了SmoothBursty
的升级版,是为了弥补SmoothBursty
的不足的(详细见后面的分析), 这里以限流核心方法acquire()
为入口,自上而下的从RateLimiter
类说起。
acquire()
原理分析
整个限流实现过程主要分为生产令牌、获取令牌、计算阻塞时间、阻塞线程四步,既然RateLimiter
做了抽象,那么说明提取了共性,在RateLimiter
里的共性是阻塞线程的逻辑,所以在acquire()
里将阻塞线程这个共性点提取了出来,而将具体生产令牌、获取令牌、计算阻塞时间的细节由子类SmoothRateLimiter
去实现,先看下RateLimiter
类的acquire()
方法代码:
@CanIgnoreReturnValue
public double acquire() {
// 获取一个令牌
return acquire(1);
}
public double acquire(int permits) {
// 预支令牌并获取需要阻塞的时间
long microsToWait = reserve(permits);
// 根据microsToWait来sleep线程(共性)
stopwatch.sleepMicrosUninterruptibly(microsToWait);
return 1.0 * microsToWait / SECONDS.toMicros(1L);
}
final long reserve(int permits) {
checkPermits(permits);
synchronized (mutex()) {
return reserveAndGetWaitLength(permits, stopwatch.readMicros());
}
}
final long reserveAndGetWaitLength(int permits, long nowMicros) {
long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
return max(momentAvailable - nowMicros, 0);
}
// 生产令牌、获取令牌、计算阻塞时间细节由子类实现
abstract long reserveEarliestAvailable(int permits, long nowMicros);
复制代码
接着看二级抽象类SmoothRateLimiter
对reserveEarliestAvailable(int permits, long nowMicros)
的实现逻辑,我们先看下这个类的几个重要属性:
nextFreeTicketMicros
:下一次请求被允许的时间。当令牌数不足时,需要由当前请求的线程负责延迟计算出令牌的生产数及耗时并更新这个值,即使需要等待,当前线程也不会去阻塞等待,而是提前预支令牌,而这个预支的代价会转嫁给下一个请求,这样做的目的是为了减少线程阻塞,详细见后面源码分析stableIntervalMicros
:每产生一个令牌需要消耗的微秒数,这个值是根据构造器传入的permitsPerSecond
换算成微秒数得来(1秒=1000000微秒)maxPermits
:桶中允许存放的最大令牌数storedPermits
:桶中当前缓存的未消耗的令牌数,当令牌消耗速度小于令牌产生速度时,桶内就会开始堆积令牌,但是这个数不会大于maxPermits
通过这几个属性已经能看出reserveEarliestAvailable(int permits, long nowMicros)
方法的核心逻辑,详细代码如下:
@Override
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
// 1、根据nextFreeTicketMicros计算新产生的令牌数,更新当前未使用的令牌数storedPermits
resync(nowMicros);
long returnValue = nextFreeTicketMicros;
// 2、计算需要阻塞等待的时间
// 2.1 先从桶中取未消耗的令牌,如果桶中令牌数不足,看最多能取多少个
double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
// 2.2 计算是否需要等待新的令牌(当桶中现有的令牌数不足时就需要等待新的令牌),如果需要,则看需要等待的令牌数
double freshPermits = requiredPermits - storedPermitsToSpend;
// 计算需要等待的时间,需要注意的是这里用的是加号,所以是分两部分计算:waitMicros = 从桶中取storedPermitsToSpend个现有令牌代价 + 等待产生freshPermits个新令牌代价。取现成的令牌也是有代价的,storedPermitsToWaitTime方法是个抽象方法,在SmoothBursty和SmoothWarmingUp两个实现类里付出的代价不一样,详见后面源码分析,生产新令牌代价=令牌个数freshPermits * 每个令牌的耗时stableIntervalMicros
long waitMicros =
storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
+ (long) (freshPermits * stableIntervalMicros);
// 3、更新nextFreeTicketMicros
this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
// 4、扣减令牌数,更新桶内剩余令牌
this.storedPermits -= storedPermitsToSpend;
// 这里需要注意,returnValue被赋值的是上次的nextFreeTicketMicros,说明当前这次的代价是由下一个线程去承受
return returnValue;
}
// 计算从nextFreeTicketMicros到当前时间内新产生的令牌数,这个就是延迟计算
void resync(long nowMicros) {
// if nextFreeTicket is in the past, resync to now
if (nowMicros > nextFreeTicketMicros) {
// 时间差除以生产一个新令牌的耗时,而coolDownIntervalMicros() 是抽象方法,每个子类的具体逻辑不一样
double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
// 更新桶内的库存令牌个数,不超过最大限制
storedPermits = min(maxPermits, storedPermits + newPermits);
// 更新nextFreeTicketMicros为当前时间
nextFreeTicketMicros = nowMicros;
}
}
/**
* 从桶中取出库存令牌的代价,
* storedPermits:当前桶中的库存令牌
* permitsToTake:从库存令牌中取出的令牌数
* <p>This always holds: {@code 0 <= permitsToTake <= storedPermits}
*/
abstract long storedPermitsToWaitTime(double storedPermits, double permitsToTake);
/**
* 没生产一个新的令牌的耗时,由子类实现
*/
abstract double coolDownIntervalMicros();
复制代码
细节总结:
- 这个方法主要就是实现生产令牌、获取令牌、计算阻塞时间三个逻辑,而生产令牌和获取令牌是共性,而计算阻塞时间时,将总的阻塞时间拆成两部分,总的阻塞时间 = 从桶中取storedPermitsToSpend个现有令牌代价 + 等待产生freshPermits个新令牌代价,对于子类来说,产生新令牌代价是相同的,只有在获取现有令牌时代价是不同的,所以抽象了
abstract long storedPermitsToWaitTime(double storedPermits, double permitsToTake)
方法由SmoothBursty
和SmoothWarmingUp
两个子类两个子类来实现。详细见后续对两个子类的分别分析 - 获取令牌的阻塞代价转移给了下一个线程(详细逻辑见上面代码分析),也就是说当前线程请求令牌时,如果需要阻塞等待,那么这个等待时间由下一个线程去承受,这样做的目的是为了减少线程的阻塞,因为下一个线程的请求时间是不定的,可能过了很久才有下一个请求,而这段时间内产生的新令牌已经满足下一个线程的需求,那么就不用阻塞了,魔鬼在细节里
- 经过
RateLimiter
和SmoothRateLimiter
对核心方法acquire()
的一层层共性的抽取,最后抽象出子类间最核心的区别就是获取桶中库存令牌时的代价与产生新令牌的代价,所以抽象了两个方法由子类去实现,这份抽象的能力就是代码功力的体现
下面分别看下abstract long storedPermitsToWaitTime(double storedPermits, double permitsToTake)
和abstract double coolDownIntervalMicros()
方法在两个子类的实现逻辑。
SmoothBursty
令牌桶算法的实现,能应对突发流量,Bursty是突发的意思,这里应对突发流量是有前提条件的,只有在桶内有库存令牌的情况下,才会放行相应的突发流量,而桶内的库存令牌是低流量时省下来的,如果系统一直处于高流量,桶内没有库存令牌的话,当突发流量过来时,也只能按固定速率放行。
所以在这个类里,获取桶中的库存令牌是不需要额外的代价,当桶中令牌足够能满足请求线程的令牌需求时,就不会阻塞线程,从而达到应对突发流量的能力,而桶中库存令牌是有上限的,上限是通过构造器设置的
构造器源码解析:
public static RateLimiter create(double permitsPerSecond) {
/*
* The default RateLimiter configuration can save the unused permits of up to one second. This
* is to avoid unnecessary stalls in situations like this: A RateLimiter of 1qps, and 4 threads,
* all calling acquire() at these moments:
*
* T0 at 0 seconds
* T1 at 1.05 seconds
* T2 at 2 seconds
* T3 at 3 seconds
*
* Due to the slight delay of T1, T2 would have to sleep till 2.05 seconds, and T3 would also
* have to sleep till 3.05 seconds.
*/
return create(permitsPerSecond, SleepingStopwatch.createFromSystemTimer());
}
@VisibleForTesting
static RateLimiter create(double permitsPerSecond, SleepingStopwatch stopwatch) {
// 默认桶内库存的上限时1秒内产生的令牌数
RateLimiter rateLimiter = new SmoothBursty(stopwatch, 1.0 /* maxBurstSeconds */);
rateLimiter.setRate(permitsPerSecond);
return rateLimiter;
}
复制代码
permitsPerSecond
是每秒产生多少个令牌,stopwatch
是系统计时器,而new SmoothBursty(stopwatch, 1.0 /* maxBurstSeconds */)
硬编码指定了桶中最多存储1秒的令牌数,比如传入的permitsPerSecond
=10,那么令牌桶类最多存储10个令牌。
abstract long storedPermitsToWaitTime(double storedPermits, double permitsToTake)
源码分析
从桶中获取库存的令牌时,是不需要额外的等待时间的,直接返回0
@Override
long storedPermitsToWaitTime(double storedPermits, double permitsToTake) {
return 0L;
}
复制代码
abstract double coolDownIntervalMicros()
源码分析
固定返回stableIntervalMicros
值,在上面讲到这个这个值是根据构造器传入的permitsPerSecond
换算成微秒数得来
@Override
double coolDownIntervalMicros() {
return stableIntervalMicros;
}
复制代码
SmoothWarmingUp
该类支持预热功能,预热是对于冷系统来说的,当系统流量低的时候,系统内的线程池会释放多余线程、连接池会释放多余连接、缓存会过期失效,系统就会冷下来,这时候如果还放行满负荷流量甚至突发流量进入冷系统的话,系统压力会陡然上升,容易出现问题,这也是SmoothBursty
不足之处,因为在SmoothBursty
的实现逻辑里,系统冷下来的时候(流量低)桶内库存令牌会增多,此时有满负荷流量甚至突发流量进来时,SmoothBursty
会放行满负荷流量和突发流量,会对系统产生比较大的压力,所以这时候不能简单的根据桶内是否有库存令牌来放行流量了,需要再加一个维度:系统的冷热程度。
而简单的说就是:流量越低的时候,桶内堆积的令牌数就会越高(因为生产速度会大于消耗速度),系统就越冷,那么这时候的令牌生产速率就要越低,从而达到预热的目的。我们先结合图来看下该类预热功能的关键实现思路:
先解释下图内的关键参数含义:
coldIntervalMicros
:系统最冷时的令牌生产速率(这时候单位令牌的耗时最大),这个值会在后面分析到stableIntervalMicros
:稳定阶段每产生一个令牌需要消耗的微秒数,这个值是根据构造器传入的permitsPerSecond
换算成微秒数得来(1秒=1000000微秒)maxPermits
:桶中允许存放的最大令牌数storedPermits
:桶中当前缓存的未消耗的令牌数,当令牌消耗速度小于令牌产生速度时,桶内就会开始堆积令牌,但是这个数不会大于maxPermits
,这个值越大时,说明系统越冷thresholdPermits
:门槛,当桶内库存令牌数storedPermits
大于这个值时,说明系统冷下来了,需要进入预热阶段,加大生产单个令牌的耗时。相反则说明系统进入热系统阶段,可以按正常速率生产令牌
实现思路:X轴表示当前桶内库存令牌数,Y轴表示生产单个令牌的耗时,可以看到,当桶内库存令牌数大于thresholdPermits
这个门槛值时,系统进入预热阶段,对应的Y轴的单个令牌的耗时就会增加,当桶内令牌数达到上限maxPermits
时,系统处于最冷阶段,此时的单个令牌的耗时也就最长,这样就达到了预热的目的,了解实现思路后看下源码:
先看下构造方法:
public static RateLimiter create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) {
checkArgument(warmupPeriod >= 0, "warmupPeriod must not be negative: %s", warmupPeriod);
return create(
permitsPerSecond, warmupPeriod, unit, 3.0, SleepingStopwatch.createFromSystemTimer());
}
static RateLimiter create(
double permitsPerSecond,
long warmupPeriod,
TimeUnit unit,
double coldFactor,
SleepingStopwatch stopwatch) {
RateLimiter rateLimiter = new SmoothWarmingUp(stopwatch, warmupPeriod, unit, coldFactor);
rateLimiter.setRate(permitsPerSecond);
return rateLimiter;
}
复制代码
调用了一个包内访问权限的构造器,其中参数如下:
permitsPerSecond
:稳定阶段速率,即:稳定阶段每秒生成的令牌数warmupPeriod
:预热时间unit
:预热时间warmupPeriod的单位coldFactor
:冷却因子,这里固定是3.0,SmoothWarmingUp
类没有提供可以修改的构造器(唯一可以设置的构造器是包内调用的,外部类无法调用),这个参数决定了上图中coldInterval
值,3.0表示在系统最冷时的单位令牌的生产耗时是稳定阶段的3倍,比如稳定阶段速率permitsPerSecond
=10(每秒产生10个令牌),每产生一个令牌耗时是1秒,那么预热开始阶段每产生一个令牌的耗时是3秒stopwatch
:可以理解成计时器,记录限流的计时信息,通过计时信息来计算令牌的产生和消耗等信息
SmoothWarmingUp(
SleepingStopwatch stopwatch, long warmupPeriod, TimeUnit timeUnit, double coldFactor) {
super(stopwatch);
// 将warmupPeriod换算成微秒赋值给warmupPeriodMicros
this.warmupPeriodMicros = timeUnit.toMicros(warmupPeriod);
this.coldFactor = coldFactor;
}
复制代码
在这个构造方法中调用了rateLimiter.setRate(permitsPerSecond);
方法,根据上面初始器传入的参数初始化一些速率相关的重要参数:
void doSetRate(double permitsPerSecond, double stableIntervalMicros) {
double oldMaxPermits = maxPermits;
// 系统最冷时的令牌生产速,固定是正常速率的3倍(coldFactor固定是3.0)
double coldIntervalMicros = stableIntervalMicros * coldFactor;
// 门槛令牌数,上面已经讲到该值用途,这里默认就是整个预热时间除以正常速率的一半,太小的话会过早进入预热阶段,影响性能,太大的话会对系统产生压力,是一个取舍后的权衡值,可以不用纠结为什么是0.5
thresholdPermits = 0.5 * warmupPeriodMicros / stableIntervalMicros;
// 最大令牌数,这个计算逻辑详见后面分析
maxPermits =
thresholdPermits + 2.0 * warmupPeriodMicros / (stableIntervalMicros + coldIntervalMicros);
// 坡度,计算逻辑详见后面分析
slope = (coldIntervalMicros - stableIntervalMicros) / (maxPermits - thresholdPermits);
// 设置当前桶内的库存数,初始化时肯定也是系统最冷的时候,所以在初始化时,桶内默认就是maxPermits
if (oldMaxPermits == Double.POSITIVE_INFINITY) {
// if we don't special-case this, we would get storedPermits == NaN, below
storedPermits = 0.0;
} else {
storedPermits =
(oldMaxPermits == 0.0)
? maxPermits // initial state is cold
: storedPermits * maxPermits / oldMaxPermits;
}
}
复制代码
细节总结:
需要注意的一点是,默认认为限流器初始化的时候就是系统最冷的时候,所以此时桶内库存的令牌数等于maxPermits
,那么此时的图就是这种:
代码中的核心参数如下:
warmupPeriodMicros
:构造参数中传入的warmupPeriod
换算成微秒的值,将时间单位控制在微秒会让耗时更精确coldIntervalMicros
: 系统最冷时的令牌生产速率,coldIntervalMicros = coldFactor * warmupPeriodMicrosmaxPermits
:桶内最大令牌数,上面代码中的公式是maxPermits = thresholdPermits + 2.0 * warmupPeriodMicros / (stableIntervalMicros + coldIntervalMicros),2.0 * warmupPeriodMicros / (stableIntervalMicros + coldIntervalMicros)
是计算上图中预热阶段的产生的令牌数,这个用到初中物理知识(可以百度下匀变速求平均速度),在均匀加速过程中,求整个过程的平均速度 = (初速度+末速度)/2,结合上面的图,这里初速度就是stableIntervalMicros
,末速度就是coldIntervalMicros
,那么知道了平均速度和预热阶段总时间warmupPeriodMicros
,预热阶段产生的令牌数 =2*warmupPeriodMicros
/(stableIntervalMicros
+coldIntervalMicros
)slope
:坡度(单位:微秒),预热阶段是以固定速度来提速的,产生后一个令牌的耗时比产生上一个令牌的耗时要少slope微秒,这里要讲下slope的计算逻辑,代码中的计算逻辑解释成数学语言就是:已知预热阶段每个令牌的初始耗时coldIntervalMicros微秒、预热结束时的每个令牌耗时stableIntervalMicros微妙、整个预热阶段产生的令牌数(maxPermits - thresholdPermits),得出增速slope=(coldIntervalMicros - stableIntervalMicros) / (maxPermits - thresholdPermits)storedPermits
:默认认为限流器初始化的时候就是系统最冷的时候,此时的storedPermits = maxPermits
abstract long storedPermitsToWaitTime(double storedPermits, double permitsToTake)
源码分析
这里将令牌耗时分为两部分,一部分是预热阶段的令牌耗时,一部分是稳定阶段令牌的耗时,当请求令牌时,可能会混合这两种令牌,比如请求4个令牌,而这时桶内令牌数是22,门槛值是20,那么这4个令牌中有两个是预热期令牌,有两个是稳定期令牌,画成图如下:
蓝色区域表示要取的4个令牌,跨越了两个阶段,这时候就需要分别计算耗时
- 预热阶段的耗时就是上面
maxPermits
计算逻辑里讲到的公式,在均匀加速过程中,平均速度=(初速度+结束速度)/2,那么总耗时=平均速度*令牌数 = ((初速度+结束速度) *令牌数)/2 - 稳定阶段耗时 = 固定速率stableIntervalMicros * 令牌数
知道思路后看下源码:
long storedPermitsToWaitTime(double storedPermits, double permitsToTake) {
// 检查当前桶内库存的令牌数是否大于门槛值 thresholdPermits
double availablePermitsAboveThreshold = storedPermits - thresholdPermits;
long micros = 0;
// 超过的thresholdPermits,则说明当前系统已经冷下来了,需要进入预热期,计算预热期的令牌耗时
if (availablePermitsAboveThreshold > 0.0) {
// 计算在超过门槛值的令牌中需要取出多少个令牌,并计算耗时
double permitsAboveThresholdToTake = min(availablePermitsAboveThreshold, permitsToTake);
// 预热阶段的耗时
double length =
// 计算初始速度
permitsToTime(availablePermitsAboveThreshold)
// 计算结束速度
+ permitsToTime(availablePermitsAboveThreshold - permitsAboveThresholdToTake);
// 总耗时 = ((初速度+结束速度) *令牌数)/2
micros = (long) (permitsAboveThresholdToTake * length / 2.0);
permitsToTake -= permitsAboveThresholdToTake;
}
// 加上稳定阶段令牌的耗时就是总耗时
micros += (long) (stableIntervalMicros * permitsToTake);
return micros;
}
// 翻译成数学题就是:已知每生产一个令牌,下一个令牌的耗时就会固定增加slope微秒,那么在知道初始耗时stableIntervalMicros的情况下,求出生产第permits个令牌的耗时,很容易想到的公式就是:耗时=初始耗时stableIntervalMicros+(slope * permits)
private double permitsToTime(double permits) {
return stableIntervalMicros + permits * slope;
}
复制代码
abstract double coolDownIntervalMicros()
源码分析
@Override
double coolDownIntervalMicros() {
// 预热时长 / 最大令牌数
return warmupPeriodMicros / maxPermits;
}
复制代码
其实这个方法返回值和SmoothWarmingUp#coolDownIntervalMicros()
里的一样,都是固定速率stableIntervalMicros
,在上面的代码里可以找到这些公式:
maxPermits = thresholdPermits + 2.0 * warmupPeriodMicros / (stableIntervalMicros + coldIntervalMicros);
- thresholdPermits = 0.5 * warmupPeriodMicros / stableIntervalMicros
coldIntervalMicros
= stableIntervalMicros * coldFactorcoldFactor
固定是 3.0
那么maxPermits
可以转化成:
(0.5 * warmupPeriodMicros / stableIntervalMicros) + 2.0 * warmupPeriodMicros / (stableIntervalMicros + stableIntervalMicros *3.0)
得出:(0.5 * warmupPeriodMicros / stableIntervalMicros) + (warmupPeriodMicros / 2 * stableIntervalMicros)
得出:maxPermits = warmupPeriodMicros / stableIntervalMicros
得出:warmupPeriodMicros / maxPermits = stableIntervalMicros
最后的结果还是 stableIntervalMicros
,感觉被耍了...~_~!
总结
通过上面的分析可以看出,在SmoothBursty
中,桶内的库存令牌是可以直接拿来用的,不需要额外的耗时,以此来应对一些突发流量,但是这些库存令牌是前面低流量时遗留下来的,如果流量一直处于满负荷,没有结余的令牌,那么突发流量来的时候,仍然会被限流,且默认最大的令牌数就是1秒内产生的令牌,比如qps设置为10的话,那么桶内最多库存10个令牌,当qps=20的流量来时,也只够1秒钟的消耗,后面又会进入限流状态。
而SmoothWarmingUp
考虑的场景更复杂,弥补了SmoothBursty
不足。它将系统分为热系统和冷系统两个阶段,满负荷流量或者突发流量对于热系统来说,可能危害不大,因为系统的各种线程池、缓存、连接池在热系统下都是火力全开,抗压能力强,但是对于冷系统来说,满负荷的流量和突发流量都会加大系统压力,导致各种问题。所以加入预热的思路来控制冷系统下的流量,而系统的冷热程度就是通过桶内库存的未消耗的令牌数来判断,因为当系统冷下来时,也就是系统流量小的时候,令牌消耗速度就少,相应的桶内库存就会涨起来,过了门槛令牌数thresholdPermits
就会进入预热阶段,构思非常巧妙。并且在代码抽象上做的很彻底,将两种思路的限流器的共性尽可能的抽取后,由各自去实现差异逻辑。没有什么是不能通过增加一层抽象解决的,关键是发现抽象的能力~
这两种限流器都是预支令牌的思路,就是当前线程获取令牌的代价(阻塞时间)需要由下一个线程来承受,这样可以减少线程阻塞的概率,因为下一个请求什么时候来是不一定的,可能过了很久才有下一个请求,而这段时间内产生的新令牌已经满足下一个线程的需求,那么就不用阻塞了。
近期评论