前言

在讲解滑动时间窗之前,有个问题可以思考一下,如何统计当前时间服务的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.

 

redission 滑动式窗口 java redis实现滑动窗口计数_滑动时间窗

 

 

源码解析

整个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类型的值变量.

数据结构

redission 滑动式窗口 java redis实现滑动窗口计数_滑动时间窗_02

 

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();
    }