Sentinel——StatisticsNode滑动窗口实现原理

在之前的项目中使用了Sentinel来进行限流和熔断降级。

基本概念

Sentinel 的核心骨架,是将不同的 Slot 按照顺序串在一起(责任链模式),从而将不同的功能(限流、降级、系统保护)组合在一起。slot chain 其实可以分为两部分:统计数据构建部分(statistic)和判断部分(rule checking)。

下图源自官网,是Sentinel的架构图

 

 

在 Sentinel 里面,所有的资源都对应一个资源名称(resourceName),每次资源调用都会创建一个 Entry 对象。Entry 可以通过对主流框架的适配自动创建,也可以通过注解的方式或调用 SphU API 显式创建。Entry 创建的时候,同时也会创建一系列功能插槽(slot chain),这些插槽有不同的职责,例如:

  • NodeSelectorSlot 负责收集资源的路径,并将这些资源的调用路径,以树状结构存储起来,用于根据调用路径来限流降级;
  • ClusterBuilderSlot 则用于存储资源的统计信息以及调用者信息,例如该资源的 RT, QPS, thread count 等等,这些信息将用作为多维度限流,降级的依据;
  • StatisticSlot 则用于记录、统计不同纬度的 runtime 指标监控信息;
  • FlowSlot 则用于根据预设的限流规则以及前面 slot 统计的状态,来进行流量控制;
  • AuthoritySlot 则根据配置的黑白名单和调用来源信息,来做黑白名单控制;
  • DegradeSlot 则通过统计信息以及预设的规则,来做熔断降级;
  • SystemSlot 则通过系统的状态,例如 load1 等,来控制总的入口流量;

Sentinel 将 ProcessorSlot 作为 SPI 接口进行扩展(1.7.2 版本以前 SlotChainBuilder 作为 SPI),使得 Slot Chain 具备了扩展的能力。可以自行加入自定义的 slot 并编排 slot 间的顺序,从而可以给 Sentinel 添加自定义的功能。

 

 

下面来探讨一下StatisticsSlot—— Sentinel 的核心功能插槽之一,用于统计实时的调用数据。

StatisticSlot

StatisticSlot 是 Sentinel 的核心功能插槽之一,用于统计实时的调用数据。Sentinel 底层采用高性能的滑动窗口数据结构 LeapArray 来统计实时的秒级指标数据,可以很好地支撑写多于读的高并发场景。

 

 它的核心部分是StatisticsNode,查看源码可以发现它提供了很多统计数据的函数。

 

 

 

从上面的结构图中我们已经知道了Slot是以责任链模式从第一个往后传递的,当信息传递到StatisticSlot时,这里就开始进行统计了,统计的结果又会被后续的Slot所采用,作为规则校验的依据。先来看StatisticSlot中的entry方法:

@Override
public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, Object... args) throws Throwable {
    try {
        // 触发下一个Slot的entry方法
        fireEntry(context, resourceWrapper, node, count, args);
// 如果能通过SlotChain中后面的Slot的entry方法,说明没有被限流或降级 // 统计信息 node.increaseThreadNum(); node.addPassRequest();
// 省略... } catch (BlockException e) { context.getCurEntry().setError(e); // Add block count. node.increaseBlockedQps(); // 省略... throw e; } catch (Throwable e) { context.getCurEntry().setError(e); // Should not happen node.increaseExceptionQps(); // 省略... throw e; } }

  

简单的来说,StatisticSlot中就是做了三件事:

  • 1.通过node中的当前的实时统计指标信息进行规则校验
  • 2.如果通过了校验,则重新更新node中的实时指标数据
  • 3.如果被block或出现了异常了,则重新更新node中block的指标或异常指标

可以看出所有的实时指标的统计都是在node中进行的。

从上面的addPassRequest可以定位到StatisticsNode中的addPassRequest方法

public class StatisticNode implements Node {

    /**
     * Holds statistics of the recent {@code INTERVAL} seconds. The {@code INTERVAL} is divided into time spans
     * by given {@code sampleCount}.
     */
    private transient volatile Metric rollingCounterInSecond = new ArrayMetric(SampleCountProperty.SAMPLE_COUNT,
        IntervalProperty.INTERVAL);

    /**
     * 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);

    @Override
    public void addPassRequest(int count) {
        rollingCounterInSecond.addPass(count);
        rollingCounterInMinute.addPass(count);
    }
  
}

  

从代码中我们可以看到,指标分为秒级和分钟级,具体的增加pass指标是通过一个叫 Metric 的接口进行操作的,并且是通过 ArrayMetric 这种实现类,现在我们在进入 ArrayMetric 中看一下。

  

public class ArrayMetric implements Metric {

    private final LeapArray<MetricBucket> data;

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

  可以看到它的成员变量data是LeapArray,也就是滑动窗口的核心。接下来就来详细看一下LeapArray的实现原理。

LeapArray

先来看一下它的成员变量和构造方法。

public abstract class LeapArray<T> {

    protected int windowLengthInMs;//以毫秒为单位的窗口长度
    protected int sampleCount;//时间窗口的个数
    protected int intervalInMs;//以毫秒为单位的区间长度
    private double intervalInSecond;//以秒为单位的区间长度

    protected final AtomicReferenceArray<WindowWrap<T>> array;//用来模拟滑动窗口的数组

    
    private final ReentrantLock updateLock = new ReentrantLock();//可重入锁,用于保证数组更新的安全性

     public LeapArray(int sampleCount, int intervalInMs) {
        AssertUtil.isTrue(sampleCount > 0, "bucket count is invalid: " + sampleCount);
        AssertUtil.isTrue(intervalInMs > 0, "total time interval of the sliding window should be positive");
        AssertUtil.isTrue(intervalInMs % sampleCount == 0, "time span needs to be evenly divided");
     
      
     //总时长除以窗口个数就是每个窗口的长度 this.windowLengthInMs = intervalInMs / sampleCount; this.intervalInMs = intervalInMs; this.intervalInSecond = intervalInMs / 1000.0; this.sampleCount = sampleCount; this.array = new AtomicReferenceArray<>(sampleCount); } }

  LeapArray 中创建了一个 AtomicReferenceArray 数组array,用来对时间窗口中的统计值进行采样。AtomicReferenceArray类提供了可以原子读取和写入的底层引用数组的操作,并且还包含高级原子操作。 AtomicReferenceArray支持对底层引用数组变量的原子操作。 它具有获取和设置方法,如在变量上的读取和写入。 通过采样的统计值再计算出平均值,就是我们需要的最终的实时指标的值了。

而array中存储的类WindowWrap就是窗口类,接下来来看一下WindowWrap的主要代码实现。

WindowWrap

public class WindowWrap<T> {

    private final long windowLengthInMs;
    private long windowStart;
    private T value;

    public WindowWrap(long windowLengthInMs, long windowStart, T value) {
        this.windowLengthInMs = windowLengthInMs;
        this.windowStart = windowStart;
        this.value = value;
    }
    
    /**
     * Reset start timestamp of current bucket to provided time.
     *把当前窗口的开始时间设置成传入的时间参数
     */
    public WindowWrap<T> resetTo(long startTime) {
        this.windowStart = startTime;
        return this;
    }

    /**
     * Check whether given timestamp is in current bucket.
     *检查当前的时刻是否还属于当前的窗口
     */
    public boolean isTimeInWindow(long timeMillis) {
        return windowStart <= timeMillis && timeMillis < windowStart + windowLengthInMs;
    }
  //省略... 
}  

可以看到WindowWrap类有一个比较重要的属性windowStart,标识这个窗口的开始时间。

currentWindow

继续看LeapArray的核心方法currentWindow

public WindowWrap<T> currentWindow(long timeMillis) {
        if (timeMillis < 0) {
            return null;
        }
        //获取窗口下标
        int idx = calculateTimeIdx(timeMillis);
        // Calculate current bucket start time.计算该窗口的理论开始时间
        long windowStart = calculateWindowStart(timeMillis);

        /*
         * 根据下脚标在环形数组中获取滑动窗口(桶)
         *
         * (1) 如果桶不存在则创建新的桶,并通过CAS将新桶赋值到数组下标位。
         * (2) 如果获取到的桶不为空,并且桶的开始时间等于刚刚算出来的时间,那么返回当前获取到的桶。
         * (3) 如果获取到的桶不为空,并且桶的开始时间小于刚刚算出来的开始时间,那么说明这个桶是上一圈用过的桶,重置当前桶
         * (4) 如果获取到的桶不为空,并且桶的开始时间大于刚刚算出来的开始时间,理论上不应该出现这种情况。
         */

        // 嵌套在一个循环中,因为有并发的情况
        while (true) {
            WindowWrap<T> old = array.get(idx);

            // 窗口未实例化的情况,使用一个 CAS 来设置该窗口实例
            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()) {
                // 该窗口已过期,重置窗口的值。使用一个锁来控制并发。
                /*
                 *   (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()) {
                // 正常情况都不会走到这个分支,异常情况其实就是时钟回拨,这里返回一个 WindowWrap 是容错
                return new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
            }
        }
    }

  方法定义了一个环形数组,如架构图中所示

 

我们假设它的长度为 60,也就是有 60 个窗口,每个窗口长度为 1 秒也就是1000ms,刚好一分钟走完一轮。然后下一轮开启“覆盖”操作。每个窗口是一个WindowWrap实例。

 

首先是获取当前窗口的下标,调用了calculateTimeIdx方法,根据当前时间计算出所属滑动窗口的数组下标。

private int calculateTimeIdx(/*@Valid*/ long timeMillis) {
        long timeId = timeMillis / windowLengthInMs;//利用除法取整原则,保证了一秒内的所有时间得到的timeId是相等的
     return (int)(timeId % array.length());//模拟环形数组,保证一秒内获取到的窗口下标一致
}

 

然后是调用calculateWindowStart方法,计算当前这轮窗口的开始时间。

protected long calculateWindowStart(/*@Valid*/ long timeMillis) {
        return timeMillis - timeMillis % windowLengthInMs;
}

 

假设当前时间是6666,那么当前应该处于(6666/1000)即下标为6的窗口,该窗口的开始时间应该为6000。接下来根据下脚标在环形数组中获取滑动窗口的规则:

(1) 如果窗口不存在则创建新的窗口,并通过CAS将新窗口赋值到数组下标位。

(2) 如果获取到的窗口不为空,并且窗口的开始时间等于刚刚算出来的时间,那么返回当前获取到的窗口。

(3) 如果获取到的窗口不为空,并且窗口的开始时间小于刚刚算出来的开始时间,那么说明这个窗口是上一轮用过的窗口,已经过期,重置当前窗口,并返回。

(4) 如果获取到的窗口不为空,并且窗口的开始时间大于刚刚算出来的开始时间,理论上不应该出现这种情况。

值得注意的是,创建新窗口时使用了CAS原子操作来保证并发的安全性;但是重置窗口时,由于并发量可能很高,所以使用ReentrantLock锁来进行并发控制。

 

MetricBucket中的LongAdder

最后再来看一下ArrayMetric类中的addPass方法

@Override
    public void addPass(int count) {
        WindowWrap<MetricBucket> wrap = data.currentWindow();
        wrap.value().addPass(count);
}

  可以看到实现窗口时所用的泛型类型为MetricBucket,点进这个类再看一下

public class MetricBucket {

    private final LongAdder[] counters;

    private volatile long minRt;

    public MetricBucket() {
        MetricEvent[] events = MetricEvent.values();
        this.counters = new LongAdder[events.length];
        for (MetricEvent event : events) {
            counters[event.ordinal()] = new LongAdder();
        }
        initMinRt();
    }
    //省略...
}

  此类定义一个LongAdder类型的成员变量counter数组来进行窗口内数据的统计。JDK1.8时,java.util.concurrent.atomic包中提供了一个新的原子类:LongAdder

根据Oracle官方文档的介绍,LongAdder在高并发的场景下会比它的前辈————AtomicLong 具有更好的性能,代价是消耗更多的内存空间:

我们知道,AtomicLong中有个内部变量value保存着实际的long值,所有的操作都是针对该变量进行。也就是说,高并发环境下,value变量其实是一个热点,也就是N个线程竞争一个热点。

LongAdder的基本思路就是分散热点,将value值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。如果要获取真正的long值,只要将各个槽中的变量值累加返回。

这种做法有没有似曾相识的感觉?没错,ConcurrentHashMap中的“分段锁”其实就是类似的思路。

总结

以上就是Sentinel流量统计的原理。使用高性能的滑动窗口LeapArray来统计实时的秒级指标数据,可以很好地支持高并发场景。

posted @ 2021-02-27 17:25  A_Aron  阅读(758)  评论(0)    收藏  举报
//一下两个链接最好自己保存下来,再上传到自己的博客园的“文件”选项中