文章目录

  • 常见四种限流算法
    • 固定窗口计数器
    • 滑动窗口计数器
    • 漏桶(也有称漏斗 Leaky bucket)
    • 令牌桶( Token bucket)
  • Sentinel源码举例
    • 滑动窗口
    • 漏桶
    • 令牌桶

常见四种限流算法

固定窗口计数器

固定窗口,相比其他的限流算法,这应该是最简单的一种。
它简单地对一个固定的时间窗口内的请求数量进行计数,如果超过请求数量的阈值,将被直接丢弃。
这个简单的限流算法优缺点都很明显。优点的话就是简单,缺点举个例子来说。
比如我们下图中的黄色区域就是固定时间窗口,默认时间范围是 60 秒,限流数量是 100。
如图中括号内所示,前面一段时间都没有流量,刚好后面 30 秒内来了 100 个请求,此时因为没有超过限流阈值,所以请求全部通过,然后下一个窗口的 20 秒内同样通过了 100 个请求。
所以变相的相当于在这个括号的 40 秒的时间内就通过了 200 个请求,超过了我们限流的阈值。

滑动窗口计数器

为了优化这个问题,于是有了滑动窗口算法。顾名思义,滑动窗口就是时间窗口在随着时间推移不停地移动。
滑动窗口把一个固定时间窗口再继续拆分成 N 个小窗口,然后对每个小窗口分别进行计数,所有小窗口请求之和不能超过我们设定的限流阈值。
以下图举例子来说:假设我们的窗口拆分成了 3 个小窗口,小窗口都是 20 秒,同样基于上面的例子,当在第三个 20 秒的时候来了 100 个请求,可以通过。
然后时间窗口滑动,下一个 20 秒请求又来了 100 个请求,此时我们滑动窗口的 60 秒范围内请求数量肯定就超过 100 了啊,所以请求被拒绝。

漏桶(也有称漏斗 Leaky bucket)

漏桶算法名副其实,就是一个漏的桶,不管请求的数量有多少,最终都会以固定的出口流量大小匀速流出。如果请求的流量超过漏桶大小,那么超出的流量将会被丢弃。
也就是说流量流入的速度是不定的,但是流出的速度是恒定的。
这个和 MQ 削峰填谷的思想比较类似,在面对突然激增的流量的时候,通过漏桶算法可以做到匀速排队,固定速度限流。
漏桶算法的优势是匀速,匀速是优点也是缺点,很多人说漏桶不能处理突增流量,这个说法并不准确。
漏桶本来就应该是为了处理间歇性的突增流量。流量一下起来了,然后系统处理不过来,可以在空闲的时候去处理,防止了突增流量导致系统崩溃,保护了系统的稳定性。
但是换一个思路来想,其实这些突增的流量对于系统来说完全没有压力,你还在慢慢地匀速排队,其实是对系统性能的浪费。
所以,对于这种有场景来说,令牌桶算法比漏桶就更有优势。

令牌桶( Token bucket)

令牌桶算法是指系统以一定地速度往令牌桶里丢令牌。当一个请求过来的时候,会去令牌桶里申请一个令牌,如果能够获取到令牌,那么请求就可以正常进行,反之被丢弃。
现在的令牌桶算法,像 Guava 和 Sentinel 的实现都有冷启动 / 预热的方式。为了避免在流量激增的同时把系统打挂,令牌桶算法会在最开始一段时间内冷启动,随着流量的增加,系统会根据流量大小动态地调整生成令牌的速度,直到最终请求达到系统阈值。

Sentinel源码举例

滑动窗口

Sentinel 中就使用到了滑动窗口算法来进行统计,不过它的实现和我上面画的图有点不一样。实际上 Sentinel 中的滑动窗口用一个圆形来描述更合理一点。
前期就是创建节点,然后 slot 串起来就是一个责任链模式。StatisticSlot 通过滑动窗口来统计数据,FlowSlot 是真正限流的逻辑。还有一些降级、系统保护的措施,最终形成了整个 Sentinel 的限流方式。

滑动窗口的实现主要可以看 LeapArray 的代码,默认的话定义了时间窗口的相关参数。

对于 Sentinel 来说其实窗口分为秒和分钟两个级别。秒级的话窗口数量是 2,分钟级则是 60 个窗口。每个窗口的时间长度是 1 秒,总的时间周期就是 60 秒,分成 60 个窗口,这里我们就以分钟级别的统计来说。


public abstract class LeapArray<T> {//窗口时间长度,毫秒数,默认1000msprotected int windowLengthInMs;//窗口数量,默认60protected int sampleCount;//毫秒时间周期,默认60*1000protected int intervalInMs;//秒级时间周期,默认60private double intervalInSecond;//时间窗口数组protected final AtomicReferenceArray<WindowWrap<T>> array;

然后我们要看的就是它是怎么计算出当前窗口的。其实源码里写得挺清楚,但是如果你按照之前想象把它当做一条直线延伸去想,估计不太好理解。

首先,计算数组索引下标和时间窗口时间这个都比较简单。难点应该大部分在于第三点,窗口大于 old 这个是什么鬼?

详细说下这几种情况:

数组中的时间窗口是是空的,这个说明时间走到了我们初始化的时间之后了,此时 new 一个新的窗口通过 CAS 的方式去更新,然后返回这个新的窗口就好了;
第二种情况是刚好时间窗口的时间相等,那么直接返回,没啥好说的;
第三种情况就是比较难以理解的,可以参看两条时间线的图,就比较好理解了。第一次时间窗口走完了达到 1200,然后圆形时间窗口开始循环,新的时间起始位置还是 1200。然后,时间窗口的时间来到 1676、B2 的位置如果还是老的窗口,那么就是 600。所以,我们要重置之前的时间窗口的时间为当前的时间;
最后一种一般情况不太可能发生,除非出现时钟回拨。

从这个我们可以发现就是针对每个 WindowWrap 时间窗口都进行了统计,最后实际上在后面的几个地方都会用到时间窗口统计的 QPS 结果。这里就不再赘述了,知道即可。


private int calculateTimeIdx(/*@Valid*/ long timeMillis) {long timeId = timeMillis / windowLengthInMs;// Calculate current index so we can map the timestamp to the leap array.return (int) (timeId % array.length());
}protected long calculateWindowStart(/*@Valid*/ long timeMillis) {return timeMillis - timeMillis % windowLengthInMs;
}public WindowWrap<T> currentWindow(long timeMillis) {//当前时间如果小于0,返回空if (timeMillis < 0) {return null;}//计算时间窗口的索引int idx = calculateTimeIdx(timeMillis);// 计算当前时间窗口的开始时间long windowStart = calculateWindowStart(timeMillis);while (true) {//在窗口数组中获得窗口WindowWrap<T> old = array.get(idx);if (old == null) {/**     B0       B1      B2    NULL      B4* ||_______|_______|_______|_______|_______||___* 200     400     600     800     1000    1200  timestamp*                             ^*                          time=888* 比如当前时间是888,根据计算得到的数组窗口位置是个空,所以直接创建一个新窗口就好了*/WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));if (array.compareAndSet(idx, null, window)) {// Successfully updated, return the created bucket.return window;} else {// Contention failed, the thread will yield its time slice to// wait for bucket available.Thread.yield();}} else if (windowStart == old.windowStart()) {/**     B0       B1      B2     B3      B4* ||_______|_______|_______|_______|_______||___* 200     400     600     800     1000    1200  timestamp*                             ^*                          time=888* 这个更好了,刚好等于,直接返回就行*/return old;} else if (windowStart > old.windowStart()) {/**     B0       B1      B2     B3      B4* |_______|_______|_______|_______|_______||___* 200     400     600     800     1000    1200  timestamp*             B0       B1      B2    NULL      B4* |_______||_______|_______|_______|_______|_______||___* ...    1200     1400    1600    1800    2000    2200  timestamp*                              ^*                           time=1676* 这个要当成圆形理解就好了,之前如果是1200一个完整的圆形,然后继续从1200开始,如果现在时间是1676,落在在B2的位置,* 窗口开始时间是1600,获取到的old时间其实会是600,所以肯定是过期了,直接重置窗口就可以了*/if (updateLock.tryLock()) {try {// Successfully get the update lock, now we reset the// bucket.return resetWindowTo(old, windowStart);} finally {updateLock.unlock();}} else {Thread.yield();}} else if (windowStart < old.windowStart()) {// 这个不太可能出现,嗯。。时钟回拨return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));}}
}

漏桶

Sentinel 主要根据 FlowSlot 中的流控进行流量控制,其中 RateLimiterController 就是漏桶算法的实现,这个实现相比其他几个还是简单多了,稍微看一下应该就明白了。

首先计算出当前请求平摊到 1 秒内的时间花费,然后去计算这一次请求预计时间;
如果小于当前时间的话,那么以当前时间为主,返回即可;
反之如果超过当前时间的话,这时候就要进行排队等待了。等待的时候要判断是否超过当前最大的等待时间,超过就直接丢弃;
没有超过就更新上一次的通过时间,然后再比较一次是否超时。如果还超时就重置时间,反之在等待时间范围之内的话就等待。如果都不是,那就可以通过了。

public class RateLimiterController implements TrafficShapingController {//最大等待超时时间,默认500msprivate final int maxQueueingTimeMs;//限流数量private final double count;//上一次的通过时间private final AtomicLong latestPassedTime = new AtomicLong(-1);@Override public boolean canPass(Node node, int acquireCount, boolean prioritized) {// Pass when acquire count is less or equal than 0.if (acquireCount <= 0) {return true;}// Reject when count is less or equal than 0.// Otherwise,the costTime will be max of long and waitTime will overflow// in some cases.if (count <= 0) {return false;}long currentTime = TimeUtil.currentTimeMillis();//时间平摊到1s内的花费long costTime = Math.round(1.0 * (acquireCount) / count * 1000); // 1 / 100 * 1000 = 10ms//计算这一次请求预计的时间long expectedTime = costTime + latestPassedTime.get();//花费时间小于当前时间,pass,最后通过时间 = 当前时间if (expectedTime <= currentTime) {latestPassedTime.set(currentTime);return true;}else {//预计通过的时间超过当前时间,要进行排队等待,重新获取一下,避免出现问题,差额就是需要等待的时间long waitTime = costTime + latestPassedTime.get()- TimeUtil.currentTimeMillis();//等待时间超过最大等待时间,丢弃if (waitTime > maxQueueingTimeMs) {return false;} else {//反之,可以更新最后一次通过时间了long oldTime = latestPassedTime.addAndGet(costTime);try {waitTime = oldTime - TimeUtil.currentTimeMillis();//更新后再判断,还是超过最大超时时间,那么就丢弃,时间重置if (waitTime > maxQueueingTimeMs) {latestPassedTime.addAndGet(-costTime);return false;}//在时间范围之内的话,就等待if (waitTime > 0) {Thread.sleep(waitTime);}return true;} catch (InterruptedException e) {}}}return false;}
}

令牌桶

最后是令牌桶,这个不在于实现的复制,而是你看源码会发现都算的些啥玩意儿……
Sentinel 的令牌桶实现基于 Guava,代码在 WarmUpController 中。
这个算法那些各种计算逻辑其实我们可以不管,流程上清晰就可以了。
几个核心的参数看注释,构造方法里那些计算逻辑暂时不管他是怎么算的,关键看 canPass 是怎么做的。
拿到当前窗口和上一个窗口的 QPS;
填充令牌,也就是往桶里丢令牌。
然后,我们先看填充令牌的逻辑。

public class WarmUpController implements TrafficShapingController {//限流QPSprotected double count;//冷启动系数,默认=3private int coldFactor;//警戒的令牌数protected int warningToken = 0;//最大令牌数private int maxToken;//斜率,产生令牌的速度protected double slope;//存储的令牌数量protected AtomicLong storedTokens = new AtomicLong(0);//最后一次填充令牌时间protected AtomicLong lastFilledTime = new AtomicLong(0);public WarmUpController(double count, int warmUpPeriodInSec, int coldFactor) {construct(count, warmUpPeriodInSec, coldFactor);}public WarmUpController(double count, int warmUpPeriodInSec) {construct(count, warmUpPeriodInSec, 3);}private void construct(double count, int warmUpPeriodInSec, int coldFactor) {if (coldFactor <= 1) {throw new IllegalArgumentException("Cold factor should be larger than 1");}this.count = count;this.coldFactor = coldFactor;// stableInterval 稳定产生令牌的时间周期,1/QPS// warmUpPeriodInSec 预热/冷启动时间 ,默认 10swarningToken = (int) (warmUpPeriodInSec * count) / (coldFactor - 1);maxToken = warningToken+ (int) (2 * warmUpPeriodInSec * count / (1.0 + coldFactor));//斜率的计算参考Guava,当做一个固定改的公式slope = (coldFactor - 1.0) / count / (maxToken - warningToken);}@Override public boolean canPass(Node node, int acquireCount, boolean prioritized) {//当前时间窗口通过的QPSlong passQps = (long) node.passQps();//上一个时间窗口QPSlong previousQps = (long) node.previousPassQps();//填充令牌syncToken(previousQps);// 开始计算它的斜率// 如果进入了警戒线,开始调整他的qpslong restToken = storedTokens.get();if (restToken >= warningToken) {//当前的令牌超过警戒线,获得超过警戒线的令牌数long aboveToken = restToken - warningToken;// 消耗的速度要比warning快,但是要比慢// current interval = restToken*slope+1/countdouble warningQps =Math.nextUp(1.0 / (aboveToken * slope + 1.0 / count));if (passQps + acquireCount <= warningQps) {return true;}} else {if (passQps + acquireCount <= count) {return true;}}return false;}
}

填充令牌的逻辑如下:
拿到当前的时间,然后去掉毫秒数得到的就是秒级时间;
判断时间小于这里就是为了控制每秒丢一次令牌;
然后就是 coolDownTokens 去计算我们的冷启动 / 预热是怎么计算填充令牌的;
后面计算当前剩下的令牌数,这个就不说了。减去上一次消耗的就是桶里剩下的令牌。


protected void syncToken(long passQps) {long currentTime = TimeUtil.currentTimeMillis();//去掉当前时间的毫秒currentTime = currentTime - currentTime % 1000;long oldLastFillTime = lastFilledTime.get();//控制每秒填充一次令牌if (currentTime <= oldLastFillTime) {return;}//当前的令牌数量long oldValue = storedTokens.get();//获取新的令牌数量,包含添加令牌的逻辑,这就是预热的逻辑long newValue = coolDownTokens(currentTime, passQps);if (storedTokens.compareAndSet(oldValue, newValue)) {//存储的令牌数量当然要减去上一次消耗的令牌long currentValue = storedTokens.addAndGet(0 - passQps);if (currentValue < 0) {storedTokens.set(0L);}lastFilledTime.set(currentTime);}
}
  1. 最开始的事实因为 lastFilledTime 和 oldValue 都是 0,所以根据当前时间戳会得到一个非常大的数字。最后,和 maxToken 取小的话就得到了最大的令牌数。所以第一次初始化的时候就会生成 maxToken 的令牌;
  2. 之后我们假设系统的 QPS 一开始很低,然后突然飙高。所以,开始的时候回一直走到高于警戒线的逻辑里去,然后 passQps 又很低。所以,会一直处于把令牌桶填满的状态(currentTime - lastFilledTime.get() 会一直都是 1000,也就是 1 秒),所以每次都会填充最大 QPScount 数量的令牌;
  3. 然后突增流量来了,QPS 瞬间很高。慢慢地令牌数量就会消耗到警戒线之下,走到我们 if 的逻辑里去,然后去按照 count 数量增加令牌。
private long coolDownTokens(long currentTime, long passQps) {long oldValue = storedTokens.get();long newValue = oldValue;//水位低于警戒线,就生成令牌if (oldValue < warningToken) {//如果桶中令牌低于警戒线,根据上一次的时间差,得到新的令牌数,因为去掉了毫秒,1秒生成的令牌就是阈值count//第一次都是0的话,会生成count数量的令牌newValue = (long) (oldValue+ (currentTime - lastFilledTime.get()) * count / 1000);}else if (oldValue > warningToken) {//反之,如果是高于警戒线,要判断QPS。因为QPS越高,生成令牌就要越慢,QPS低的话生成令牌要越快if (passQps < (int) count / coldFactor) {newValue = (long) (oldValue+ (currentTime - lastFilledTime.get()) * count / 1000);}}//不要超过最大令牌数return Math.min(newValue, maxToken);
}

上面的逻辑理顺之后,我们就可以继续看限流的部分逻辑:

  1. 令牌计算的逻辑完成,然后判断是不是超过警戒线。按照上面的说法,低 QPS 的状态肯定是一直超过的,所以会根据斜率来计算出一个 warningQps。因为我们处于冷启动的状态,所以这个阶段就是要根据斜率来计算出一个 QPS 数量,让流量慢慢地达到系统能承受的峰值。举个例子,如果 count 是 100,那么在 QPS 很低的情况下,令牌桶一直处于满状态。但是系统会控制 QPS,实际通过的 QPS 就是 warningQps,根据算法可能只有 10 或者 20(怎么算的不影响理解)。QPS 主键提高的时候,aboveToken 再逐渐变小,整个 warningQps 就在逐渐变大。直到走到警戒线之下,到了 else 逻辑里;
  2. 流量突增的情况,就是 else 逻辑里低于警戒线的情况,我们令牌桶在不停地根据 count 去增加令牌。此时消耗令牌的速度超过我们生成令牌的速度,可能就会导致一直处于警戒线之下。这时候判断当然就需要根据最高 QPS 去判断限流了。
long restToken = storedTokens.get();
if (restToken >= warningToken) {//当前的令牌超过警戒线,获得超过警戒线的令牌数long aboveToken = restToken - warningToken;// 消耗的速度要比warning快,但是要比慢// current interval = restToken*slope+1/countdouble warningQps = Math.nextUp(1.0 / (aboveToken * slope + 1.0 / count));if (passQps + acquireCount <= warningQps) {return true;}
} else {if (passQps + acquireCount <= count) {return true;}
}

所以,按照低 QPS 到突增高 QPS 的流程,来想象一下这个过程:

  1. 刚开始,系统的 QPS 非常低,初始化我们就直接把令牌桶塞满了;
  2. 然后这个低 QPS 的状态持续了一段时间,因为我们一直会填充最大 QPS 数量的令牌(因为取最小值,所以其实桶里令牌基本不会有变化),所以令牌桶一直处于满的状态,整个系统的限流也处于一个比较低的水平。这以上的部分一直处于警戒线之上。实际上就是叫做冷启动 / 预热的过程;
  3. 接着系统的 QPS 突然激增,令牌消耗速度太快。就算我们每次增加最大 QPS 数量的令牌任然无法维持消耗,所以桶里的令牌在不断低减少。这个时候,冷启动阶段的限制 QPS 也在不断地提高,最后直到桶里的令牌低于警戒线;
  4. 低于警戒线之后,系统就会按照最高 QPS 去限流,这个过程就是系统在逐渐达到最高限流的过程。那这样一来,实际就达到了我们处理突增流量的目的,整个系统在漫漫地适应突然飙高的 QPS,然后最终达到系统的 QPS 阈值;
  5. 最后,如果 QPS 回复正常,那么又会逐渐回到警戒线之上,就回到了最开始的过程。

Sentinel限流算法详解(硬啃)相关推荐

  1. Nginx源码研究之nginx限流模块详解

    这篇文章主要介绍了Nginx源码研究之nginx限流模块详解,小编觉得挺不错的,现在分享给大家,也给大家做个参考.一起跟随小编过来看看吧 高并发系统有三把利器:缓存.降级和限流: 限流的目的是通过对并 ...

  2. Guava-RateLimiter秒杀限流技术详解

    使用场景 系统使用下游资源时,需要考虑下游对资源受限.处理能力,在下游资源无法或者短时间内无法提升处理性能的情况下,可以使用限流器或者类似保护机制,避免下游服务崩溃造成整体服务的不可用. 常用算法 常 ...

  3. 微服务架构服务限流方案详解

    话说在 Spring Cloud Gateway 问世之前,Spring Cloud 的微服务世界里,网关一定非 Netflix Zuul 莫属.但是由于 Zuul 1.x 存在的一些问题,比如阻塞式 ...

  4. Django REST Framework教程(10): 限流(throttle)详解与示例

    在前面的DRF系列教程中,我们以博客为例介绍了序列化器(Serializer), 并使用APIView和ModelViewSet开发了针对文章资源进行增删查改的完整API端点,并详细对权限.认证(含j ...

  5. 详解4种经典的限流算法

    最近,我们的业务系统引入了Guava的RateLimiter限流组件,它是基于令牌桶算法实现的,而令牌桶是非常经典的限流算法.本文将跟大家一起学习几种经典的限流算法. 限流是什么? 维基百科的概念如下 ...

  6. 算法高级(7)-限流(Rate limit)算法详解

    一.前言 保障服务稳定的三大利器:熔断降级.服务限流和故障模拟.今天和大家谈谈限流算法的几种实现方式,本文所说的限流并非是Nginx层面的限流,而是业务代码中的逻辑限流. 那么为什么需要限流呢? 按照 ...

  7. 一文详解四种经典限流算法,面试必备。

    前言 最近一位朋友去拼夕夕面试,被问了这么一道题:限流算法有哪些?用代码实现令牌桶算法.跟好友讨论了一波,发现大家都忘记得差不多了.所以再整理一波,常见的四种限流算法,以及简单代码实现,相信大家看完, ...

  8. 算法高级(8)-Hystrix实现熔断、限流与服务保护中的算法详解

    上一章讲了常见的限流算法,本章我们来看看,Spring Cloud中的Hystrix组件在对请求进行熔断.限流与服务保护操作时的算法实践. 一.雪崩 分布式系统环境下,服务间依赖非常常见,一个业务调用 ...

  9. Sentinel滑动时间窗限流算法

    Sentinel系列文章 Sentinel熔断限流器工作原理 Sentinel云原生K8S部署实战 Sentinel核心源码解析 时间窗限流算法 如图 10-20这个时间窗内请求数量是60小于阈值10 ...

最新文章

  1. UIButton文字居左显示
  2. C++中随机函数rand()和srand()的用法
  3. 如何中断JAVA线程
  4. ViewController类中得方法和属性的用途
  5. SDUTOJ 【1166】打印直角三角形
  6. 了解Spring Web初始化
  7. SSH框架整合——基于XML配置文件
  8. sql注入之——sqlmap教程
  9. C++ 文件输入输出问题
  10. jacob调用word宏
  11. 检测计算机无线网卡驱动,win10怎么修复无线网卡驱动 无线网卡驱动修复方法
  12. 智能优化及其相关算法
  13. win7文件共享服务器搭建,Win7下搭建web服务器实现数据共享的简单步骤
  14. javax.mail 发送163邮件
  15. 2022年最新文本生成图像研究 开源工作速览(Papers with code)
  16. alpha-beta剪枝五子棋c语言,五子棋AI算法第三篇-Alpha Beta剪枝
  17. 如何跟猎头有效的沟通?
  18. OpenMP编程指南
  19. 【超全面】机器学习中的超参优化方法总结
  20. 一夜成名的航班追踪网站,什么来头?

热门文章

  1. AttributeError: module transformers has no attribute LLaMATokenizer解决方案
  2. 解决esxi主机vmware 无法清除磁盘的报错
  3. 这也太让人大开眼界了,你有没有见过的这样spring boot项目启动图案
  4. Linux安装包-run制作
  5. U盘和移动硬盘用什么文件系统?
  6. 使用 Tetra 构建全栈应用程序
  7. 深度|神经网络和深度学习简史(第一部分):从感知机到BP算法
  8. 迈出代码可测试性的第一步
  9. 计算机在地理测绘领域的应用,浅谈地理信息系统在测绘领域的扩展应用
  10. linux 修改ramdisk内容,修改linux ramdisk大小