深度解析微服务高并发资源指标数据统计:资源指标数据统计全解析
资源指标数据统计全解析
本节将结合前面所学的内容,以及基于滑动窗口实现资源指标数据统计,分析Sentinel是如何实现资源指标数据统计的。
节点选择器插槽
节点选择器插槽(NodeSelectorSlot)负责为资源的首次访问创建DefaultNode实例,以及修改Context实例的curNode字段指向当前资源的DefaultNode实例,将DefaultNode实例绑定到调用树上。因为后续的ProcessorSlot在逻辑上都需要依赖这个ProcessorSlot,所以它被放在ProcessorSlot链表的第一个位置。NodeSelectorSlot的源码如下。

如源码所示,map字段是一个非静态字段,意味着每个NodeSelectorSlot实例都有一个Map实例。而Sentinel只会为一个资源创建一个ProcessorSlotChain,一个ProcessorSlotChain又只会创建一个NodeSelectorSlot,并且map字段缓存DefaultNode使用的key并非资源ID,而是调用链入口名称。所以,map字段的作用是缓存同一资源、不同调用链入口创建的DefaultNode实例。
Sentinel会为同一资源创建多少个DefaultNode实例取决于有多少个入口节点不同的调用链包含这个资源,这就是为什么说一个资源可能有多个DefaultNode实例的原因。
为什么这么设计呢?举个例子,对于同一支付接口,我们既可以使用Spring MVC暴露给前端访问,也可以使用Dubbo暴露给其他内部服务调用。由于入口节点不同,支付接口会被两条调用链包含。针对这种情况,我们可以通过设置来限制从Spring MVC进来的流量,也就是对前端请求限流。
NodeSelectorSlot类的entry方法最难理解的就是,将当前资源的DefaultNode实例绑定到调用树的如下代码:

这行代码可以分为两种情况来分析,接下来以Sentinel提供的demo为例进行分析。
1. 一般情况
Sentinel的sentinel-demo模块提供了多种使用场景的demo,我们以sentinel-demo-springwebmvc这个demo为例进行讲解。该demo下有一个hello接口,其代码如下。

提示:这里不需要添加任何规则,只是为了调试Sentinel的源码。
在启动demo后,使用浏览器访问hello接口,在NodeSelectorSlot类的entry方法的绑定调用树这一行代码下断点,观察此时Context实例的字段信息。在正常情况下,我们可以看到如图4.4所示的结果。

图4.4 绑定调用树1
从图4.4中可以看出,此时调用链入口节点(entranceNode)的子节点(childList)为空,并且当前CtEntry实例(curEntry)的父(parent)、子(child)节点都是Null。当绑定调用树这一行代码执行完成后,Context实例的字段信息如图4.5所示。

图4.5 绑定调用树2
从图4.5中可以看出,NodeSelectorSlot为当前资源创建的DefaultNode实例被添加到了调用链入口节点(entranceNode)的子节点(childList)中。
此时,ROOT节点、调用链入口节点及当前资源的DefaultNode节点构造成的调用树如下。

如果现在访问demo的其他接口,如访问err接口,则将会生成如下所示的调用树。

提示:名称为
sentinel_spring_web_context的调用链入口节点将会存储Web项目中所有资源的DefaultNode节点。
2. 存在多次调用SphU#entry方法的情况
如果在一个服务中既添加了Sentinel的WebMvc适配模块的依赖,也添加了Sentinel的OpenFeign适配模块的依赖,并且使用了OpenFeign调用内部其他服务的接口,就会存在一次调用链路上出现多次调用SphU#entry方法的情况。
WebMvc适配器在接收客户端请求时会调用一次SphU#entry方法,在处理客户端请求时可能需要使用OpenFeign调用内部其他服务的接口,那么在发起接口调用时,Sentinel的OpenFeign适配器也会调用一次SphU#entry方法。
现在将demo的hello接口修改一下,将hello接口调用的doBusiness方法也作为资源,并使用Sentinel保护起来,改造后的hello接口代码如下。

我们可以将doBusiness方法看作远程调用。例如,使用POST方式调用第三方的接口,接口名称为hello2,那么我们可以使用POST:/hello2作为资源名称,并将流量类型设置为OUT类型,将上下文名称设置为my_context。
在启动demo后,使用浏览器访问hello接口。当代码执行到apiHello方法时,在NodeSelectorSlot#entry方法的绑定调用树这一行代码下断点,当绑定调用树这一行代码执行完成后,Context实例的字段信息如图4.6所示。
从图4.6中可以看出,Sentinel并没有创建名称为my_context的Context实例,因为当前调用链上已经存在Context实例,Sentinel只是在调用链入口处创建了Context实例。

图4.6 绑定调用树3
在执行NodeSelectorSlot#entry方法之前,由于还没有为名称为POST:/hello2的资源创建ProcessorSlotChain,因此SphU#entry方法会为该资源创建一个ProcessorSlotChain,并为该ProcessorSlotChain创建一个NodeSelectorSlot。当执行到NodeSelectorSlot#entry方法时,该方法就会为该资源创建一个DefaultNode实例,而将该资源的DefaultNode实例绑定到节点树后,该资源的DefaultNode实例就会成为GET:/hello资源的DefaultNode实例的子节点,此时调用树如下。

此时,当前调用链路上已经存在两个CtEntry实例,这两个CtEntry实例构造了一个双向链表,如图4.7所示。

图4.7 CtEntry构造的双向链表
虽然存在两个CtEntry实例,但此时Context实例的curEntry字段指向的是第二个CtEntry实例,第二个CtEntry实例是在apiHello方法调用SphU#entry方法时创建的。在执行完doBusiness方法后,需要调用当前CtEntry#exit方法,由该CtEntry将Context实例的curEntry字段还原为指向该CtEntry的父CtEntry。
接下来分析NodeSelectorSlot#entry方法中的另一行代码,代码如下。

这行代码的作用是将当前创建的DefaultNode实例赋值给当前CtEntry实例的curNode字段。结合图4.7来理解,就是将资源GET:/hello的DefaultNode实例赋值给第一个CtEntry实例的curNode字段,将资源POST:/hello2的DefaultNode实例赋值给第二个CtEntry实例的curNode字段。
ClusterNode构造器插槽
在一个资源的ProcessorSlotChain中,NodeSelectorSlot负责为资源创建DefaultNode实例,而这个DefaultNode实例仅限同一入口的调用链使用。所以,一个资源可能会存在多个DefaultNode实例,那么想要获取一个资源的总QPS,就必须遍历这些DefaultNode实例。出于性能考虑,Sentinel会为每个资源创建一个全局唯一的ClusterNode实例,用于统计资源的全局指标数据。
与NodeSelectorSlot的职责相似,ClusterBuilderSlot的职责是为资源创建全局唯一的ClusterNode实例,且仅在资源第一次被访问时创建。
ClusterBuilderSlot在创建ClusterNode实例时,需要将ClusterNode实例赋值给DefaultNode实例的clusterNode字段,由DefaultNode实例持有ClusterNode实例,并由DefaultNode负责代理ClusterNode完成资源指标数据统计。
必须先有DefaultNode实例,才能将ClusterNode实例委托给DefaultNode实例,这就是ClusterBuilderSlot在ProcessorSlotChain中必须被放在NodeSelectorSlot之后的原因。
ClusterBuilderSlot声明的字段如下。

因为一个资源只会有一个ProcessorSlotChain,这就意味着ClusterBuilderSlot只会被创建一个,那么让ClusterBuilderSlot持有资源的ClusterNode实例,就可以省去每次都从clusterNodeMap静态字段中获取资源的ClusterNode实例的步骤,这当然也是出于性能方面的考虑。
ClusterBuilderSlot类的entry方法的源码如下。

① 如果clusterNode字段为空,说明当前资源首次被访问,那么需要为资源创建一个全局唯一的ClusterNode实例。
② 将ClusterNode实例委托给资源的DefaultNode实例来统计资源指标数据,node参数为NodeSelectorSlot传递过来的DefaultNode实例。
③ 如果调用来源不为空,那么为当前调用来源创建一个StatisticNode实例。
ClusterBuilderSlot将ClusterNode实例赋值给DefaultNode实例的clusterNode字段,后续的ProcessorSlot就能从entry方法的node参数中获取ClusterNode实例。DefaultNode与ClusterNode的关系如图4.8所示。

图4.8 DefaultNode与ClusterNode的关系
每个ClusterNode实例都有一个Map类型的字段,用来缓存调用来源(origin)与StatisticNode实例的映射,代码如下。

•originCountMap:如果上游服务调用当前服务的接口将origin字段传递过来,那么ClusterBuilderSlot就会为ClusterNode实例创建一个StatisticNode实例,用来统计当前资源被该远程服务调用的指标数据。
提示:例如,上游服务在发送HTTP请求时,在请求头添加S-user参数,或者上游服务在发送Dubbo RPC调用时,在请求参数列表添加application参数,就能获取来源应用名称。
当我们想要查看哪个来源应用访问这个接口最频繁时,可以从ClusterNode实例的originCountMap字段,根据来源应用名称获取StatisticNode实例,从而获取QPS,并据此实现按调用来源限流。
ClusterNode#getOrCreateOriginNode方法的源码如下。

为了便于使用,ClusterBuilderSlot会将调用来源的StatisticNode实例赋值给CtEntry实例的originNode字段,后续的ProcessorSlot可先调用Context实例的getCurEntry方法获取CtEntry实例,再调用CtEntry实例的getOriginNode方法即可获取该StatisticNode实例。
这里我们可以得出一个结论,如果自定义的ProcessorSlot需要用到调用来源的StatisticNode,那么在构建ProcessorSlotChain时,必须将这个自定义的ProcessorSlot放在ClusterBuilderSlot之后。
资源指标数据统计插槽
StatisticSlot是实现资源各项指标数据统计的处理器插槽,它与NodeSelectorSlot、ClusterBuilderSlot共同组成了资源指标数据统计流水线。
NodeSelectorSlot负责为资源创建DefaultNode实例,并将DefaultNode实例向下传递给ClusterBuilderSlot;ClusterBuilderSlot则负责加工资源的DefaultNode实例,添加ClusterNode实例,然后将DefaultNode实例向下传递给StatisticSlot,如图4.9所示。

图4.9 资源指标数据统计流水线
StatisticSlot在统计指标数据之前会先调用后续的ProcessorSlot,再根据后续ProcessorSlot判断是否需要拒绝当前请求的结果并决定记录哪些指标数据。StatisticSlot的源码框架如下。

•entry:先通过fireEntry方法调用后续的ProcessorSlot#entry方法,再根据后续的ProcessorSlot是否抛出BlockException来决定统计哪些指标数据,并将资源并行占用的线程数加1。
• exit:若无任何异常,则统计请求成功、请求执行耗时指标,并将资源并行占用的线程数减1。
从StatisticSlot#entry方法的源码中可以看出,为什么Sentinel设计的责任链需要由前一个ProcessorSlot在entry方法或exit方法中调用fireEntry方法或fireExit方法以调用下一个ProcessorSlot的entry方法或exit方法,而不是使用for循环遍历调用ProcessorSlot。因为每个ProcessorSlot都有权决定先等后续的ProcessorSlot执行完成再做自己的事情,还是先完成自己的事情再让后续的ProcessorSlot执行,这与流水线有所区别。
1. entry方法
第一种情况:当后续的ProcessorSlot未抛出任何异常时,表示不需要拒绝当前请求,当前请求会被放行。
如果当前请求被放行,则需要将当前资源并行占用的线程数加1,将当前时间窗口被放行的请求总数加1,代码如下。

如果调用来源不为空,也将调用来源对应的StatisticNode的当前并行占用线程数加1,将当前时间窗口被放行的请求数加1,代码如下。
如果流量类型为IN,则让统计整个应用所有流入类型流量的ENTRY_NODE自增并行占用的线程数、当前时间窗口被放行的请求数加1,代码如下。
回调所有的ProcessorSlotEntryCallback的onPass方法,代码如下。
调用
StatisticSlotCallbackRegistry#addEntryCallback静态方法注册
ProcessorSlotEntryCallback。ProcessorSlotEntryCallback接口的定义如下。
• onPass:该方法在请求被放行时被回调执行。
• onBlocked:该方法在请求被拒绝时被回调执行。
第二种情况:捕获到PriorityWaitException。
这是特殊情况,在需要对请求限流时,只有使用默认流量效果控制器才可能会抛出PriorityWaitException,这部分内容将在讲解FlowSlot的实现源码时再做分析。
当捕获到PriorityWaitException时,说明当前请求已经被休眠了一段时间了,但还是允许请求通过的,只是不需要让DefaultNode实例统计这个请求了,只自增当前资源并行占用的线程数,同时,DefaultNode实例也会让ClusterNode实例自增并行占用的线程数,最后会回调所有
ProcessorSlotEntryCallback#onPass方法。这部分的源码如下。
第三种情况:捕获到BlockException。
BlockException只在需要拒绝请求时被抛出。捕获到BlockException时执行的代码如下。
①当捕获到BlockException时,将异常保存到调用链上下文的当前CtEntry实例中,StatisticSlot的exit方法会识别是统计请求异常指标还是统计请求被拒绝指标。
②调用DefaultNode#increaseBlockQps方法自增请求被拒绝总数,将当前时间窗口的block qps这项指标数据的值加1。
③ 如果调用来源不为空,则让调用来源对应的StatisticNode实例统计的请求被拒绝总数加1。
④ 如果流量类型为IN,则让ENTRY_NODE统计的请求被拒绝总数加1。
⑤ 回调所有的
ProcessorSlotEntryCallback#onBlocked方法。
StatisticSlot捕获BlockException只是为了统计请求被拒绝的总数,而BlockException还是会被向上抛出。抛出异常的目的是拦住请求,执行服务降级处理。
第四种情况:捕获到其他异常。
其他异常并非指业务异常,因为此时业务代码还未被执行,而业务代码抛出的异常,会通过调用Tracer#trace方法统计请求异常总数。
当捕获到非BlockException时,除PriorityWaitException外,其他类型的异常都进行同样的处理:让资源的DefaultNode实例自增当前时间窗口的请求异常总数;让调用来源的StatisticNode实例、统计所有IN类型流量的ENTRY_NODE自增当前时间窗口的请求异常总数。这部分的源码如下。
① 将异常保存到调用链上下文的当前Entry实例中。
②调用DefaultNode#increaseExceptionQps方法统计异常指标,将当前时间窗口的exception qps这项指标数据的值加1。
③ 如果调用来源不为空,则让调用来源的StatisticNode实例统计异常指标。
④ 如果流量类型为IN,则让ENTRY_NODE统计异常指标。⑤ 抛出异常。
2. exit方法
当exit方法被调用时,要么请求被拒绝,要么请求被放行且已经被执行完成,所以exit方法需要知道当前请求是否被正常执行完成,这正是StatisticSlot在捕获异常时将异常保存到当前CtEntry实例的原因。
exit方法通过Context实例可以获取当前CtEntry实例,从当前CtEntry实例中可以获取entry方法中保存的异常。exit方法的源码如下(有删减)。

① exit方法通过Context实例可以获取当前资源的DefaultNode实例,如果entry方法中未出现异常,则说明请求是正常完成的。
② 当计算耗时时,可以将当前时间减去调用链上当前CtEntry实例的创建时间的值作为请求的执行耗时。
③ 在请求被正常完成的情况下,需要统计总耗时指标,增加当前请求的执行耗时,统计成功请求总数,将成功请求总数加1。
④ 如果调用来源不为空,则让调用来源的StatisticNode实例统计总耗时指标,增加当前请求的执行耗时,统计成功请求总数,将成功请求总数加1。
⑤ 恢复当前资源占用的线程数。
⑥ 如果调用来源不为空,则恢复当前调用来源占用的线程数。
⑦如果流量类型为IN,则让ENTRY_NODE统计总耗时指标,增加当前请求的执行耗时,统计成功请求总数,将成功请求总数加1,恢复占用的线程数。
⑧ 回调所有ProcessorSlotExitCallback#onExit方法。
资源指标数据的收集过程
ClusterNode是一个资源全局的指标数据统计节点,但我们并未在StatisticSlot的entry方法与exit方法中看到其被使用。这里实际上使用了委托模式,ClusterNode被ClusterBuilderSlot委托给DefaultNode统计指标数据,如下述代码所示。

当请求被成功处理后,StatisticSlot会调用DefaultNode实例的addRtAndSuccess方法增加请求处理成功总数和总耗时;DefaultNode会先调用父类的addRtAndSuccess方法,再调用ClusterNode实例的addRtAndSuccess方法。ClusterNode类与DefaultNode类都是StatisticNode类的子类。StatisticNode类的addRtAndSuccess方法的源码如下。

rollingCounterInSecond是一个秒级滑动窗口,rollingCounterInMinute是一个分钟级滑动窗口,类型都为ArrayMetric。分钟级滑动窗口共有60个MetricBucket,每个MetricBucket都会被WindowWrap数组包装,用于统计1秒内的各项指标数据,如图4.10所示。

图4.10 WindowWrap数组
当调用rollingCounterInMinute的addSuccess方法时,先由滑动窗口根据当前时间戳获取当前时间窗口的MetricBucket实例,再调用MetricBucket实例的addSuccess方法,将success这项指标的值加上方法参数successCount的值(一般是1)。
Sentinel在MetricEvent枚举类中定义了Sentinel会收集的指标数据。MetricEvent枚举类的源码如下。

• PASS指标:请求被放行的总数。
• BLOCK指标:请求被拒绝的总数。
• EXCEPTION指标:异常的请求总数。
• SUCCESS指标:被成功处理的请求总数。
• RT指标:被成功处理的请求的总耗时。
•OCCUPIED_PASS指标:预通过总数(前一个时间窗口使用了当前时间窗口的passQps)。
其他一些指标数据都可以通过以上指标数据计算得出,例如,被成功处理的请求的平均耗时可以根据被成功处理的请求的总耗时除以被成功处理的请求总数计算得出。
小结
本篇主要介绍了Sentinel如何实现基于滑动窗口统计资源的实时指标数据、Sentinel资源指标数据统计流程分析,以及调用树的结构,同时分析了NodeSelectorSlot、ClusterBuilderSlot和StatisticSlot这几个处理器插槽的用途及它们之间的联系。