Sentinel 是如何做限流的
限流是保障服务高可用的方式之一,尤其是在微服务架构中,对接口或资源进行限流可以有效地保障服务的可用性和稳定性。
之前的项目中使用的限流措施主要是 Guava 的 RateLimiter。RateLimiter 是基于令牌桶流控算法,使用非常简单,但是功能相对比较少。
而现在,我们有了一种新的选择,阿里提供的 Sentinel。
Sentinel 是阿里巴巴提供的一种限流、熔断中间件,与 RateLimiter 相比,Sentinel 提供了丰富的限流、熔断功能。它支持控制台配置限流、熔断规则,支持集群限流,并可以将相应服务调用情况可视化。
目前已经有很多项目接入了 Sentinel,而本文主要是对 Sentinel 的限流功能做一次详细的分析,至于 Sentinel 的其他能力,则不作深究。
一、总体流程
先来了解一下总体流程:
( 引用于 Sentinel 官网)
上面的图是官网的图,
从设计模式上来看,典型的的责任链模式。外部请求进来后,要经过责任链上各个节点的处理,而 Sentinel 的限流、熔断就是通过责任链上的这些节点实现的。
从限流算法来看,Sentinel 使用滑动窗口算法来进行限流。要想深入了解原理,还是得从源码上入手,下面,直接进入 Sentinel 的源码阅读。
二、源码阅读
1. 源码阅读入口及总体流程
读源码先得找到源码入口。我们经常使用 @ SentinelResource 来标记一个方法,可以将这个被 @ SentinelResource 标记的方法看成是一个 Sentinel 资源。因此,我们以 @ SentinelResource 为入口,找到其切面,看看切面拦截后所做的工作,就可以明确 Sentinel 的工作原理了。直接看注解 @SentinelResource 的切面代码(SentinelResourceAspect)。
可以清晰的看到 Sentinel 的行为方式。进入 SentinelResource 切面后,会执行 SphU.entry 方法,在这个方法中会对被拦截方法做限流和熔断的逻辑处理。
如果触发熔断和限流,会抛出 BlockException,我们可以指定 blockHandler 方法来处理 BlockException。而对于业务上的异常,我们也可以配置 fallback 方法来处理被拦截方法调用产生的异常。
所以,Sentinel 熔断限流的处理主要是在 SphU.entry 方法中,其主要处理逻辑见下图源码。
可见,在 SphU.entry 方法中,Sentinel 实现限流、熔断等功能的流程可以总结如下:
获取 Sentinel 上下文(Context);
获取资源对应的责任链;
生成资源调用凭证(Entry);
执行责任链中各个节点。
接下来,围绕这几个方面,对 Sentinel 的服务机制做一个系统的阐述。
2. 获取 Sentinel 上下文(Context)
Context,顾名思义,就是 Sentinel 熔断限流执行的上下文,包含资源调用的节点和 Entry 信息。
来看看 Context 的特征:
Context 是线程持有的,利用 ThreadLocal 与当前线程绑定。
Context 包含的内容
这里就引出了 Sentinel 的三个比较重要的概念:Conetxt,Node,Entry。这三个类是 Sentinel 的核心类,提供了资源调用路径、资源调用统计等信息。
Context
Context 是当前线程所持有的 Sentinel 上下文。
进入 Sentinel 的逻辑时,会首先获取当前线程的 Context,如果没有则新建。当任务执行完毕后,会清除当前线程的 context。Context 代表调用链路上下文,贯穿一次调用链路中的所有 Entry。
Context 维持着入口节点(entranceNode)、本次调用链路的 当前节点(curNode)、调用来源(origin)等信息。Context 名称即为调用链路入口名称。
Node
Node 是对一个 @SentinelResource 标记的资源的统计包装。
Context 中记录本当前线程资源调用的入口节点。
我们可以通过入口节点的 childList,可以追溯资源的调用情况。而每个节点都对应一个 @SentinelResource 标记的资源及其统计数据,例如:passQps,blockQps,rt 等数据。
Entry
Entry 是 Sentinel 中用来表示是否通过限流的一个凭证,如果能正常返回,则说明你可以访问被 Sentinel 保护的后方服务,否则 Sentinel 会抛出一个 BlockException。
另外,它保存了本次执行 entry()方法的一些基本信息,包括资源的 Context、Node、对应的责任链等信息,后续完成资源调用后,还需要更具获得的这个 Entry 去执行一些善后操作,包括退出 Entry 对应的责任链,完成节点的一些统计信息更新,清除当前线程的 Context 信息等。
3. 获取 @SentinelResource 标记资源对应的责任链
资源对应的责任链是限流逻辑具体执行的地方,采用的是典型的责任链模式。
先来看看默认的的责任链的组成:
默认的责任链中的处理节点包括 NodeSelectorSlot、ClusterBuilderSlot、StatisticSlot、FlowSlot、DegradeSlot 等。调用链(ProcessorSlotChain)和其中包含的所有 Slot 都实现了 ProcessorSlot 接口,采用责任链的模式执行各个节点的处理逻辑,并调用下一个节点。
每个节点都有自己的作用,后面将会看到这些节点具体是干什么的。
此外,相同资源(@SentinelResource 标记的方法)对应的责任链是一致的。也就是说,每个资源对应一条单独的责任链,可以看下源码中资源责任链的获取逻辑:先从缓存获取,没有则新建。
4. 生成调用凭证 Entry
生成的 Entry 是 CtEntry。其构造参数包括资源包装(ResourceWrapper)、资源对应的责任链以及当前线程的 Context。
可以看到,新建 CtEntry 记录了当前资源的责任链和 Context,同时更新 Context,将 Context 的当前 Entry 设置为自己。可以看到,CtEntry 是一个双向链表,构建了 Sentinel 资源的调用链路。
5. 责任链的执行
接下来就进入了责任链的执行。责任链和其中的 Slot 都实现了 ProcessorSlot,责任链的 entry 方法会依次执行责任链各个 slot,所以下面就进入了责任链中的各个 Slot。为了突出重点,这次本文只研究与限流功能有关的 Slot。
5.1 NodeSelectorSlot -- 获取当前资源对应 Node,构建节点调用树
此节点负责获取或者构建当前资源对应的 Node,这个 Node 被用于后续资源调用的统计及限流和熔断条件的判断。同时,NodeSelectorSlot 还会完成调用链路构建。来看源码:
熟悉的代码风格。我们知道一个资源对应一个责任链。每个调用链中都有 NodeSelectorSlot。
NodeSelectSlot 中的 node 缓存 map 是非静态变量,所以 map 只对当前这个资源共用,不同的资源对应的 NodeSelectSlot 及 Node 的缓存都是不一样的,资源和 Node 缓存 map 的关系可见下图。
所以 NodeSelectorSlot 的的作用是:
在资源对应的调用链执行时,获取当前 context 对应的 Node,这个 Node 代表着这个资源的调用情况。
将获取到的 node 设为当前 node,添加到之前的 node 后面,形成树状的调用路径。(通过 Context 中的当前 Entry 进行)
触发下一个 Slot 的执行。
这里有个很有趣的问题,就是我们在责任链的 NodeSelectorSlot 中获取资源对应的 Node 时,为什么用的是 Context 的 name,而不是 SentinelResource 的 name 呢?
首先,我们知道一个资源对应一条责任链。但是进入一个资源调用的 Context 却可能是不同的。如果使用资源名来作为 key,获取对应的 Node,那么通过不同 context 进来的调用方法获取到的 Node 就都是同一个了。所以通过这种方式,可以将相同 resource 对应的 node 按 Context 区分开。
举个例子,Sentinel 功能的实现不仅仅可以通过 @SentinelResource 注解方法来实现,也可以通过引入相关依赖(sentinel-dubbo-adapter),利用 Dubbo 的 Filter 机制直接对 DUBBO 接口进行保护。我们来比较 @SentinelResource 和 Dubbo 方式生成 Context 的区别:
@SentinelResource
生成的 context 的 name 是:sentinel_default_context。所有资源对应的 Context 都是这个值。
Dubbo Filter 方式
生成的 context 的 name 是 Dubbo 的接口限定名或者方法限定名。
如果出现嵌套在 Dubbo Filter 方式下面的其他 SentinelResource 的资源调用,那么这些资源调用的就会就会出现不同的 Context。
所以有这样一种情况,不同的 dubbo 接口进来,这些 dubbo 接口都调用了同一个 @SentinelResource 标记的方法,那么这个方法对应的 SentinelReource 的在执行时对应的 Context 就是不同的。
另一个问题是,既然资源按 Context 分出了不同的 node,那我们想看资源总数统计是怎么办呢?这就涉及到 ClusterNode 了。详细可见 ClusterBuilderSlot。
5.2 ClusterBuilderSlot -- 聚合相同资源不同 Context 的 Node
此节点负责聚合相同资源不同 Context 对应的 Node,以供后续限流判断使用。
可以看到,ClusterNode 的获取是以资源名为 key。ClusterNode 将会成为当前 node 的一个属性,主要目的是为了聚合同一个资源不同 Context 情况下的多个 node。默认的限流条件判断就是依据 ClusterNode 中的统计信息来进行的。
5.3 StatisticSlot -- 资源调用统计
此节点主要负责资源调用的统计信息的计算和更新。与前面以及后面的 slot 不同,StatisticSlot 的执行时先触发下一个 slot 的执行,等下面的 slot 执行完才会执行自己的逻辑。
这也很好理解,作为统计组件,总要等熔断或者限流处理完之后才能做统计吧。下面看一下具体的统计过程。
上面这张图已经很清晰的描述了 StatisticSlot 的数据统计的过程。可以注意一下无异常和阻塞异常的情况,主要是更新线程数、通过请求数量和阻塞请求数量。不管是 DefaultNode,还是 ClusterNode,都继承自 StatisticNode。所以 Node 的数据更新要来到 StatisticNode。
参考 Sentinel 数据统计框图,描述了 Node 统计数据更新的大体流程如下:
我们从 StatisticNode.addPassRequest()方法入手,以 passQps 为例,探究 StatisticNode 是如何更新通过请求的 QPS 计数的。
从源码可见,计数变量 rollingCounterInSecond 和 rollingCounterInMinute 都是 Metric,两个变量的时间维度分别是秒和分钟。rollingCounterInSecond 和 rollingCounterInMinute 用的是 Metric 的实现类 ArrayMetric。
从 ArrayMetric 追溯下去:
统计信息都是保存到 ArrayMetric 的 data,也就是 LeapArray<MertricBucket>中的。
LeapArray 是时间窗口数组。基本信息包括:时间窗口长度(ms,windowLengthInMs),取样数(也就是时间窗口的数量,sampleCount),时间间隔(ms,intervalInMs),以及时间窗口数组(array)。时间窗口长度、取样数及时间间隔有下面的关系:
windowLengthInMs = intervalInMs / sampleCount
代码中 rollingCounterInSecond 使用的 intervalInMs 是 1000(ms),也就是 1s,sampleCount=2。所以,窗口时长就是 windowLengthInMs = 500ms。rollingCounterInMinute 使用的 intervalInMs 是 60 * 1000(ms),也就是 60s。sampleCount=60,所以,windowLengthInMs = 1000ms,也就是 1s。
时间窗口数组(array)是类型是 AtomicReferenceArray,可见这是一个原子操作的的数组引用。数组元素类型是 WindowWrap<MetricBucket>。windowWrap 是对时间窗口的一个包装,包括窗口的开始时间(windowStart)及窗口的长度(windowLengthInMs),以及本窗口的计数器(value,类型为 MetricBucket)。窗口实际的计数是由 MetricBucket 进行的,计数信息是保存在 MetricBucket 里计数器 counters(类型为(LongAdder))。可以看一下下图计数组件的组成框图:
回到 StatisticNode.addPassRequest 方法,以 rollingCounterInSecond.addPass(count)为例,探究 Sentinel 如何进行滑动窗口计数的。
5.3.1 获取当前时间窗口
(1)取当前时间戳对应的数组下标
long timeId = time / windowLength
int idx = (int)(timeId % array.length());
time 为当前时间,windowLength 为时间窗口长度,rollingCounterInSecond 的时间窗口长度是 500ms。array 是单位时间内时间窗口的数量,rollingCounterInSecond 的单位时间(1s)时间窗口数是 2。timeId 是当前时间对时间窗口的整除。time 每增加一个 windowLength 的长度,timeId 就会增加 1,时间窗口就会往前滑动一个。
(2)计算窗口开始时间
窗口开始时间 = 当前时间(ms)-当前时间(ms)%时间窗口长度(ms)
获取的窗口开始时间均为时间窗口的整数倍。
(3)获取时间窗口
首先,根据数组下标从 LeapArray 的数组中获取时间窗口。
如果获取到的时间窗口自为空,则新建时间窗口(CAS)。
如果获取到的时间窗口非空,且时间窗口的开始时间等于我们计算的开始时间,说明当前时间正好在这个时间窗口里,直接返回该时间窗口。
如果获取到的时间窗口非空,且时间窗口的开始时间小于我们计算的开始时间,说明时间窗口已经过期(距离上次获取时间窗口已经过去比较久的场景),需要更新时间窗口(加锁操作),将时间窗口的开始时间设为计算出来的开始时间,将时间窗口里的计数器重置为 0。
如果获取到的时间窗口非空,且时间窗口的开始时间大于我们计算的开始时间,创建新的时间窗口。这个一般不会走进这个分支,因为说明当前时间已经落后于时间窗口了,获取到的时间窗口是将来的时间,那就没有意义了。
5.3.2 对时间窗口的计数器进行累加
时间窗口计数器是一个 LongAdder 数组,这个数组用于存放通过请求数、异常请求数、阻塞请求数等数据。如下图:
其中,通过计数、阻塞计数、异常计数为执行 StatisticSlot 的 entry 方法时更新。成功计数及响应时间是执行 StatisticSlot 的 exit 方法时更新。其实就是分别在被拦截方法执行前和执行后进行相应计数的更新。当然,addPass 就是在计数数组的第一个元素上进行累加。
计数数组元素类型是 LongAdder。LongAdder 是 JDK8 添加到 JUC 中的。它是一个线程安全的、比 Atomic*系工具性能更好的"计数器"。
5.4 FlowSlot -- 限流判断
FlowSlot 是进行限流条件判断的节点。之前在 StatisticSlot 对相关资源调用做的统计,在 FlowSlot 限流判断时将会得到使用。
直接来到限流操作的核心逻辑–限流规则检查器(FlowRuleChecker):
主要的流程包括:
获取资源对应的限流规则
根据限流规则检查是否被限流
如果被限流,则抛出限流异常 FlowException。FlowException 继承自 BlockException。
那么 FlowSlot 检查是否限流的过程是怎么样的?
默认情况下,限流使用的节点是当前节点的 cluster node。主要分析的限流方式是 QPS 限流。来看一下限流的关键代码(DefaultController):
获取节点的当前 qps 计数;
判断获取新的计数后是否超过阈值
超过阈值单返回 false,表示被限流,后面会抛出 FlowException。否则返回 true,不被限流。
可以看到限流判断非常简单,只需要对 qps 计数进行检查就可以了。这归功于 StatisticSlot 做的数据统计。
5.5 责任链小结
通过上面的讲解,再来看下面这张图,是不是很清晰了?
( 引用于 Sentinel 官网)
NodeSelectorSlot 用于获取资源对应的 Node,并构建 Node 调用树,将 SentinelSource 的调用链路以 Node Tree 的形式组起来。ClusterBuilderSlot 为当前 Node 创建对应的 ClusterNode,聚合相同资源对应的不同 Context 的 Node,后续的限流依据就是这个 ClusterNode。
ClusterNode 继承自 StatisticNode,记录着相应资源处理的一些统计数据。StatisticSlot 用于更新资源调用的相关计数,用于后续的限流判断使用。FlowSlot 根据资源对应 Node 的调用计数,判断是否进行限流。至此,Sentinel 的责任链执行逻辑就完整了。
6. Sentienl 的收尾工作
无论执行成功还是失败,或者是阻塞,都会执行 Entry.exit()方法,来看一下这个方法。
判断要退出的 entry 是否是当前 context 的当前 entry;
如果要退出的 entry 不是当前 context 的当前 entry,则不退出此 entry,而是退出 context 的的当前 entry 及其所有父 entry,并抛出异常;
如果要退出的 entry 是当前 context 的当前 entry(这种是正常情况),先退出当前 entry 对应的责任链的所有 slot。在这一步,StatisticSlot 会更新 node 的 success 计数和 RT 计数;
将 context 的当前 entry 置为被退出的 entry 的父 entry;
如果被退出 entry 的父 entry 为空,且 context 为默认 context,自动退出默认 context(清除 ThreadLocal)。
清除被退出 entry 的 context 引用
7. 总结
通过阅读 Sentinel 的源码,可以很清晰的理解 Sentinel 的限流过程了,而对上面的源码阅读,总结如下:
三大组件 Context、Entry、Node,是 Sentinel 的核心组件,各类信息及资源调用情况都由这三大类持有;
采用责任链模式完成 Sentinel 的信息统计、熔断、限流等操作;
责任链中 NodeSelectSlot 负责选择当前资源对应的 Node,同时构建 node 调用树;
责任链中 ClusterBuilderSlot 负责构建当前 Node 对应的 ClusterNode,用于聚合同一资源对应不同 Context 的 Node;
责任链中的 StatisticSlot 用于统计当前资源的调用情况,更新 Node 与其对用的 ClusterNode 的各种统计数据;
责任链中的 FlowSlot 根据当前 Node 对应的 ClusterNode(默认)的统计信息进行限流;
资源调用统计数据(例如 PassQps)使用滑动时间窗口进行统计;
所有工作执行完毕后,执行退出流程,补充一些统计数据,清理 Context。
三、参考文献
https://github.com/alibaba/Sentinel/wiki
作者:Sun Yi
版权声明: 本文为 InfoQ 作者【vivo互联网技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/59bf074661c3bf21b11cf6683】。文章转载请联系作者。
评论