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来统计实时的秒级指标数据,可以很好地支持高并发场景。

浙公网安备 33010602011771号