本文属于sentinel学习笔记系列。网上看到吴就业老师的专栏,作为官网的有力补充,原文链接如下,讲得好,不要钱,值得推荐,我整理的有所删减,推荐看原文:
深入理解Sentinel
1 为什么需要服务降级?
服务降级是服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,至少确保服务不会奔溃。常见的服务降级实现方式有:开关降级、限流降级、熔断降级。
限流降级
假设服务 A 需要依赖服务 B 完成客户端的一次请求,那么服务 B 可以通过压测方式预测单节点所能处理的最大并发请求数,只要最大并发数不超过自己的极限服务就能稳定运行。限制服务 B 处理最大并发请求就是限流。
限流降级适合哪些场景?秒杀场景最合适不过,抢到商品的都是有效流量,抢不到商品的都是无效流量,对于无效流量我们可以采用直接拒绝或者匀速排队的流量控制策略。
对于大促那种流量高峰,有真实购买需求的,还是弹性扩容加机器好。
熔断降级
假设服务 A 需要依赖服务 B 完成客户端的一次请求,当服务 A 的下游服务 B 突然变得不可用或者不稳定时,服务 A 可以自动切断与服务 B 的交互从而保证自己可用,就像保险丝一样,当电流异常升高到一定高度的时候,保险丝切断电流,这就是熔断降级。
当服务 B 恢复之后服务 A 也应该能感知到才行,所以熔断需要以一个时长为周期,比如 1 秒,这个周期也称为时间窗口,每个时间窗口都重新计算请求总数、异常总数这些指标数据,这样就能实现自动恢复。 Sentinel 支持的系统负载保护也算是一种熔断降级方式。
开关降级
开关降级用于在有限的硬件条件下,提升系统核心功能的并发处理能力,以最少的硬件成本应对流量高峰。比如搞大促之前,都会通过开关方式将一些无关紧要的业务接口变成“不可用”。
小结:
服务降级只是为了保障服务能够稳定运行,应对流量突增用降级牺牲一些流量换取系统的稳定。
开关降级适用于促销活动这种可以明确预估到并发会突增的场景。
2 为什么选择 Sentinel?
是否满足实际需求才是我们最终决定是否使用 Sentinel 的最关键因素,可以参照两个对照表。
Hystrix 18年开始不再发新版,好在多年比较成熟了,Sentinel 还在不断优化,不断更新。
3 Sentinel 基于滑动窗口的实时指标数据统计
先了解限流,先得熟悉指标统计,统计不了指标就没法限流。
Sentinel 是基于滑动窗口实现的实时指标数据统计,下面是一些主要类
Bucket
Sentinel 使用 Bucket 统计一个窗口时间内的各项指标数据,这些指标数据包括请求总数、成功总数、异常总数、总耗时、最小耗时、最大耗时等。
com.alibaba.csp.sentinel.slots.statistic.data.MetricBucket
public class MetricBucket {//存储各事件的计数,比如异常总数、请求总数等private final LongAdder[] counters;//这段事件内的最小耗时private volatile long minRt;
Bucket 记录一段时间内的各项指标数据用的是一个 LongAdder 数组,LongAdder 保证了数据修改的原子性,并且性能比 AtomicInteger 表现更好。数组的每个元素分别记录一个时间窗口内的请求总数、异常数、总耗时。
Sentinel 用枚举类型 MetricEvent 的 ordinal 属性作为下标,ordinal 的值从 0 开始,按枚举元素的顺序递增,正好可以用作数组的下标。com.alibaba.csp.sentinel.slots.statistic.MetricEvent
public enum MetricEvent {/*** Normal pass.*/PASS,/*** Normal block.*/BLOCK,EXCEPTION,SUCCESS,RT,/*** Passed in future quota (pre-occupied, since 1.5.0).*/OCCUPIED_PASS
}
当需要获取 Bucket 记录总的成功请求数或者异常总数、总的请求处理耗时,可根据事件类型(MetricEvent)从 Bucket 的 LongAdder 数组中获取对应的 LongAdder,并调用 sum 方法获取总数,
public long get(MetricEvent event) {return counters[event.ordinal()].sum();}
当需要 Bucket 记录一个成功请求或者一个异常请求、处理请求的耗时,可根据事件类型(MetricEvent)从 LongAdder 数组中获取对应的 LongAdder,并调用其 add 方法。
public MetricBucket add(MetricEvent event, long n) {counters[event.ordinal()].add(n);return this;}
滑动窗口
如果我们希望能够知道某个接口的每秒处理成功请求数(成功 QPS)、每秒处理失败请求数(失败 QPS),以及处理每个成功请求的平均耗时(avg RT),我们只需要控制 Bucket 统计一秒钟的指标数据即可。Sentinel 是这样实现的,它定义一个 Bucket 数组,根据时间戳来定位到数组的下标。假设我们需要统计每 1 秒处理的请求数等数据,且只需要保存最近一分钟的数据。那么 Bucket 数组的大小就可以设置为 60,每个 Bucket 的 windowLengthInMs(窗口时间)大小就是 1000 毫秒(1 秒),如下图所示
我们只需要保留一分钟的数据时,Bucket 数组的大小就可以设置为 60,我们希望这个数组可以循环使用,并且永远只保存最近 1 分钟的数据,这样不仅可以避免频繁的创建 Bucket,也减少内存资源的占用。
这种情况下如何定位 Bucket 呢?我们只需要将当前时间戳去掉毫秒部分得到当前的秒数,再将得到的秒数与数组长度取余数,就能得到当前时间窗口的 Bucket 在数组中的位置(索引)
com.alibaba.csp.sentinel.slots.statistic.base.LeapArray#calculateTimeIdx
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());}
calculateTimeIdx 方法中,取余数就是实现循环利用数组。由于循环使用的问题,当前时间戳与一分钟之前的时间戳和一分钟之后的时间戳都会映射到数组中的同一个 Bucket,因此,必须要能够判断取得的 Bucket 是否是统计当前时间窗口内的指标数据,这便要数组每个元素都存储 Bucket 时间窗口的开始时间戳。
protected long calculateWindowStart(/*@Valid*/ long timeMillis) {/*** 假设窗口大小为 1000 毫秒,即数组每个元素存储 1 秒钟的统计数据* timeMillis % windowLengthInMs 就是取得毫秒部分* timeMillis - 毫秒数 = 秒部分* 这就得到每秒的开始时间戳*/return timeMillis - timeMillis % windowLengthInMs;}
WindowWrap
因为 Bucket 自身并不保存时间窗口信息,所以 Sentinel 给 Bucket 加了一个包装类 WindowWrap,用于记录 Bucket 的时间窗口信息,
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;
只要知道时间窗口的开始时间和窗口时间大小,只需要给定一个时间戳,就能知道该时间戳是否在 Bucket 的窗口时间内。
/*** Check whether given timestamp is in current bucket.** @param timeMillis valid timestamp in ms* @return true if the given time is in current bucket, otherwise false* @since 1.5.0*/public boolean isTimeInWindow(long timeMillis) {return windowStart <= timeMillis && timeMillis < windowStart + windowLengthInMs;}
通过时间戳定位 Bucket
Bucket 用于统计各项指标数据,WindowWrap 用于记录 Bucket 的时间窗口信息,记录窗口的开始时间和窗口的大小,WindowWrap 数组就是一个滑动窗口。
当接收到一个请求时,可根据接收到请求的时间戳计算出一个数组索引,从滑动窗口(WindowWrap 数组)中获取一个 WindowWrap,从而获取 WindowWrap 包装的 Bucket,调用 Bucket 的 add 方法记录相应的事件。
根据当前时间戳定位 Bucket 的算法实现如下
com.alibaba.csp.sentinel.slots.statistic.base.LeapArray#currentWindow(long)
public WindowWrap<T> currentWindow(long timeMillis) {if (timeMillis < 0) {return null;}//获取时间戳映射到的数组索引int idx = calculateTimeIdx(timeMillis);// Calculate current bucket start time. 计算 bucket 时间窗口的开始时间long windowStart = calculateWindowStart(timeMillis);/** Get bucket item at given time from the array.* 从数组中获取 bucket* (1) Bucket is absent, then just create a new bucket and CAS update to circular array.* (2) Bucket is up-to-date, then just return the bucket.* (3) Bucket is deprecated, then reset current bucket.*/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.* 创建新的 bucket,并创建一个 bucket 包装器*/WindowWrap<T> window = new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));// cas 写入,确保线程安全,期望数组下标的元素是空的,否则就不写入,而是复用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* windowStart 正好是当前时间戳计算出的时间窗口的开始时间* 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.* 复用旧的 bucket* 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.// 重置 bucket,并指定 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. 不应该走到这,返回空bucketreturn new WindowWrap<T>(windowLengthInMs, windowStart, newEmptyBucket(timeMillis));}}}
上面代码实现的是,通过当前时间戳计算出当前时间窗口的 Bucket(New Buket)在数组中的索引(cidx),以及 Bucket 时间窗口的开始时间,通过索引从数组中取得 Bucket(Old Bucket)。算法设计的很巧妙啊。
获取当前时间戳的前一个 Bucket
根据当前时间戳计算出当前 Bucket 的时间窗口开始时间,用当前 Bucket 的时间窗口开始时间减去一个窗口时间大小就能定位出前一个 Bucket。
由于是使用数组实现滑动窗口,数组的每个元素都会被循环使用,因此当前 Bucket 与前一个 Bucket 会有相差一个完整的滑动窗口周期的可能,需要根据 Bucket 的时间窗口开始时间与当前时间戳比较,如果跨了一个周期就是无效的。
小结:
- WindowWrap 用于包装 Bucket,随着 Bucket 一起创建。
- WindowWrap 数组实现滑动窗口,Bucket 只负责统计各项指标数据,WindowWrap 用于记录 Bucket 的时间窗口信息。
- 定位 Bucket 实际上是定位 WindowWrap,拿到 WindowWrap 就能拿到 Bucket。
感谢吴就业老师的专栏,不然能力有限,不然直接去看太吃力了很多看不懂的。