StatisticSlot主要统计两类数据:
- 线程数
- 请求数,也就是QPS
对于线程数统计比较简单,是通过内部维护LongAdder进行的当前线程数的统计,每进入一个线程加1,线程执行完减1,从而得到线程数。
对于QPS的统计则要复杂点,其中用到了滑动窗口的原理。下面就来重点分析下实现的详情。
Bucket
Sentinel使用Bucket统计一个窗口时间内的各项指标数据,这些指标数据包括请求总数、成功总数、异常总数、总耗时、最小耗时、最大耗时等,而一个Bucket可以是记录1s内的数据,也可以是10ms内的数据,这个时间长度称为窗口时间。
public class MetricBucket {
/**
* 存储各事件的计数,比如异常总数、请求总数等
*/
private final LongAdder[] counters;
/**
* 这段事件内的最小耗时
*/
private volatile long minRt;
}
Bucket记录一段时间内的各项指标数据用的是一个LongAdder数组,LongAdder保证了数据修改的原子性,并且性能较AtomicInteger表现更好。数组的每个元素分别记录一个时间窗口的请求总数、异常数、部耗时。
Sentinel使用枚举类型MetricEvent的ordinal属性作为下标,当需要获取Bucket记录总的成功请求数或异常总数、总的请求处理耗时,可根据事件类型(MetricEvent)从Bucket的LongAdder数组中获取对应的LongAdder,并调用sum方法获取:
// 假设事件为 MetricEvent.SUCCESS
public long get(MetricEvent event) {
// MetricEvent.SUCCESS.ordinal()为 1
return counters[event.ordinal()].sum();
}
需要记录请求数时操作如下:
// 假设事件为 MetricEvent.RT
public void add(MetricEvent event, long n) {
// MetricEvent.RT.ordinal()为 2
counters[event.ordinal()].add(n);
}
滑动窗口
我们希望知道某个接口的每秒处理成功请求数(成功QPS)、请求平均耗时(avg rt),我们只需要控制Bucket统计一秒钏的指标数据即可。Sentinel是如何实现的呢?它定义了一个Bucket数组,根据时间戳来定位到数组的下标。假设我们需要统计每1秒处理的请求数,且只需要保存最近一分钟的数据,那么Bucket数组的大小就可以设置为60,每个Bucket的windowLengthInMs(窗口时间)大小就是1000ms。
我们不可能当然也不需要无限存储Bucket,如果说只需要保留一分钟的数据,那我们就可以将Bucket的大小设置为60并循环使用,避免频繁创建Bucket。这种情况下如何定位Bucket呢?做法是将当前时间戳去掉毫秒部分等到当前秒数,再将得到的秒数与数组长度取余,就能得到当前时间窗口的Bucket在数组中的位置。
例如给定时间戳计算数组索引:
private int calculateTimeIdx(long timeMillis) {
/**
* 假设当前时间戳为 1577017699235
* windowLengthInMs 为 1000 毫秒(1 秒)
* 则
* 将毫秒转为秒 => 1577017699
* 然后对数组长度取余=>映射到数组的索引
* 取余是为了循环利用数组
*/
long timeId = timeMillis / windowLengthInMs;
return (int) (timeId % array.length());
}
由于数组是循环使用的,当前时间戳与一分钟之前的时间戳与后一分钟的时间戳都会映射到数组中的同一个Bucket,因此,必须要能够判断取得的Bucket是否是统计当前时间窗口内的指标数据,这便要数组每个元素都存储Bucet时间窗口的开始时间戳。那开始时间如果计算呢?
protected long calculateWindowStart(long timeMillis) {
/**
* 假设窗口大小为 1000 毫秒,即数组每个元素存储 1 秒钟的统计数据
* timeMillis % windowLengthInMs 就是取得毫秒部分
* timeMillis - 毫秒数 = 秒部分
* 这就得到每秒的开始时间戳
*/
return timeMillis - timeMillis % windowLengthInMs;
}
WindowWrap
因为Bucket自身并不保存时间窗口信息,所以Sentinel给Bucket加了一个包装类WindowWrap,用于记录Bcuket的时间窗口。
public class WindowWrap {
/**
* 窗口时间长度(毫秒)
*/
private final long windowLengthInMs;
/**
* 开始时间戳(毫秒)
*/
private long windowStart;
/**
* 时间窗口的内容,在 WindowWrap 中是用泛型表示这个值的,
* 但实际上就是 MetricBucket 类
*/
private T value;
public WindowWrap(long windowLengthInMs, long windowStart, T value) {
this.windowLengthInMs = windowLengthInMs;
this.windowStart = windowStart;
this.value = value;
}
}
我们只要知道窗口的开始时间和窗口时间大小,给定一个时间戳,就能知道该时间戳是否在Bucket的窗口时间内。
/**
* 检查给定的时间戳是否在当前 bucket 中。
*
* @param timeMillis 时间戳,毫秒
* @return
*/
public boolean isTimeInWindow(long timeMillis) {
return windowStart <= timeMillis && timeMillis < windowStart + windowLengthInMs;
}
通过时间戳定位Bucket
Bucket用于统计各项指标数据,WindowWrap用于记录Bucket的时间窗口信息,记录窗口的开始时间和窗口的大小,WindowWrap数组就是一个滑动窗口。
当接收到一个请求时,可根据请求的时间戳计算出一个数组索引,从滑动窗口(WindowWrap)中获取一个WindowWrap,从而获取WindowWrap包装的Bucket,调用Bucket的add方法记录相应的事件。
/**
* 根据时间戳获取 bucket
*
* @param timeMillis 时间戳(毫秒)
* @return 如果时间有效,则在提供的时间戳处显示当前存储桶项;如果时间无效,则为空
*/
public WindowWrap currentWindow(long timeMillis) {
if (timeMillis < 0) {
return null;
}
// 获取时间戳映射到的数组索引
int idx = calculateTimeIdx(timeMillis);
// 计算 bucket 时间窗口的开始时间
long windowStart = calculateWindowStart(timeMillis);
// 从数组中获取 bucket
while (true) {
WindowWrap old = array.get(idx);
// 一般是项目启动时,时间未到达一个周期,数组还没有存储满,没有到复用阶段,所以数组元素可能为空
if (old == null) {
// 创建新的 bucket,并创建一个 bucket 包装器
WindowWrap window = new WindowWrap(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
// cas 写入,确保线程安全,期望数组下标的元素是空的,否则就不写入,而是复用
if (array.compareAndSet(idx, null, window)) {
return window;
} else {
Thread.yield();
}
}
// 如果 WindowWrap 的 windowStart 正好是当前时间戳计算出的时间窗口的开始时间,则就是我们想要的 bucket
else if (windowStart == old.windowStart()) {
return old;
}
// 复用旧的 bucket
else if (windowStart > old.windowStart()) {
if (updateLock.tryLock()) {
try {
// 重置 bucket,并指定 bucket 的新时间窗口的开始时间
return resetWindowTo(old, windowStart);
} finally {
updateLock.unlock();
}
} else {
Thread.yield();
}
}
// 计算出来的当前 bucket 时间窗口的开始时间比数组当前存储的 bucket 的时间窗口开始时间还小,
// 直接返回一个空的 bucket 就行
else if (windowStart < old.windowStart()) {
return new WindowWrap(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));
}
}
}
protected WindowWrap resetWindowTo(WindowWrap w, long time) {
// Update the start time and reset value.
// 重置windowStart
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;
}
上面代码通过当前时间戳计算当前是的时间窗口的Bucket(new Bucket)在数组中的索引,以驻Bucket时间窗口的开始时间,通过索引从数组中取得Bucket(old bucket)。
- 当索引处不存在Bucket时,创建一个新的Bucket,并线程安全的写入到索引处,然后将Bucket返回
- 当old Bucket不为空时,且old Bucket时间窗口的开始时间与当前计算得到的new Bucket的时间窗口开始时间相等,则该Bucket就是当前要找的Bucket,直接返回
- 当计算的new Bucket时间窗口的开始时间大于当前数组存储的old Bucket时间窗口的开始时间时,可以复用这个old Bucket,以线程安全重置
- 当计算出new Bucket时间窗口的开始时间小于当前数组存储的old Bucket时间窗口的开始时间时,直接返回一个空的Bucket。
如何获取当前时间戳的前一个Bucket呢,答案是根据当前时间戳计算当前Bucket的时间窗口开始时间,用当前Bucket的时间窗口开始时间减去一个窗口大小就可以定位出前一个Bucket了。
需要注意的时,数组是循环使用的,所以当前Bucket与计算出的Bucket可能相差一个滑动窗口也可能相差一个以上,所以需要根据Bucket的时间窗口开始时间与当前时间戳进行比较,如果跨了一个周期就是无效的。
总结
- WindowWrap用于包装Bucket,随着Bucket一起创建
- WindowWrap数组实现滑动窗口,Bucket只负责统计各项指标数据,WindowWrap用于记录Bucket的时间窗口信息
- 定位Bucket实际上是定位WindowWrap,拿到WindowWrap就可以拿到Bucket