为什么使用滑动窗口
Sentinel 使用滑动窗口算法来进行限流是为了实现更精细和实时的流量控制。这种方法允许系统更灵活、更精确地处理突发流量,保证资源的合理分配,并且降低了服务因流量突增而超载的风险。下面详细解释 Sentinel 使用滑动窗口进行限流的原理和优势。
滑动窗口原理
滑动窗口是一种常用的算法,用于统计一段时间内的事件或数据点。在限流场景中,滑动窗口通过将时间窗口分割成多个小的时间片段(通常称为桶),每个时间片段独立统计进入的请求量。随着时间的推移,最旧的时间片段的数据会被新的时间片段替换,形成“滑动”的效果。
实现细粒度的时间控制
使用滑动窗口算法可以更精细地控制时间窗口内的流量。与固定窗口(整个时间窗口只统计一次)相比,滑动窗口通过连续滑动减少了窗口切换时的流量突变,避免了请求在窗口刚开始时因为累积的计数而被误判为超限。
减少突发流量的影响
在实际应用中,流量往往呈现出突发性特征。如果使用固定窗口算法,在窗口重置的瞬间可能会接受大量请求,造成短时间内的服务压力。滑动窗口通过平滑时间窗口的边缘,可以更均匀地分布请求,从而降低了因突发请求导致的系统压力。
提高系统响应的实时性
滑动窗口提供了实时更新的流量数据,使得系统能够基于最近的流量情况做出快速响应。这对于需要快速适应流量变化的在线服务尤其重要,可以即时调整资源分配和访问策略。
保证服务的稳定性和可靠性
通过精确控制每个时间片段内的流量,滑动窗口算法帮助确保了服务的稳定性和可靠性,避免因超负载而导致的服务不可用或响应延迟增加。
Sentinel是如何实现的
滑动窗口LeapArray
�对应的是这个:
按照图上那就是 样本窗口为10s,样本窗口数为6,以秒为单位的滑动窗口跨越时间长度为60s(1分钟),里面有多个WindowWrap(样本窗口)
样本窗口
对应的是这个:
窗口长度为10s,窗口开始时间计算出来的,后面会分析到。窗口数据为MetricBucket,这个窗口数据存储一些数据:比如通过的请求多少、阻塞多少、成功了多少等等
MetricBucket
MetricBucket 是用于存储度量数据的基本数据结构。它是性能监控和流量统计的核心组成部分,用于记录各种运行时的指标。每个 MetricBucket 通常包括以下几种类型的计数器,这些计数器通过 LongAdder 类型来实现,以支持高并发下的快速更新。
每个LongAdder你可以理解为存储一类的数据:
- 通过请求数(Passed Requests)
- 记录在监控窗口内成功通过 Sentinel 检查的请求数量。这包括所有没有被限流或降级的请求。
- 阻塞请求数(Blocked Requests)
- 记录因达到预定义的限流条件而被 Sentinel 阻塞的请求数量。这些是那些被限流规则阻止执行的请求。
- 成功执行的请求数(Successful Requests)
- 记录在监控窗口内成功执行并正常完成的请求数量。这些请求没有在执行过程中抛出任何异常。
- 异常请求数(Exception Requests)
- 记录在执行过程中抛出异常的请求数量。这些是因服务异常(如抛出异常)而未成功完成的请求。
- 响应时间(Response Time)
- 累加每个请求的处理时间,用于计算在监控窗口内请求的平均响应时间。这有助于监控服务的性能表现。
��
源码剖析
入口:StatisticSlot
在之前分析StatisticSlot讲过,它会往后执行,等执行完后会执行到这里的node.addPassRequest(count);
�
�DefaultNode.addPassRequest
这里面有两个滑动窗口,一个是秒级别的滑动窗口(1秒的滑动窗口、 500ms一个样本窗口,2个样本),一个是分钟级别的滑动窗口(1分钟的滑动窗口、1秒一个样本窗口,60个样本)
我们就以rollingCounterInSecond.addPass来分析,逻辑都是一样的
rollingCounterInSecond.addPass
�
这里会调用到LeapArray的currentWindow方法:
public WindowWrap<T> currentWindow(long timeMillis) {
if (timeMillis < 0) {
return null;
}
// todo 比如timeMillis为888ms,现在用的是按1s统计的,2个样本窗口,一个500ms
// 根据时间戳获取时间窗口索引值
int idx = calculateTimeIdx(timeMillis);
// Calculate current bucket start time.
// 根据时间戳获取应该在的时间窗口的开始时间值
long windowStart = calculateWindowStart(timeMillis);
/*
* Get bucket item at given time from the array.
*
* (1) Bucket is absent, then just create a new bucket and CAS update to circular array.
* (2) Bucket is up-to-date, then just return the bucket.
* (3) Bucket is deprecated, then reset current bucket.
*/
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
* bucket is empty, so create new and update
*
* If the old bucket is absent, then we create a new bucket at {@code windowStart},
* then try to update circular array via a CAS operation. Only one thread can
* succeed to update, while other threads yield its time slice.
*/
// 就新建一个时间窗口
// WindowWrap 就是对时间窗口的抽象类,真正的时间窗口存储结构是MetricBucket
WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
// 更新时间窗口到LeapArray中
// LeapArray是对整个滑动时间窗口集实际存储,整个滑动时间窗口存在于其中的
// AtomicReferenceArray<WindowWrap<T>> array
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
* startTime of Bucket 3: 800, so it's up-to-date
*
* If current {@code windowStart} is equal to the start timestamp of old bucket,
* that means the time is within the bucket, so directly return the bucket.
*/
// 如果目标窗口就是找到的时间窗口就直接返回
return old;
} else if (windowStart > old.windowStart()) {
/*
* (old)
* B0 B1 B2 NULL B4
* |_______||_______|_______|_______|_______|_______||___
* ... 1200 1400 1600 1800 2000 2200 timestamp
* ^
* time=1676
* startTime of Bucket 2: 400, deprecated, should be reset
*
* If the start timestamp of old bucket is behind provided time, that means
* the bucket is deprecated. We have to reset the bucket to current {@code windowStart}.
* Note that the reset and clean-up operations are hard to be atomic,
* so we need a update lock to guarantee the correctness of bucket update.
*
* The update lock is conditional (tiny scope) and will take effect only when
* bucket is deprecated, so in most cases it won't lead to performance loss.
*/
// 滚动窗口,因为之前就已经初始化好了对应时间的窗口规格(大小和数量),所以这里只会覆盖上一个时间周期的老数据,相当于环形数组
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();
}
} else if (windowStart < old.windowStart()) {
// 时钟回拨问题,例如服务器时间被前调,导致了计算出来的窗口开始时间小于了现在目标的窗口时间
// 那么就新建一个窗口,仅用作统计,不会在流控slot中进行计算,出现这个问题肯定就会计算不准
// Should not go through here, as the provided time is already behind.
return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
}
}
}
代码比较多,一点一点分析:
- 获取样本窗口的索引值:(拿当前的时间戳/样本窗口时间) % 样本窗口长度
比如这里的timeMillis为1714292771261,
timeMillis/500 获取到3428585542
3428585542 % 2最终得到样本窗口索引为0
- 获取时间窗口的开始时间
这个比较简单,拿timeMillis - timeMillis % windowLengthInMs 获取在该样本窗口的起始位置
�
- 看当前index 有没有样本窗口,如果没有就需要创建一个
- 如果已经存在就需要判断 当前计算出来的窗口开始时间 == 存在窗口的开始时间,如果等于,就直接返回,
- 如果不等于(说明之前的窗口数据已经过期了),就会出现窗口滑动,重新设置一个窗口
更新窗口统计数据:wrap.value().addPass(count)
这个value就是MetricBucket�
会通过这个event的original值定位到counters下标,然后做 +1操作
简单总结
本篇主要还是介绍了滑动窗口进行一个qps等数据的保存和维护的,后续我们将进入flowSlot的源码分析,看看限流是如何运用刚刚统计到的信息进行一个限流判定的。