详解Sentinel中的滑动时间窗算法完成数据QPS统计
前言
在讲解滑动时间窗之前,有个问题可以思考一下,如何统计当前时间服务的QPS呢?博主在前公司的时候,他们是这么设计的,在分布式环境下,列如当前时间 2021-9-13 23:12:10, 那么解析成key = 2021-9-13-23-12-10,并通过这个key查询redis,获取一个整型的统计值,并执行incr自增。当到下一个时间2021-9-13 23:12:11,则生成key=2021-9-13-23-12-11.从redis中获取一个新的计数器,并incr。
这有什么问题?
1) 随着时间的增长,redis中的key越来越多
2)23时12分10秒600毫秒 与 23时12分11秒600毫秒 这1秒期间,QPS的值将无法统计。
滑动时间窗算法
1 我们把当前的时间看做一个无限延长的有向轴,并且分隔成一个个固定单位,这个固定单位成为时间窗,可以是1s,也可以是1min。为了便于统计一分钟内或者1秒钟内的QPS,将单位时间窗划分为多个样本时间窗。
2 划分样本时间窗的目的是 为了避免重复统计,列如当时间运行到样本窗7 ,则需要统计样本窗4-7。 下次如果当前时间到了样本窗8,则需要统计5-8,而中间的5-7已经统计过了,只需要再加上样本窗8中的数据。
3 在sentinel中,缓存了一个单位时间窗的统计数据,并组织成一个环,实际上就是通过对样本窗口数取余来确定请求落在哪个样本窗上。
4 使用了滑动时间窗算法之后,获取的QPS永远统计的是当前时间之前的一个单位时间窗的统计结果,也解决了在前言中的问题2.
源码解析
整个Sentinel是按着责任链模式来组织架构的,在chain上的slot,StatisticSlot#entry里面,先根据之前的统计执行下面的slot,比如FlowSlot,根据流控结果来统计当前请求是否成功或者失败。
// Do some checking. // 先做其他的slot fireEntry(context, resourceWrapper, node, count, prioritized, args); // Request passed, add thread count and pass count. node.increaseThreadNum(); // 对当前的资源DefaultNode添加统计 node.addPassRequest(count); if (context.getCurEntry().getOriginNode() != null) { // Add count for origin node. context.getCurEntry().getOriginNode().increaseThreadNum(); // 对所有相同资源和相同来源。不同的context上的同一个originNode做统计 context.getCurEntry().getOriginNode().addPassRequest(count); }
统计的维度有这么几个,1 对于当前请求在指定资源上的DefaultNode的统计 2指定资源上上全局的clusterNode的统计。代码如下node.addPassRequest(count)
@Override public void addPassRequest(int count) { // 增加当前入口的defaultNode中的统计数据 super.addPassRequest(count); // 增加当前资源的clusterNode中全局统计数据 this.clusterNode.addPassRequest(count); }
3 对于相同资源的全局CluserNode在指定来源上的统计 ,比如 全局统计资源A在来源A上的QPS ,代码 context.getCurEntry().getOriginNode().addPassRequest(count);
@Override public void addPassRequest(int count) { // 为滑动计数器增加本次访问的计数器 rollingCounterInSecond.addPass(count); rollingCounterInMinute.addPass(count); }
入口已经讲解完毕,接下来具体从阐述数据结构及算法核心逻辑。
在StatisticsNode里面有2个重要的计量器,一个是单位时间窗以秒为单位,一个单位时间窗是分为单位
/** * Holds statistics of the recent {@code INTERVAL} milliseconds. The {@code INTERVAL} is divided into time spans * by given {@code sampleCount}. * 定义一个使用数组保存数据的计量器 */ private transient volatile Metric rollingCounterInSecond = new ArrayMetric(SampleCountProperty.SAMPLE_COUNT /* 样本窗口数量 默认2 */, IntervalProperty.INTERVAL /* 时间窗长度 默认1s */); /** * Holds statistics of the recent 60 seconds. The windowLengthInMs is deliberately set to 1000 milliseconds, * meaning each bucket per second, in this way we can get accurate statistics of each second. */ private transient Metric rollingCounterInMinute = new ArrayMetric(60, 60 * 1000, false);
在讲解之前可以看一下数据结构是如何设计的,实际上一个计量器内部维护这一个数组,即单位时间窗,元素为一个样本窗口对象windowWrap,一个样本窗口维护这一个MetricBucket类型的值变量.
数据结构
addPass
public void addPass(int count) { // 获取当前时间点所在的样本窗口 WindowWrap<MetricBucket> wrap = data.currentWindow(); // 在样本窗中增加count wrap.value().addPass(count); }
这里面最关键是如何根据当前时间来获取在单位时间窗内的样本窗,先计算出当前时间在单位时间窗内的样本窗位置.
// 计算当前时间在哪个样本窗口 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()); }
然后根据idx来获取到样本窗对象,比较样本窗开始时间来确认是否过时
public WindowWrap<T> currentWindow(long timeMillis) { if (timeMillis < 0) { return null; } // 计算当前时间所在的样本窗口ID,即在计算数组leapArray中的索引 // 这个idx不会超过样本数量 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 and clean all deprecated buckets. */ 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<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 * 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()) { // 在idx上的样本时间窗可能已经老了,可能是上几圈的idx位置,本线程的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<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis)); } } }
这时候已经完成了统计工作,那么在流量控制FlowSlot中是如何的实现QPS查询呢?
FlowSlot#entry -> FlowRuleChecker#checkFlow -> FlowRuleChecker#canPassCheck -> DefaultController#canPass -> DefaultController#avgUsedTokens -> node.passQps()
获取计量数中的通过数 除以 单位时间窗长度
@Override public double passQps() { return rollingCounterInSecond.pass() / rollingCounterInSecond.getWindowIntervalInSec(); }
接下来看一下如何获取单位时间窗内的请求量
实际上就是把有效的样本时间窗内的各个统计量相加
@Override public long pass() { // 更新array中当前时间点所在的样本窗口实例中的数据 data.currentWindow(); long pass = 0; List<MetricBucket> list = data.values(); // 将当前时间窗中的所有样本窗口统计的值求和 for (MetricBucket window : list) { pass += window.pass(); } return pass; }
在拿样本时间窗的时候,需要拿有效的样本,不能包含过时的
public List<T> values(long timeMillis) { if (timeMillis < 0) { return new ArrayList<T>(); } int size = array.length(); List<T> result = new ArrayList<T>(size); for (int i = 0; i < size; i++) { WindowWrap<T> windowWrap = array.get(i); // 判断样本窗是否过时: 当前时间-windowsStart > intervalInms if (windowWrap == null || isWindowDeprecated(timeMillis, windowWrap)) { // 有可能这个时间窗口 没有请求,在一个时间窗的时候 发现这个样本窗已经过时 continue; } result.add(windowWrap.value()); } return result; }
LeapArray有2个实现类,分别是OccupiableBucketLeapArray (单位时间窗为1s)和 BucketLeapArray(单位时间窗为1min),那么这两个实现类有什么区别的。实际上对于OccupiableBucketLeapArray,如果是优先级较高,同时流控的时候超过了配置的阈值,那么可以等待一定的时间,使用未来的配额,保证通过流控。
DefaultController#canPass()
@Override public boolean canPass(Node node, int acquireCount, boolean prioritized) { int curCount = avgUsedTokens(node); if (curCount + acquireCount > count) { // 被流控 // 如果流控策略是基于QPS 并且优先级较高 if (prioritized && grade == RuleConstant.FLOW_GRADE_QPS) { long currentTime; long waitInMs; currentTime = TimeUtil.currentTimeMillis(); waitInMs = node.tryOccupyNext(currentTime, acquireCount, count); if (waitInMs < OccupyTimeoutProperty.getOccupyTimeout()) { // 添加占用未来时间对应的样本窗的pass配额 node.addWaitingRequest(currentTime + waitInMs, acquireCount); // 统计 OccupiedPass 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; }
那么如何确定等待的时间呢?
在上面的代码中有个比较 waitInMs < OccupyTimeoutProperty.getOccupyTimeout(),这说明就是因为当前样本窗的pass数量太多造成的,这个时间只要sleep 剩余样本窗是的时间就可以了。
而等待时间超过500的情况下,说明之前的样本窗就已经超过阈值了,这个时候不允许通过流量控制,返回false。
public long tryOccupyNext(long currentTime, int acquireCount, double threshold) { double maxCount = threshold * IntervalProperty.INTERVAL / 1000; // 0 long currentBorrow = rollingCounterInSecond.waiting(); if (currentBorrow >= maxCount) { return OccupyTimeoutProperty.getOccupyTimeout(); } // 500 int windowLength = IntervalProperty.INTERVAL / SampleCountProperty.SAMPLE_COUNT; /** * 这个算法 是获取当前时间对应的样本窗的结束时间 往前推一个单位时间窗 来获取到一个能统计到当前时间对应的样本窗的最早时间 */ long earliestTime = currentTime - currentTime % windowLength + windowLength - IntervalProperty.INTERVAL; int idx = 0; /* * Note: here {@code currentPass} may be less than it really is NOW, because time difference * since call rollingCounterInSecond.pass(). So in high concurrency, the following code may * lead more tokens be borrowed. */ // 当前时间单位窗口的统计数 long currentPass = rollingCounterInSecond.pass(); /** * 这块逻辑很好理解: * 从最早时间 到 当前时间为止 * 初始等待时间为 当前时间对应的样本窗,剩余的时间长度 * 如果去掉最早样本窗的pass数之后,发现仍然大于阈值 ,则等待时间数 + 样本时间窗长度 */ while (earliestTime < currentTime) { long waitInMs = idx * windowLength + windowLength - currentTime % windowLength; if (waitInMs >= OccupyTimeoutProperty.getOccupyTimeout()) { break; } long windowPass = rollingCounterInSecond.getWindowPass(earliestTime); if (currentPass + currentBorrow + acquireCount - windowPass <= maxCount) { return waitInMs; } earliestTime += windowLength; currentPass -= windowPass; idx++; } return OccupyTimeoutProperty.getOccupyTimeout(); }