1. 引言
Hi,你好,我是有清
对于刚经历过双 11 的电商人来说,限流这个词肯定在 10.24 的晚 20.00 点被提起过
限流作为保护我们系统不被流量冲垮的手段之一,建议每个电商人深入了解学习,什么,你不是电商人,那你也得了解一下,不然怎么在金三银四和面试官大胆对线
目前市面上比较流行的流量治理框架是 Sentinel,在本文中我们先复习一下常见八股-限流算法有哪些,然后再理解一下 Sentinel 的是如何使用滑动窗口
2. 常见限流算法
2.1. 令牌桶
令牌桶顾名思义,具有一个桶存放着令牌,系统会以恒定的速率往桶里放令牌,拿到令牌的请求才可进行后续操作,如果你没有拿到,sorry,你的请求将被抛弃,如图所示
无标题-2023-08-07-1113
我们可以借助 Guava 的 RateLimiter 来实现令牌桶,优点在于使用令牌桶放过的流量比较均匀,有利于保护系统不被流量冲垮;当然令牌桶的弊端在于,对于持续的峰值流量无法应对。由于令牌桶算法是以恒定速率添加令牌,当持续时间内产生大量请求时,可能无法及时获取到足够的令牌,导致请求被拒绝
2.2. 漏桶
漏桶算法,我们可以理解为存在一个水龙头持续往桶里滴水,然后这个桶可以匀速往外滴水,平移到我们的项目实践中,即我们可以维护一个有界队列作为漏桶,用来承接进来的网络请求,系统均匀处理队列中的网络请求,一旦队列满了,就触发限流策略,如图所示
漏桶算法的弊端在于无法处理突如其来的大流量,假设我们当前处理的速率为 1000 qps ,桶容量 5000 ,现在来了一波持续 10s 的 2000 qps,那么后几秒的网络请求将都会被抛弃
2.3. 固定窗口
固定窗口算法即系统会维护一个计数器,在固定的时间段内,流量进来,计数器计数,一旦超过上限则进行限流相关拒绝策略,在下一个窗口计数器又将会被置零,如图所示
固定
固定窗口的算法弊端在于:我们统一假设当前的窗口限制为 1000 qps 的流量,窗口间隔为 5s。第一个弊端在于边界问题:在第5 s 和第 6 s,分别进来了1000qps 流量,相当于窗口切换的 0.1 s 内,系统接受了 2000 qps 的流量,很容易,piaji,系统挂了;第二个问题在于流量突发的情况,在第一秒进来了 1001 的 qps,那么在 4 - 5 s 的时间内,系统流量都将被限制,带给用户的感觉就是:这个系统怎么这么垃圾
2.4. 滑动窗口
滑动窗口算法,其实就是为了解决固定窗口的弊端,大窗口依然不变,但是大窗口内会分为 n 个小窗口,每个小窗口内维护计数器,大窗口随着时间的移动,不断抛弃和拾取小窗口,从而达到限流的目的。
Snipaste_2023-11-05_11-35-34
滑动窗口依然无法避免边界问题,并且小窗口数需要开发者进行仔细的考量
3. 滑动窗口核心实现类
铺垫了这么久,终于要进入正戏了
Sentinel 目前采取的就是滑动窗口算法,根据上文的介绍我们来一起分析一下滑动窗口的核心实现类有哪些
- LeapArray:leap 四级单词,务必掌握,这个单词意思是间隔,leapArray 即为间隔数组,我们可以简单理解为一个大窗口,大窗口可以包含小窗口,小窗口的数量为 sampleCount 、间隔时间大小 windowLengthInMs ,都是由此数据结构控制,再来一个数学公式: sampleCount = intervalInMs / windowLengthInMs
提问 intervalInMs 代表什么含义?
- WindowWrap:wrap 四级单词,务必掌握,包裹,整体单词意思为 窗口包装类,理解为一个小窗口
- Metric:继续来学习单词,这个不知道是几级单词因为我也不会,理解为 指标,该接口用来标识一些指标信息,诸如 qps、rt、tps 等等
- ArrayMetric:已经有聪明的同学开始抢答了,该类意为数组指标,即我们滑动窗口的核心实现类,对,就是男一号
- MetricBucket:指标桶,用来滑动窗口中实际统计数据
todo:补充一个 uml 类图
4. 滑动窗口实现原理
在核心类中我们认识了 ArrayMetric 。接下来我们就围绕着 ArrayMetric 展开说明 Sentinel 的限流实现原理
4.1. ArrayMetric 构造
我们先看下如何构造 ArrayMetric
arduino
public ArrayMetric(int sampleCount, int intervalInMs) {
this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs);
}
public ArrayMetric(int sampleCount, int intervalInMs, boolean enableOccupy) {
if (enableOccupy) {
this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs);
} else {
this.data = new BucketLeapArray(sampleCount, intervalInMs);
}
}
ArrayMetric 提供了两个构造方法,我们先来看一下参数,sampleCount 在上文提到的即为小窗口数,继续搬出我们的公式:sampleCount = intervalInMs / windowLengthInMs,intervalInMs 即每个大窗口的间隔时间,enableOccupy 意为是否允许抢占,即是否允许抢占下一个窗口的资源,允许的话,构造的子类即为 OccupiableBucketLeapArray,否则为 BucketLeapArray,具体二者区别我们下文再展开
在 ArrayMetric 方法中,无论是 pass、rt 还是 block ,都需要获取当前小窗口信息 ,调用的方法为 data.currentWindow();
csharp
/**
* Get the bucket at current timestamp.
*
* @return the bucket at current timestamp
*/
public WindowWrap<T> currentWindow() {
return currentWindow(TimeUtil.currentTimeMillis());
}
通过注释我们可以看出,该方法是根据当前时间戳,获取小窗口信息
继续点进下一步实现类之前,我们可以先想一下,如果是我们去写这样一个获取小窗的方法,我们会怎么实现?
是不是需要取获取到当前时间戳命中的窗口下标?如果其他线程已经创建过同等的时间戳窗口是否可以直接复用?如果当前时间戳大于之前已经生成的窗口的时间戳,如何处理?
4.2. 获取当前窗口
带着这几个问题,我们继续看下源码
csharp
public WindowWrap<T> currentWindow(long timeMillis) {
if (timeMillis < 0) {
return null;
}
int idx = calculateTimeIdx(timeMillis);
long windowStart = calculateWindowStart(timeMillis);
while (true) {
WindowWrap<T> old = array.get(idx);
if (old == null) {
WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
if (array.compareAndSet(idx, null, window)) {
return window;
} else {
Thread.yield();
}
} else if (windowStart == old.windowStart()) {
return old;
} else if (windowStart > old.windowStart()) {
if (updateLock.tryLock()) {
try {
return resetWindowTo(old, windowStart);
} finally {
updateLock.unlock();
}
} else {
Thread.yield();
}
} else if (windowStart < old.windowStart()) {
return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
}
}
}
首先生成窗口下标
arduino
int idx = calculateTimeIdx(timeMillis);
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());
}
来,继续做数学题,timeMillis 意味当前时间戳,windowLengthInMs 小窗间隔时间,假设当前时间戳为 666,小窗间隔时间为 200,看图 👇
Snipaste_2023-11-04_16-34-19
接下来计算小窗的起始时间
arduino
protected long calculateWindowStart(/*@Valid*/ long timeMillis) {
return timeMillis - timeMillis % windowLengthInMs;
}
小窗的起始时间计算的方法其实很简单了 666 - 666 % 200 = 600 ,对照上图,一目了然
接下来分成三种情况,我们一一来讨论一下
- 不存在旧窗口
这种情况,比较简单,我们直接生成新窗口即可,此处采取了 CAS 来进行窗口生成,保证线程一致
dart
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();
}
- 命中旧窗口
这种情况就更简单了,直接返回旧窗口即可
- 当前时间戳大于旧窗口时间戳
这种情况是当 A 线程生成小窗的时候时间戳命中了 B1 窗口,此时 B 线程的时间戳命中 B5 窗口,即当前窗口就为 B5,需要进行窗口重置,我们来看代码
java
if (updateLock.tryLock()) {
try {
// Successfully get the update lock, now we reset the bucket.
return resetWindowTo(old, windowStart);
} finally {
updateLock.unlock();
}
} else {
// Contention failed, the thread will yield its time slice to wait for bucket available.
Thread.yield();
}
@Override
protected WindowWrap<MetricBucket> resetWindowTo(WindowWrap<MetricBucket> w, long startTime) {
// Update the start time and reset value.
w.resetTo(startTime);
w.value().reset();
return w;
}
这边可以看到处理的方式就是将当前的时间的起始时间和统计值全部进行重置处理
其实还有一种情况,就是当前时间小于旧窗口的起始时间,但是一般不存在这种情况,我们不进行讨论
4.3. 获取上一个窗口
获取上一个窗口的实现类中,同样是取去计算窗口的下标,但是计算下标的时候传入的不是当前的时间戳,而是减去一个小窗间隔的时间戳
ini
public WindowWrap<T> getPreviousWindow(long timeMillis) {
if (timeMillis < 0) {
return null;
}
int idx = calculateTimeIdx(timeMillis - windowLengthInMs);
timeMillis = timeMillis - windowLengthInMs;
WindowWrap<T> wrap = array.get(idx);
if (wrap == null || isWindowDeprecated(wrap)) {
return null;
}
if (wrap.windowStart() + windowLengthInMs < (timeMillis)) {
return null;
}
return wrap;
}
4.4. 窗口是否废弃
如果当前的时间减去窗口的起始时间大于一整个大窗口的时间,即该窗口已失效
arduino
public boolean isWindowDeprecated(long time, WindowWrap<T> windowWrap) {
return time - windowWrap.windowStart() > intervalInMs;
}
4.5. OccupiableBucketLeapArray
LeapArray 的重点方法我们都分析完毕了,我们看下子类针对于这些方法是否有进行重写
4.5.1. 构造方法
其实在上文我们已经看到过构造 OccupiableBucketLeapArray 需要 sampleCount 和 intervalInMs,但其实真正构造 OccupiableBucketLeapArray,还会去构造一个 FutureBucketLeapArray 对象,该对象也是继承 LeapArray,结合上文说的抢占的意思,可以推测出这是一个未来时间窗口的 LeapArray
java
private final FutureBucketLeapArray borrowArray;
public OccupiableBucketLeapArray(int sampleCount, int intervalInMs) {
// This class is the original "CombinedBucketArray".
super(sampleCount, intervalInMs);
this.borrowArray = new FutureBucketLeapArray(sampleCount, intervalInMs);
}
4.5.2. newEmptyBucket
newEmptyBucket 顾名思义,用来创建一个新的空的小窗,它作用的地方在于我们获取当前窗口的时候,看个图
这边的实现也很简单,借助到我们在构造函数的时候生成的 borrowArray,如果 borrowArray 存在当前时间戳的数据,则直接拿到 borrowArray 中的计数数据
ini
public MetricBucket newEmptyBucket(long time) {
MetricBucket newBucket = new MetricBucket();
MetricBucket borrowBucket = borrowArray.getWindowValue(time);
if (borrowBucket != null) {
newBucket.reset(borrowBucket);
}
return newBucket;
}
4.5.3. resetWindowTo
重置窗口在上文中我们已经介绍过了,在该类实现中,其实也就是判断是否 borrowArray 是否存在数据,存在的话,需要加上 borrowArray 中的通过线程数
scss
protected WindowWrap<MetricBucket> resetWindowTo(WindowWrap<MetricBucket> w, long time) {
// Update the start time and reset value.
w.resetTo(time);
MetricBucket borrowBucket = borrowArray.getWindowValue(time);
if (borrowBucket != null) {
w.value().reset();
w.value().addPass((int)borrowBucket.pass());
} else {
w.value().reset();
}
return w;
}
4.6. 滑动流程
接下来我们整体看一下限流流程
首先我们假设构造小窗数量为 2,小窗间隔时间为 500 ms 的 LeapArray
Snipaste_2023-11-05_11-36-46
当时间戳通过 currentWindow 命中 windowWrap-1,构造窗口,当时间戳命中 windowWrap-2,构造窗口,这边会看构造的是 OccupiableBucketLeapArray 亦或是BucketLeapArray
当时间往下走,大于 1s,可能时间戳又再次命中 windowWrap-1,此时就需要 resetWindowTo,同样针对不同的实现类有不同的方法
这就是滑动窗口在 Sentinel 的运用,easy 哇!
4.7 总结
滑动窗口的实现原理就是在于窗口的构造与判断,其实整体流程还是相对来说比较简单,主要就是理解其运用的数据结构,本文其实没有针对 BucketLeapArray 展开说明,感兴趣的小伙伴可以自己去扒拉一下源码