引子
在说滑动窗口原理之前,我们先来看一个最简单的限流算法。
假设我们规定,对于A接口来说,我们1分钟的访问次数不能超过100个。那么我们可以这么做:在一开始的时候,我们可以设置一个计数器counter,最大请求数maxCounter,并且初始化一个开始时间,每当一个请求过来的时候, 我们就判断首先该请求距离上一个请求时间有没有大于一分钟,大于,则重置计数器,并且将初始时间设置成当前时间,否则,继续判断该请求有没有超过最大请求数maxCounter,超过,则被限流,否则通过。
代码如下:
public class Counter {
public long timeStamp = System.currentTimeMillis(); // 当前时间
public int reqCount = 0; // 初始化计数器
public final int limit = 100; // 时间窗口内最大请求数
public final long interval = 1000 * 60; // 时间窗口ms
public boolean limit() {
long now = System.currentTimeMillis();
if (now < timeStamp + interval) {
// 在时间窗口内
reqCount++;
// 判断当前时间窗口内是否超过最大请求控制数
return reqCount <= limit;
} else {
timeStamp = now;
// 超时后重置
reqCount = 1;
return true;
}
}
}
这个算法实现很简单,同时也存在很大的问题,就是临界值的问题。
如果用户在0-50秒没有调用,在50-60秒一次性调用了100次请求,那么请求是可以通过的。然后用户在60-70秒内又调用了100次请求,也可以成功,这就突破了1s100个请求的限制。
针对固定时间算法会在临界点存在瞬间大流量冲击的场景,滑动时间窗口算法应运而生。原理如下:
滑动窗口原理:
它将时间窗口划分为更小的时间片段,每过一个时间片段,时间窗口就会往右滑动一格,每个时间片段都有独立的计数器。我们在计算整个时间窗口内的请求总数时会累加所有的时间片段内的计数器。时间窗口划分的越细,那么滑动窗口的滚动就越平滑,限流的统计就会越精确(下文我们具体举例说明,就会对这句话有更深刻的理解)
一、sentinel中滑动窗口的设计
ArrayMetric用来统计滑动窗口中的各种数据,比如已经通过的请求数,被限流的请求数、向当前窗口中增加通过的请求数等等
ArrayMetric:(滑动窗口统计)
LeapArraydata; --滑动窗口
long pass() ; --获取通过的请求数
void addSuccess(int count); --增加通过的请求数
......
LeapArray是滑动窗口的真正实现,包括计算请求的当前窗口重要方法
LeapArray(高性能数组-滑动窗口)
int windowLengthInMs; 子窗口长度(ms):500ms
int sampleCount; 样本窗口数量(子窗口数量)
int intervalInMs; 窗口滑动间隔时间(ms)--1000ms
double intervalInSecond; 窗口滑动间隔时间(ms)--1s
final AtomicReferenceArray> array; 数组
WindowWrap是一个对象,真正的数据被保存到泛型T的value中,在这里使用的是MetricBucket对象
WindowWrap(子窗口)
long windowLengthInMs; --子窗口长度(ms):500ms
long windowStart; --子窗口开始时间
T value; --(ms)具体的统计数据
MetricBucket(统计维度)
LongAdder[] counters --数组
二、调用链路:
三、限流的整体逻辑
@Spi(order = Constants.ORDER_STATISTIC_SLOT)
public class StatisticSlot extends AbstractLinkedProcessorSlot {
@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,
boolean prioritized, Object... args) throws Throwable {
try {
// Do some checking.(校验请求是否通过)
fireEntry(context, resourceWrapper, node, count, prioritized, args);
// Request passed, add thread count and pass count.
node.increaseThreadNum();
//增加请求通过数量
node.addPassRequest(count);
..........
}
}
}
public class FlowRuleChecker {
private static boolean passLocalCheck(FlowRule rule, Context context, DefaultNode node, int acquireCount,
boolean prioritized) {
Node selectedNode = selectNodeByRequesterAndStrategy(rule, context, node);
if (selectedNode == null) {
return true;
}
//根据限流效果的不同,走直接失败、预热、允许排队
return rule.getRater().canPass(selectedNode, acquireCount, prioritized);
}
}
public class DefaultController implements TrafficShapingController {
@Override
public boolean canPass(Node node, int acquireCount, boolean prioritized) {
//获取当前时间窗口中已经通过的请求数量
int curCount = avgUsedTokens(node);
/**
* 若已经统计的数据与本次请求的数量和大于设置的阈值,返回false表示被限流
* 若小于等于阈值,则返回true,表示检测通过
*/
if (curCount + acquireCount > count) {
if (prioritized && grade == RuleConstant.FLOW_GRADE_QPS) {
long currentTime;
long waitInMs;
currentTime = TimeUtil.currentTimeMillis();
waitInMs = node.tryOccupyNext(currentTime, acquireCount, count);
if (waitInMs < OccupyTimeoutProperty.getOccupyTimeout()) {
node.addWaitingRequest(currentTime + waitInMs, acquireCount);
node.addOccupiedPass(acquireCount);
sleep(waitInMs);
// PriorityWaitException indicates that the request will pass after waiting for {@link @waitInMs}.
throw new PriorityWaitException(waitInMs);
}
}
return false;
}
return true;
}
}
public class ArrayMetric implements Metric {
/**
* 循环通过窗口中的每个子窗口中通过的请求数量
*/
@Override
public long pass() {
//计算请求的当前窗口(此方法是关键)
data.currentWindow();
long pass = 0;
List list = data.values();
for (MetricBucket window : list) {
pass += window.pass();
}
return pass;
}
}
public abstract class LeapArray {
public WindowWrap currentWindow(long timeMillis) {
if (timeMillis < 0) {
return null;
}
/**
* 获取当前时间的数组下标
*/
int idx = calculateTimeIdx(timeMillis);
/**
* 计算当前桶(子窗口)的开始时间
*/
long windowStart = calculateWindowStart(timeMillis);
/**
* 1)如果子窗口中没有值,这个时候系统刚启动,需要新建窗口(窗口长度比如500ms,窗口的起始时间和当前时间)
* 2)如果新来的请求计算出来的起始时间和之前子窗口中的起始时间相同,表示请求间隔时间很短,在同一个窗口长度(比如500ms)内,
* 因此这个请求也被划分到当前的这个子窗口内
* 3)如果新来的请求计算出来的起始时间 大于 之前子窗口中的起始时间,说明一个滑动窗口长度(比如1000ms)已经过去了,
* 这个时候滑动窗口的时间需要向前移动,因此需要重置子窗口的其实时间为 新来的请求计算出来的起始时间
*/
/*
* 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 and clean all deprecated buckets.
*/
while (true) {
WindowWrap 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 window = new WindowWrap(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
* 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()) {
// Should not go through here, as the provided time is already behind.
return new WindowWrap(windowLengthInMs, windowStart, newEmptyBucket(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());
}
protected long calculateWindowStart(/*@Valid*/ long timeMillis) {
return timeMillis - timeMillis % windowLengthInMs;
}
}
结合以上滑动窗口的原理图和代码,我们可以看到,其实滑动窗口主题就是一个数组,数组默认长度为2,这样就将一个窗口默认分成了两个子窗口,通过固定时间(windowLengthInMs)向前滑动每个子窗口,而在数组中的体现就是,通过计算出每个请求对应的数组下标和对应的起始时间不断覆盖数组中的子窗口。细想就会发现,通过这种方式实现的限流依赖于数组中的子窗口数量,子窗口数量越多,限流的准确度就会越高。
具体举例说明:我们规定1s中最多100次请求,滑动窗口滑动的单位是1000ms,分为两个窗口,每个窗口占500ms(windowLengthInMs=500ms)。第一次请求过来假定在200ms
计算数组下标:timeId=200/500=0;idx=0%2=0;
计算请求对应的开始时间:200 - 200%500 = 0ms
第一个子窗口就定下来了,起始时间是0ms,结束时间是500ms((第二个500-1000ms的子窗口为空,因为暂时还没有此时间段内的请求通过)。在200ms时刻来的请求就落在了这个子窗口中,请求通过,子窗口请求通过数+1。
然后在400-500ms内陆续来了99个请求,都落在了0-500ms的子窗口内,请求也都通过,子窗口请求数为:1+99 = 100。在500-1000ms内没有请求通过。在1000-1100ms突然来了100个请求。我们通过上面的计算方式得到此时间段内的请求也全部落在了 0-500ms的 窗口内,1000ms > 0ms,说明此时子窗口已经来到了下一个周期,将窗口起始时间置为1000ms,结束时间为1500ms。并将请求通过数重置为0。此时子窗口被重置,1000-1100ms时间段内的请求也都全部通过。
我们发现,我们规定的限流规则是1s内请求数不得大于100,但是在400-1100ms的时间段内,请求数已经为200,突破了我们的规则。滑动窗口就是为了解决固定窗口临界值大流量的问题,但是这么看来,也没有完全解决。
再来看另一种情况,比如我们将上述的窗口划分为10个窗口,也就是每100ms一个窗口,那么在400-500ms内的99个请求应该位于400-500ms内的子窗口内,而后续在1000-1100ms内来的100个请求也只会重置0-100ms内的子窗口,此时再来计算整个窗口(包含10个子窗口)的通过请求数量为99,加上新来的100个请求,199 > 100,则这100个请求就都会被限流。
通过以上示例,我们再来理解一下文章开始我们提到的滑动窗口的原理:
滑动窗口将时间窗口划分为更小的时间片段,每过一个时间片段,时间窗口就会往右滑动一格,每个时间片段都有独立的计数器。我们在计算整个时间窗口内的请求总数时会累加所有的时间片段内的计数器。时间窗口划分的越细,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。