Sentinel是一个限流框架,而对于限流来说现在都有多种的限流算法,比如滑动时间窗口算法,漏桶算法,令牌桶算法等,Sentinel对这几种算法都有具体的实现,在sentinel的dashboard中,假如我们对某一个资源设置了一个流控规则,并且选择的流控模式是“快速失败”,那么sentinel就会采用滑动时间窗口算法来作为该资源的限流算法,下面我们就来看一下Sentinel中对于滑动时间窗口算法的实现。
一.Sentinel中关于统计数据的几个基类
(1)ArrayMetric
Metric是一个接口,翻译过来就是度量的意思,它里面定义了很多统计数据的方法:
而ArrayMeric就是实现了Metric接口,它是sentinel对资源的qps数据统计的最外层api,封装了对时间窗口数据的操作,比如给当前时间窗口增加成功的请求数,增加异常的请求数,获取时间窗口中所有成功的请求数等等
(2)LeapArray<T>
LeapArray实现了整个滑动时间窗口的框架,它抽象了滑动时间窗口的具体实现,它的泛型会由继承的子类去定义,作用是WindowWrap中使用哪种数据统计类型,比如BucketLeapArray就是使用了MetricBucket作为每一个样本窗口的数据存储对象,下面会具体讲它是如何实现滑动时间窗口的
(3)BucketLeapArray
BucketLeapArray是sentinel使用的默认时间窗口的实现,它指定了使用MetricBucket作为每一个样本窗口存储统计数据的对象
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) {
// 更新窗口起始时间
w.resetTo(startTime);
// 将多维度统计数据清零
w.value().reset();
return w;
}
}
(4)WindowWrap<T>
public class WindowWrap<T> {
/**
* 样本窗口长度
*/
private final long windowLengthInMs;
/**
* 样本窗口的起始时间戳
*/
private long windowStart;
/**
* 当前样本窗口中的统计数据,其类型为MetricBucket
*/
private T value;
}
样本窗口对象,一个时间窗中可以包含多个样本窗口,样本窗口分得越小,整个时间窗就越准确。在sentinel中LeapArray可以看作就是整合滑动时间窗口,它里面使用了一个array数组存储每一个WindowWrap样本窗口对象,它里面主要包含三个属性,一个是当前样本窗口的长度,一个是样本窗口的起始时间戳,这两个属性的作用下面讲LeapArray的时候会重点讲解,最后一个是这个样本窗口的数据统计对象,它的类型由泛型T去规定,因为LeapArray包含了WindowWrap,所以最终LeapArray的子类会去规定具体的数据统计类型,比如BucketLeapArray使用了MetricBucket作为WindowWrap的数据存储对象
(5)MetricBucket
数据统计对象,也就是上面说到的WindowWrap中的泛型对象,当然数据统计对象并不只有MetricBucket,LeapArray的很多子类都有各自的数据统计类型,比如ClusterMetricBukcet,ParamMapBucket。那么MetricBucket是以什么形式去存储不同类型的数据的呢?答案就是通过一个LongAddr数组
/**
* 统计的数据存放在这里
* 这里要统计的数据是多维度的,这些维度类型在MetricEvent枚举中,比如第0个位置是放正常请求类型的数量
*/
private final LongAdder[] counters;
数组中的每一个位置都对应着不同类型数据的累计结果,比如我们看请求成功维度的数据:
public long pass() {
return get(MetricEvent.PASS);
}
public long get(MetricEvent event) {
return counters[event.ordinal()].sum();
}
MetricEvent枚举定义了数据不同的维度,根据枚举的顺序编号去作为数组下标到LongAddr数组中找到这个维度的统计数据即可
二.滑动时间窗口实现基类LeapArray
上面提到LeapArray对滑动时间窗口进行了一个基本实现框架,所以它是一个抽象基类,sentinel默使用的具体实现类是BucketLeapArray。
(1)基本属性
LeapArray中有如下几个关键属性:
/**
* 每一个时间窗口长度(intervalInMs / sampleCount)
*/
protected int windowLengthInMs;
/**
* 一个时间窗中包含的样本窗口数量,数量越多,则每个时间窗口长度越短,这样整个滑动时间窗口算法也越准确
*/
protected int sampleCount;
/**
* 时间窗长度,以毫秒为单位
*/
protected int intervalInMs;
/**
* 时间窗长度,以秒为单位
*/
private double intervalInSecond;
/**
* 这个一个数组,元素为WindowWrap样本窗口
*/
protected final AtomicReferenceArray<WindowWrap<T>> array;
其中array这个数组就是存放样本窗口的数组,代表着整个时间窗,这些属性的值可以在初始化LeapArray的时候进行指定,下面是LeapArray的构造方法:
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);
}
我们可以传入样本窗口的数量,以及整个时间窗的大小,如果样本数量是2,时间窗大小是1000ms,那么整个array数组中就会有两个样本窗口每一个样本窗口的时间长度为500ms。当然了,sentinel默认创建的样本数量就是2,时间窗口大小就是1000ms
(2)更新样本窗口
public WindowWrap<T> currentWindow() {
// 更新当前时间点所在的样本窗口并返回
return currentWindow(TimeUtil.currentTimeMillis());
}
/**
* 根据提供的时间戳去获取到对应的时间窗口
*
* @param timeMillis 以毫秒为单位的有效时间戳(通常都是传当前时间戳)
* @return 如果时间有效则返回对应的时间窗口对象,否则返回null
*/
public WindowWrap<T> currentWindow(long timeMillis) {
if (timeMillis < 0) {
return null;
}
// 计算当前时间所在的样本窗口id,即在计算数组LeapArray中的索引
int idx = calculateTimeIdx(timeMillis);
// 计算当前时间所在区间的开始时间点
long windowStart = calculateWindowStart(timeMillis);
while (true) {
// 获取到当前时间所在的样本窗口
WindowWrap<T> old = array.get(idx);
// 条件成立:说明该样本窗口还不存在,则创建一个,这里主要就是刚开始的时候会进来对样本窗口实例的初始化,后面基本不会进来
if (old == null) {
// 创建一个时间窗
WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
// 通过CAS方式将新建窗口放入到array
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()) {
return old;
}
// 条件成立:说明当前样本窗口的起始时间点 大于 计算出的样本窗口起始时间点,也就是说计算出的样本窗口已经过时了,此时需要将原来的样本窗口替换
else if (windowStart > old.windowStart()) {
// 加锁进行更新老窗口中过期的统计数据
if (updateLock.tryLock()) {
try {
// 重置老窗口的统计数据
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,样本窗口大小为500ms去举例):
- 获取当前时间所属的样本窗口位置,以及计算当前时间所对应的样本窗口开始时间
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());
}
protected long calculateWindowStart(/*@Valid*/ long timeMillis) {
return timeMillis - timeMillis % windowLengthInMs;
}
timeMillis传的都是当前时间戳,calculateTimeIdx方法主要就是计算出当前时间戳应该要占用的样本窗口位置,计算方法也很简单,就是先用当前时间戳除以样本窗口大小,再用得到的结果对样本窗口数量进行取模,比方说当前时间戳是1200,那么 1200 / 500 = 2,2 % 2 = 0,这样当前时间戳就是要使用0号位置的样本窗口实例了
protected long calculateWindowStart(/*@Valid*/ long timeMillis) {
return timeMillis - timeMillis % windowLengthInMs;
}
calculateWindowStart方法主要是用来计算当前时间戳的样本窗口的开始时间,比方说当前时间戳是1200,那么算出来的开始时间 = 1200 - 1200 % 500 = 1000,以此类推,如果传入的是1800,那么就是1500,也就是说算出来的结果是往前最靠近当前时间戳的500的倍数
- 如果样本窗口还未初始化,则进行样本窗口实例的初始化
if (old == null) {
// 创建一个时间窗
WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
// 通过CAS方式将新建窗口放入到array
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();
}
}
这个分支是给array数组中初始化样本窗口实例的,一般都是在程序一开始运行的时候会走到这里,一开始array数组中都是空的,根据我们上面算出来的样本窗口位置,以及样本窗口的开始位置去创建出样本窗口实例,然后通过cas去放到array中,如果cas失败的线程则通过yield方法去释放cpu时间片,等待抢占到cpu时间片的时候再进来while循环
- 如果样本窗口并未过期,直接返回该样本窗口
// 条件成立:条件成立:说明当前样本窗口的起始时间点与计算出的样本窗口起始时间点相同,也就是说这个样本窗口并未过期
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
*
* 如果当前{@code windowStart}等于旧bucket的开始时间戳,则表示时间在bucket内,因此直接返回bucket。
*/
return old;
}
首先我们要知道windowStart 与old.windowStart这两个值比较的结果分别对应着什么场景,这里还是通过例子进行举例,假如当前时间戳是1200ms,第一次进来先去找到对应的样本窗口,根据上面的计算可以算出应该是属于array数组的第0号位置,因为是第一次进来,所以需要初始化这个样本窗口实例,最后返回这个新创建的样本窗口实例;当1400ms的时候请求又进来了,首先我们知道1400ms和1200ms之间只间隔了200ms,所以它们是同属一个时间窗口的,而1400ms的窗口开始时间等于1000ms,与1200ms时创建的样本窗口实例的开始时间正好相等,所以windowStart == old.windowStart其实就意味着这个样本窗口还未过期;而2300ms与1200ms不是属于同一个时间窗的,2300ms的窗口开始时间是2000ms,大于1200ms的窗口开始时间,所以windowStart > old.windowStart就意味着这个样本窗口已经过期了。所以最终可以得出windowStart 与old.windowStart这两个值比较的结果能够判断这个样本窗口是否已经过期了
- 如果样本窗口已过期,则更新样本窗口并返回
// 条件成立:说明当前样本窗口的起始时间点 大于 计算出的样本窗口起始时间点,也就是说计算出的样本窗口已经过时了,此时需要将原来的样本窗口替换
else if (windowStart > old.windowStart()) {
// 加锁进行更新老窗口中过期的统计数据
if (updateLock.tryLock()) {
try {
// 重置老窗口的统计数据
return resetWindowTo(old, windowStart);
} finally {
updateLock.unlock();
}
} else {
// Contention failed, the thread will yield its time slice to wait for bucket available.
Thread.yield();
}
}
根据上面的推断,如果windowStart > old.windowStart成立,就说明这个样本窗口已经过期了,也就意味着这个样本窗口中的统计数组已经没用了,所以调用了resetWindowTo方法去对统计数据进行重置,而resetWindowTo是个抽象方法,需要子类去进行实现,下面是BucketLeapArray的实现
protected WindowWrap<MetricBucket> resetWindowTo(WindowWrap<MetricBucket> w, long startTime) {
// 更新窗口起始时间
w.resetTo(startTime);
// 将多维度统计数据清零
w.value().reset();
return w;
}
public MetricBucket reset() {
// 将每个维度的统计数据清零
for (MetricEvent event : MetricEvent.values()) {
counters[event.ordinal()].reset();
}
initMinRt();
return this;
}
可以看到这个方法里面会把窗口开始时间进行重置更新,然后再把MetricBucket中记录的统计数据清空。这里还需要注意的是,在执行resetWindowTo方法之前会先去加锁,这是因为resetWindowTo方法中做了重置和清理这两个事情,加锁是为了保证两个操作的原子性,最后返回更新后的样本窗口实例
- 样本窗口开始时间还大于当前时间戳的开始时间(一般不会出现)
// 条件成立:说明当前样本窗口的起始时间点 小于 计算出的样本窗口起始时间点,这种情况一般不会出现,因为时间不会倒流。除非人为修改了系统时钟
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));
}
一般来说不会出现这种情况,所以我们可以忽略这种情况
三.总结
我们一开始先对sentinel中与统计数据相关的对象进行了一个粗略的讲解,从最上层来看我们想要对qps的数据进行统计的都可以使用ArrayMetric,而ArrayMetric底层对qps数据统计是使用了滑动时间窗口算法来进行实现的,也就是基于LeapArray这个类的基础上去实现的。当然,当我们要统计qps的某一项数据时,我们需要先去调用LeapArray的currentWindow方法去对时间窗口进行一次更新,然后才能再去记录某一项的统计数据