Sentinel 数据统计和滑动窗口

·  阅读 1005

[TOC]

Sentinel 的数据统计

将Sentinel的数据统计提前来讲,是因为Sentinel的数据统计非常重要,流量控制,和服务熔断降级等,都需要在数据统计的前提下进行,所以要了解后续的功能链路,必须先了解Sentinel的数据统计是如何做的。

Sentinel 的定位是流量控制,它有两个维度的控制,一个是控制并发线程数,另一个是控制 QPS,它们都是针对某个具体的接口来设置的,其实说资源比较准确,Sentinel 把控制的粒度定义为 Resource。

Sentinel有关数据统计的代码都在StatisticNode中,对于统计数据采用了滑动窗口的设计。

1. StatisticNode

根据UML图可以看出来StatisticNode 内部是通过ArrayMetric做数据指标统计的统计的,自己本身继承了Node接口,实现了很多的实时获取统计信息的接口,例如:passQps,blockQps,rt等实时数据。正是有了这些统计数据后,sentinel才能进行限流、降级等一系列的操作。

1.1 StatisticNode UML图

image-20201014120536338

1.2 StatisticNode 类

从下面的StatisticNode 类中可以看出,主要统计的是两类信息

  1. 请求数 (rollingCounter): 通过 ArrayMetric 进行统计
    1. rollingCounterInSecond 按照秒为单位的请求数
    2. rollingCounterInMinute 按照分钟为单位的请求数
  2. 线程数(curThreadNum): 对于线程数的统计比较简单,通过 LongAdder类型的计数器,进行当前线程数的统计,每次进入一个请求加1,每释放一个请求减1,然后得到实时的线程数

public class StatisticNode implements Node {

    /**
     * Holds statistics of the recent {@code INTERVAL} seconds. The {@code INTERVAL} is divided into time spans                 		 * 统计近一秒的数据, 按秒统计,分成两个窗口,每个窗口500ms,用来统计QPS
     * 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.
     *  -- 统计近一分钟的数据, 按分钟统计,分成60个窗口,每个窗口 1000ms
     */
    private transient Metric rollingCounterInMinute = new ArrayMetric(60, 60 * 1000, false);

    /**
     * The counter for thread count. -- 当前并发线程数
     */
    private LongAdder curThreadNum = new LongAdder();

    /**
     * The last timestamp when metrics were fetched.
     */
    private long lastFetchTime = -1;
  
  	....
    ....
}
复制代码

2. 滑动窗口

2.1 滑动窗口核心类图

image-20201014145000026

从滑动窗口的UML图中可以看出,整个Sentinel的统计是基于ArrayMetric来做的,ArrayMetric内部的核心数据结构是LeapArray,LeapArray采用了滑动窗口的思想。下面我们先介绍下滑动窗口上的核心类
复制代码
  • Metric : 数据指标收集核心接口,主要定义一个滑动窗口中成功的数量、异常数量、阻塞数量,TPS、响应时间等数据。
  • ArrayMetric: 滑动窗口的核心实现类
  • LeapArray: 滑动窗口的顶层数据结构,包含一个一个的窗口数据
  • WindowWrap: 每一个滑动窗口的包装类,包含该窗口的大小,窗口的起始时间,窗口的统计数据类型T是泛型,类型一般是MetricBucket。
  • MetricBucket:数据指标数据,例如通过数量、阻塞数量、异常数量、成功数量、响应时间,已通过未来配额(抢占下一个滑动窗口的数量)。
  • MetricEvent:指标类型,例如通过数量、阻塞数量、异常数量、成功数量、响应时间等。

2.2 滑动窗口实现原理

2.2.1 ArrayMetric -- 滑动窗口的核心实现类

ArrayMetric 是一个包装类,真正实现数据统计的是 LeapArray

可以看到 ArrayMetric 是构造函数中初始化的时候就是为了初始化 LeapArray,有两个核心的参数

  1. sampleCount 样本数
  2. intervalInMs 采样周期
/**
 * The basic metric class in Sentinel using a {@link BucketLeapArray} internal.
 * 使用 BucketLeapArray 来实现Sentinel数据统计
 *
 * @author jialiang.linjl
 * @author Eric Zhao
 */
public class ArrayMetric implements Metric {

    private final LeapArray<MetricBucket> data;

    public ArrayMetric(int sampleCount, int intervalInMs) {
        this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs);
    }
    
    // enableOccupy: 是否允许抢占,即当前时间戳已经达到限制后,是否可以占用下一个时间窗口的容量,这里对应 LeapArray 的两个实现类,如果允许抢占,则为  OccupiableBucketLeapArray,否则为 BucketLeapArray
    public ArrayMetric(int sampleCount, int intervalInMs, boolean enableOccupy) {
        if (enableOccupy) {
            this.data = new OccupiableBucketLeapArray(sampleCount, intervalInMs);
        } else {
            this.data = new BucketLeapArray(sampleCount, intervalInMs);
        }
    }
    
    ...
}
复制代码
2.2.2 LeapArray -- 滑动窗口的顶层数据结构

LeapArray: 用滑动窗口数据结构来统计实时的秒级指标数据,其中包含了滑动窗口的基本信息:大小,窗口个数,窗口内容,每个窗口就是一个统计单位

2.2.2.1 LeapArray 核心属性和构造方法
public abstract class LeapArray<T> {

    protected int windowLengthInMs; 	// 每一个窗口的时间间隔,单位为毫秒。
    protected int sampleCount;				// 样本数,就一个统计时间间隔中包含的滑动窗口个数,在 intervalInMs 相同的情况下,sampleCount 越多,抽样的统计数据就越精确,相应的需要的内存也越多。
    protected int intervalInMs; 			// 采样周期 ms为单位
    private double intervalInSecond;	// 采样周期 s为单位
    protected final AtomicReferenceArray<WindowWrap<T>> array; // 一个统计时间间隔中滑动窗口的数组,从这里也可以看出,一个滑动窗口就是使用的 WindowWrap< MetricBucket > 来表示。

    /**
     * The conditional (predicate) update lock is used only when current bucket is deprecated.
     */
    private final ReentrantLock updateLock = new ReentrantLock();
    /**
     * The total bucket count is: {@code sampleCount = intervalInMs / windowLengthInMs}.
     *
     * @param sampleCount  bucket count of the sliding window
     * @param intervalInMs the total time interval of this {@link LeapArray} in milliseconds
     */
    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);
    }
}

public class WindowWrap<T> {
    //Time length of a single window bucket in milliseconds. --- 该窗口的长度
    private final long windowLengthInMs;
    //Start timestamp of the window in milliseconds. --- 该窗口的起始时间
    private long windowStart;
    //Statistic data. 统计信息
    private T value;

    /**
     * @param windowLengthInMs a single window bucket's time length in milliseconds.
     * @param windowStart      the start timestamp of the window
     * @param value            statistic data
     */
    public WindowWrap(long windowLengthInMs, long windowStart, T value) {
        this.windowLengthInMs = windowLengthInMs;
        this.windowStart = windowStart;
        this.value = value;
    }
}
复制代码
2.2.2.2 根据当前时间获取滑动窗口
   /**
     * Get bucket item at provided timestamp.
     * 根据指定的时间戳获取对应的窗口
     *
     * @param timeMillis a valid timestamp in milliseconds
     * @return current bucket item at provided timestamp if the time is valid; null if time is invalid
     */
    public WindowWrap<T> currentWindow(long timeMillis) {
        if (timeMillis < 0) {
            return null;
        }
				
      	// 根据当前时间计算出当前时间属于那个滑动窗口的数组下标
        int idx = calculateTimeIdx(timeMillis);
        // 计算当前窗口的起始时间
        long windowStart = calculateWindowStart(timeMillis);

        /*
         * 根据下标在环形数组中获取滑动窗口.
         *
         * (1) 如果指定下标的窗口不存在, 创建一个新的窗口并通过CAS赋值到数组的指定下标位置.
         * (2) 如果指定下标的窗口存在,并且该窗口的开始时间等于计算出来的windowStart,返回当前的窗口.
         * (3) 如果指定下标的窗口存在,但是该窗口的开始时间小于计算出来的windowStart,证明是上一圈已经用过的过期的窗口,则重置当前的窗口数据
         * (4) 如果指定下标的窗口存在,但是该窗口的开始时间大于刚刚算出来的开始时间,理论上不应该出现这种情况。
         */
        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()) {
                /*
                 *   (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));
            }
        }
    }
复制代码
2.2.2.3 isWindowDeprecated() 详解 -- 判断是否窗口过期

​ 判断滑动窗口是否生效的依据是当系统时间与滑动窗口的开始时间戳的间隔大于一个采集时间,即表示过期。即从当前窗口开始,通常包含的有效窗口为 sampleCount 个有效滑动窗口。

public boolean isWindowDeprecated(/*@NonNull*/ WindowWrap<T> windowWrap) {
    return isWindowDeprecated(TimeUtil.currentTimeMillis(), windowWrap);
}
public boolean isWindowDeprecated(long time, WindowWrap<T> windowWrap) {
	return time - windowWrap.windowStart() > intervalInMs;
}
复制代码
2.2.2.4 getPreviousWindow() 详解 -- 根据当前时间获取前一个有效滑动窗口
  1. 用当前时间减去一个时间窗口间隔,然后去定位所在 LeapArray 中 数组的下标。
  2. 如果为空或已过期,则返回 null
  3. 如果定位的窗口的开始时间再加上 windowLengthInMs 小于 timeMills ,说明失效,则返回 null,通常是不会走到该分支。
public WindowWrap<T> getPreviousWindow(long timeMillis) {
    if (timeMillis < 0) {
		return null;
    }
    int idx = calculateTimeIdx(timeMillis - windowLengthInMs); 
    timeMillis = timeMillis - windowLengthInMs;
    WindowWrap<T> wrap = array.get(idx);
    if (wrap == null || isWindowDeprecated(wrap)) {                
		return null;
    }
   if (wrap.windowStart() + windowLengthInMs < (timeMillis)) {   
		return null;
    }
    return wrap;
}
复制代码
2.2.2.5 BucketLeapArray ---> resetWindowTo() 详解 -- 重置滑动窗口

可以看到LeapArray的 resetWindowTo() 方法是一个抽象方法,具体的实现根据实现类的不同而有所差异,这里我们先只看 BucketLeapArray 的,其实就是重新给该 WindowWrap的windowStart 属性赋值,同时重置清空该WindowWrap的值

public abstract class LeapArray<T> {
    /**
     * Reset given bucket to provided start time and reset the value.
     * 将指定的滑动窗口的窗口起始值重置为给定的startTime,并将该WindowWrap的value值重置
     *
     * @param startTime  the start time of the bucket in milliseconds
     * @param windowWrap current bucket
     * @return new clean bucket at given start time
     */
    protected abstract WindowWrap<T> resetWindowTo(WindowWrap<T> windowWrap, long startTime);
}


public class BucketLeapArray extends LeapArray<MetricBucket> {

    public BucketLeapArray(int sampleCount, int intervalInMs) {
        super(sampleCount, intervalInMs);
    }

    @Override
    public MetricBucket newEmptyBucket(long time) {
        return new MetricBucket();
    }

    @Override
    protected WindowWrap<MetricBucket> resetWindowTo(WindowWrap<MetricBucket> w, long startTime) {
        // Update the start time and reset value.
        w.resetTo(startTime);
        w.value().reset();
        return w;
    }
}
复制代码
2.2.2.6 BucketLeapArray ---> 滑动窗口图示

image-20201015105851526

上图的图示样例的采集时间间隔(采集周期)为2s,抽样次数为2(一个采集周期内有两个样本数,也就是两个滑动窗口)

1. 首先会创建一个元素数量为2的类型为AtomicReferenceArray<WindowWrap<T>>的array数组
2. 第一个采集周期:
	采集的时候数组的两个元素均为null,
	当采集的周期为第一个周期的前1000ms,经过 calculateTimeIdx 计算后定位到数组下标为0的位置,发现这个位置没有元素,则会创建一个WindowWrap(windowStart = 0, windowLengthInMs = 1000)的元素,
	之后时间走到第一次采集周期的后1000ms,经过 calculateTimeIdx 计算后定位到数组下标为1的位置,发现这个位置没有元素,则会创建一个WindowWrap(windowStart = 1000, windowLengthInMs = 1000)的元素.

2. 第二个采集周期:
	采集的时候数组的两个元素均不为null
	当采集的周期为第二个周期的前1000ms,经过 calculateTimeIdx 计算后定位到数组下标为0的位置,发现这个位置已有元素(因为已有上一个周期采集的数据),则会丢弃改windowWrap的数据,并重置windowStart(windowStart = 2000, windowLengthInMs = 1000)的元素,
	之后时间走到第二次采集周期的后1000ms,经过 calculateTimeIdx 计算后定位到数组下标为1的位置,发现这个位置已有元素(因为已有上一个周期采集的数据),则会丢弃改windowWrap的数据,并重置windowStart(windowStart = 3000, windowLengthInMs = 1000)的元素,
复制代码
2.2.3 OccupiableBucketLeapArray -- 可以预支的滑动窗口,有待研究,还不是很清楚????

所谓的 OccupiableBucketLeapArray ,实现的思想是当前抽样统计中的“令牌”已耗尽,即达到用户设定的相关指标的阈值后,可以向下一个时间窗口,即借用未来一个采样区间。接下来我们详细来探讨一下它的核心实现原理。

2.2.3.1 OccupiableBucketLeapArray 类图

OccupiableBucketLeapArray 引入了一个 FutureBucketLeapArray 的成员变量,命名为 borrowArray,存储的就是借来的滑动窗口。

从构造函数可以看到除了正常初始化了 LeapArray 之外,还创建了一个FutureBucketLeapArray

image-20201015113123541

public class OccupiableBucketLeapArray extends LeapArray<MetricBucket> {

    private final FutureBucketLeapArray borrowArray;

    public OccupiableBucketLeapArray(int sampleCount, int intervalInMs) {
        // This class is the original "CombinedBucketArray".
        super(sampleCount, intervalInMs);
        this.borrowArray = new FutureBucketLeapArray(sampleCount, intervalInMs);
    }

		/**
		 * newEmptyBucket 是在 LeapArray 的 currentWindow 方法中当,对应的窗口数组下标为空的时候才回去创建,
		 * 1. 首先会创建一个空的 MetricBucket
		 * 2. 判断是否有曾经借过来的未来的相同时间的窗口,如果有则将借来的窗口数组copy的新创建的窗口上,在返回新的窗口
		 */
    @Override
    public MetricBucket newEmptyBucket(long time) {
      MetricBucket newBucket = new MetricBucket();

      MetricBucket borrowBucket = borrowArray.getWindowValue(time);
      if (borrowBucket != null) {
        newBucket.reset(borrowBucket);
      }

      return newBucket;
    }

  	/**
  	 * 首先重置的思路和之前 BucketLeapArray的思路是一样的,只是额外加了如果有借用过的窗口时,需要将在borrowArray中当前时间对用的时间窗口中统计的请求通
     * 过数添加到在array数组中的当前时间的时间窗口中
  	 */
    @Override
    protected WindowWrap<MetricBucket> resetWindowTo(WindowWrap<MetricBucket> w, long time) {
      // Update the start time and reset value.
      w.resetTo(time);
      MetricBucket borrowBucket = borrowArray.getWindowValue(time);
      if (borrowBucket != null) {
        w.value().reset();
        w.value().addPass((int)borrowBucket.pass());
      } else {
        w.value().reset();
      }

      return w;
    }

    @Override
    public long currentWaiting() {
      borrowArray.currentWindow();
      long currentWaiting = 0;
      List<MetricBucket> list = borrowArray.values();

      for (MetricBucket window : list) {
        currentWaiting += window.pass();
      }
      return currentWaiting;
    }

    @Override
    public void addWaiting(long time, int acquireCount) {
      WindowWrap<MetricBucket> window = borrowArray.currentWindow(time);
      window.value().add(MetricEvent.PASS, acquireCount);
    }
}
复制代码
分类:
阅读
标签:
分类:
阅读
标签:
收藏成功!
已添加到「」, 点击更改