资源指标统计实现全解析(下篇):StatisticSlot核心原理

图片[1]-资源指标统计实现全解析(下篇):StatisticSlot核心原理 - 速优课-速优课

资源指标统计实现全解析(下篇):StatisticSlot核心原理

前言

上一篇我们分析了NodeSelectorSlot和ClusterBuilderSlot,它们负责构建各种Node,为统计做好准备。

今天这篇,我们来分析统计链路的最后一个、也是最核心的一个Slot——StatisticSlot

StatisticSlot是真正负责记录各项指标数据的Slot。它和NodeSelectorSlot、ClusterBuilderSlot一起,组成了Sentinel的”指标统计流水线”。

一、StatisticSlot的特殊地位

先看一下统计流水线的分工:

图片[2]-资源指标统计实现全解析(下篇):StatisticSlot核心原理 - 速优课-速优课
  • NodeSelectorSlot:创建DefaultNode,向下传递
  • ClusterBuilderSlot:给DefaultNode加上ClusterNode,向下传递
  • StatisticSlot:根据后续Slot的执行结果,记录各项指标数据

为什么StatisticSlot这样设计?

注意StatisticSlot的entry方法有个特殊之处:它先调用fireEntry让后面的Slot执行,然后根据执行结果来统计数据。

这也是为什么Sentinel的责任链要用”每个Slot自己调用fireEntry”的方式,而不是用for循环遍历——因为每个Slot都可以决定:

  • 是先做自己的事,再让后面的执行
  • 还是先让后面的执行完,再做自己的事

StatisticSlot就是后者——它需要知道请求是被放行还是被拒绝了,才能决定记录什么数据。

StatisticSlot的整体框架

先看StatisticSlot的整体结构:

public class StatisticSlot extends AbstractLinkedProcessorSlot<DefaultNode> {
​
    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, 
                      int count, boolean prioritized, Object... args) throws Throwable {
        try {
            // 先调用后面的Slot
            fireEntry(context, resourceWrapper, node, count, prioritized, args);
            // 请求通过,记录通过数据
        } catch (PriorityWaitException ex) {
            // 优先级等待异常,特殊处理
        } catch (BlockException e) {
            // 请求被拒绝,记录拒绝数据
            throw e;
        } catch (Throwable e) {
            // 其他异常,记录异常数据
            throw e;
        }
    }
​
    @Override
    public void exit(Context context, ResourceWrapper resourceWrapper, 
                     int count, Object... args) {
        DefaultNode node = (DefaultNode)context.getCurNode();
        // 请求处理完成,记录成功和耗时
        fireExit(context, resourceWrapper, count);
    }
}

简单总结:

  • entry阶段:先放行,根据结果记录(通过/拒绝/异常),同时线程数+1
  • exit阶段:记录成功和耗时,线程数-1

下面我们分情况详细分析。

二、entry方法的四种情况

entry方法里有四个分支,对应四种不同的情况。我们一个个来看。

2.1 情况一:请求正常通过

后续的Slot没有抛出任何异常,说明请求通过了所有检查,可以放行。

需要做这些事情:

// 1. DefaultNode:线程数+1,通过数+1
node.increaseThreadNum();
node.addPassRequest(count);
​
// 2. OriginNode:如果有调用来源,来源节点也记录
if (context.getCurEntry().getOriginNode() != null) {
    context.getCurEntry().getOriginNode().increaseThreadNum();
    context.getCurEntry().getOriginNode().addPassRequest(count);
}
​
// 3. ENTRY_NODE:如果是流入流量,全局入口节点也记录
if (resourceWrapper.getEntryType() == EntryType.IN) {
    Constants.ENTRY_NODE.increaseThreadNum();
    Constants.ENTRY_NODE.addPassRequest(count);
}
​
// 4. 回调:通知所有注册的通过回调
for (ProcessorSlotEntryCallback<DefaultNode> handler : 
     StatisticSlotCallbackRegistry.getEntryCallbacks()) {
    handler.onPass(context, resourceWrapper, node, count, args);
}

三个层级的Node都要更新:

  • DefaultNode:当前Context下这个资源的统计
  • OriginNode:按调用来源的统计(如果有的话)
  • ENTRY_NODE:全局入口节点的统计(只有IN类型才更新)

为什么要更新这么多Node?因为不同的功能需要不同维度的统计数据。比如按来源限流需要OriginNode的数据,系统自适应限流需要ENTRY_NODE的数据。

回调机制

Sentinel还提供了回调机制——你可以注册ProcessorSlotEntryCallback,在请求通过或被拒绝时收到通知。

public interface ProcessorSlotEntryCallback<T> {
    // 请求通过时回调
    void onPass(Context context, ResourceWrapper resourceWrapper, T param, 
                int count, Object... args) throws Exception;
    // 请求被拒绝时回调
    void onBlocked(BlockException ex, Context context, ResourceWrapper resourceWrapper, 
                   T param, int count, Object... args);
}

通过StatisticSlotCallbackRegistry.addEntryCallback()注册。这是一个扩展点,可以用来做日志、告警等。


2.2 情况二:捕获PriorityWaitException

这是一个比较特殊的情况。

PriorityWaitException是什么?只有在使用优先级限流时才会出现。简单说就是:请求没被直接拒绝,而是先等一会儿再通过。

这种情况下,请求最终还是会通过的,所以:

  • 不需要记录通过数(因为已经在别的地方记了?或者说这是预通过)
  • 只需要把线程数+1
node.increaseThreadNum();
if (context.getCurEntry().getOriginNode() != null) {
    context.getCurEntry().getOriginNode().increaseThreadNum();
}
if (resourceWrapper.getEntryType() == EntryType.IN) {
    Constants.ENTRY_NODE.increaseThreadNum();
}
// 回调onPass,因为最终还是通过了
for (ProcessorSlotEntryCallback<DefaultNode> handler : 
     StatisticSlotCallbackRegistry.getEntryCallbacks()) {
    handler.onPass(context, resourceWrapper, node, count, args);
}

具体的优先级限流逻辑,我们在分析FlowSlot的时候再详细讲。


2.3 情况三:捕获BlockException

BlockException是Sentinel的”拒绝异常”——只要抛出这个异常,就说明请求被限流或熔断了。

捕获到BlockException后,需要做这些事:

// 1. 把异常存到Entry里,exit方法会用到
context.getCurEntry().setError(e);
​
// 2. DefaultNode:记录拒绝数
node.increaseBlockQps(count);
​
// 3. OriginNode:来源节点也记录拒绝数
if (context.getCurEntry().getOriginNode() != null) {
    context.getCurEntry().getOriginNode().increaseBlockQps(count);
}
​
// 4. ENTRY_NODE:全局入口也记录
if (resourceWrapper.getEntryType() == EntryType.IN) {
    Constants.ENTRY_NODE.increaseBlockQps(count);
}
​
// 5. 回调onBlocked
for (ProcessorSlotEntryCallback<DefaultNode> handler : 
     StatisticSlotCallbackRegistry.getEntryCallbacks()) {
    handler.onBlocked(e, context, resourceWrapper, node, count, args);
}
​
// 6. 重新抛出异常!
throw e;

注意最后一步:StatisticSlot只是”偷偷”记录一下,然后把异常重新抛出去

为什么要重新抛出?因为异常需要一路抛到最外层,让调用方知道请求被拒绝了,才能执行降级逻辑。

这里有个细节:异常先存到了CtEntry里。为什么?因为exit方法需要知道请求是成功还是失败了,才能决定要不要记录成功和耗时。


2.4 情况四:捕获其他异常

除了BlockException和PriorityWaitException,其他异常都算”系统异常”。

注意:这里说的不是业务异常——业务代码还没执行呢,业务异常是通过Tracer.trace()记录的。这里的异常是指Sentinel内部Slot抛出的非BlockException。

处理方式:

// 1. 存到Entry里
context.getCurEntry().setError(e);
​
// 2. 记录异常数
node.increaseExceptionQps(count);
if (context.getCurEntry().getOriginNode() != null) {
    context.getCurEntry().getOriginNode().increaseExceptionQps(count);
}
if (resourceWrapper.getEntryType() == EntryType.IN) {
    Constants.ENTRY_NODE.increaseExceptionQps(count);
}
​
// 3. 重新抛出
throw e;

和BlockException类似,记录完异常数就抛出去。

三、exit方法:记录成功与耗时

讲完了entry,再来看exit方法。

exit方法在什么时候调用?在业务代码执行完之后,finally块里调用entry.exit()的时候。

exit方法需要知道:请求是成功完成了,还是中途被拒绝了?

答案就在entry方法里——如果被拒绝了,异常会存在CtEntry.error字段里。exit方法检查这个字段就知道了。

@Override
public void exit(Context context, ResourceWrapper resourceWrapper, 
                 int count, Object... args) {
    DefaultNode node = (DefaultNode)context.getCurNode();
    
    // 没有异常 = 请求正常完成
    if (context.getCurEntry().getError() == null) {
        // 计算耗时:当前时间 - Entry创建时间
        long rt = TimeUtil.currentTimeMillis() - context.getCurEntry().getCreateTime();
        
        // 1. DefaultNode:记录耗时和成功数
        node.addRtAndSuccess(rt, count);
        
        // 2. OriginNode:来源节点也记录
        if (context.getCurEntry().getOriginNode() != null) {
            context.getCurEntry().getOriginNode().addRtAndSuccess(rt, count);
        }
        
        // 3. DefaultNode:线程数-1
        node.decreaseThreadNum();
        
        // 4. OriginNode:线程数-1
        if (context.getCurEntry().getOriginNode() != null) {
            context.getCurEntry().getOriginNode().decreaseThreadNum();
        }
        
        // 5. ENTRY_NODE:流入流量的话也记录
        if (resourceWrapper.getEntryType() == EntryType.IN) {
            Constants.ENTRY_NODE.addRtAndSuccess(rt, count);
            Constants.ENTRY_NODE.decreaseThreadNum();
        }
    }
    
    // 退出回调
    Collection<ProcessorSlotExitCallback> exitCallbacks = 
        StatisticSlotCallbackRegistry.getExitCallbacks();
    for (ProcessorSlotExitCallback handler : exitCallbacks) {
        handler.onExit(context, resourceWrapper, count, args);
    }
    
    fireExit(context, resourceWrapper, count);
}

核心逻辑:

  • 如果没出错:计算耗时,记录成功数和耗时,线程数-1
  • 如果出错了:啥也不做(因为entry阶段已经记录过了)
  • 最后:调用exit回调,继续向后传递

耗时是怎么计算的?

很简单:当前时间 – Entry的创建时间

long rt = TimeUtil.currentTimeMillis() - context.getCurEntry().getCreateTime();

Entry是什么时候创建的?在CtSph.entryWithPriority里,调用chain.entry之前创建的。所以这个耗时包含了:

  • 所有Slot的entry方法执行时间
  • 业务代码执行时间
  • (不包含exit方法的执行时间)

对于大多数场景,这个精度足够了。

四、指标数据的完整传递链路

StatisticSlot调用的是DefaultNode的方法,那ClusterNode的数据是怎么更新的?

答案是:DefaultNode会自动同步给ClusterNode

看DefaultNode的代码:

public class DefaultNode extends StatisticNode {
    private ClusterNode clusterNode;

    @Override
    public void addPassRequest(int count) {
        // 先更新自己的
        super.addPassRequest(count);
        // 再同步给ClusterNode
        this.clusterNode.addPassRequest(count);
    }
}

不止addPassRequest,increaseBlockQps、increaseExceptionQps、addRtAndSuccess、increaseThreadNum、decreaseThreadNum……所有这些方法,DefaultNode都会同时更新自己和ClusterNode。

这样设计的好处是:StatisticSlot只需要操作DefaultNode就行,不用关心ClusterNode的存在。数据同步由DefaultNode内部完成。

最终落到滑动窗口

不管是DefaultNode还是ClusterNode,最终统计数据都存在StatisticNode的滑动窗口里。

StatisticNode里有两个滑动窗口:

  • 秒级(rollingCounterInSecond):用于实时统计
  • 分钟级(rollingCounterInMinute):用于历史数据

以addRtAndSuccess为例:

@Override
public void addRtAndSuccess(long rt, int successCount) {
    // 秒级滑动窗口
    rollingCounterInSecond.addSuccess(successCount);
    rollingCounterInSecond.addRT(rt);
    // 分钟级滑动窗口
    rollingCounterInMinute.addSuccess(successCount);
    rollingCounterInMinute.addRT(rt);
}

两个窗口都要更新。

最终数据存在MetricBucket的LongAdder数组里,就是我们之前讲滑动窗口时说的那个结构。

图片[3]-资源指标统计实现全解析(下篇):StatisticSlot核心原理 - 速优课-速优课

Sentinel收集哪些指标?

在MetricEvent枚举里定义了所有指标:

public enum MetricEvent {
    PASS,           // 通过数
    BLOCK,          // 拒绝数
    EXCEPTION,      // 异常数
    SUCCESS,        // 成功数
    RT,             // 总耗时
    OCCUPIED_PASS   // 预通过数
}
指标含义
PASS请求被放行的总数
BLOCK请求被拒绝的总数
EXCEPTION请求处理异常的总数
SUCCESS请求处理成功的总数
RT成功请求的总耗时
OCCUPIED_PASS预通过总数(抢占下一个窗口的配额)

其他指标都可以通过这些基础指标计算出来,比如:

  • 平均耗时 = RT / SUCCESS
  • 异常率 = EXCEPTION / (SUCCESS + EXCEPTION)
  • 等等

五、整体总结:数据是怎么流动的

最后,我们用一张完整的图来梳理一下,从请求进入到退出,数据是怎么流动的:

请求进入
  │
  ▼
entry()
  ├─→ NodeSelectorSlot:创建DefaultNode,构建调用树
  ├─→ ClusterBuilderSlot:创建ClusterNode,关联DefaultNode
  └─→ StatisticSlot
       ├─→ 先调用fireEntry() → 经过AuthoritySlot/SystemSlot/FlowSlot/DegradeSlot
       │     ├─→ 通过 → 记录pass + thread+1
       │     ├─→ 被拒绝 → 记录block + 存异常 + 抛出
       │     └─→ 其他异常 → 记录exception + 存异常 + 抛出
       │
  ┌────┘
  │
  ▼
执行业务代码
  │
  ├─→ 业务异常 → Tracer.trace() → 记录exception
  │
  ▼
exit()
  └─→ StatisticSlot
       ├─→ 成功 → 计算RT → 记录success + RT + thread-1
       └─→ 失败 → 啥也不做

关键数据结构再回顾

结构维度作用
DefaultNode资源 + Context当前调用链下的资源统计
ClusterNode资源(全局)资源的全局统计
OriginNode资源 + 来源按调用来源的统计
ENTRY_NODE全局入口系统整体的统计
StatisticNode基础统计实现(滑动窗口)

数量关系

  • 1个线程 → 1个Context(ThreadLocal)
  • 1个Context名称 → 1个EntranceNode
  • 1个资源 + N个Context名称 → N个DefaultNode
  • 1个资源 → 1个ProcessorSlotChain → 1个ClusterNode
  • 1个资源 + M个来源 → M个OriginNode

总结与思考

这两篇文章,我们把Sentinel的指标统计实现完整地过了一遍。

从NodeSelectorSlot构建DefaultNode,到ClusterBuilderSlot添加ClusterNode,再到StatisticSlot记录各项指标——三个Slot各司其职,组成了完整的统计流水线。

几个核心要点再强调一下:

  1. StatisticSlot的设计很巧妙:先放行再统计,通过异常类型判断结果
  2. 数据是多维度的:DefaultNode、ClusterNode、OriginNode、ENTRY_NODE,满足不同场景需求
  3. DefaultNode自动同步ClusterNode:StatisticSlot只操作DefaultNode,同步逻辑封装在DefaultNode里
  4. 最终都落到滑动窗口:所有指标数据最终存在MetricBucket的LongAdder数组里
  5. 回调机制提供扩展点:可以注册回调监听通过/拒绝事件

思考一下:

  • 为什么线程数的统计不放在滑动窗口里?(因为线程数是”当前值”,不是”窗口累计值”,用LongAdder直接存当前值就行)
  • 为什么exit方法不记录BlockException的情况?(因为entry阶段已经记录过了,而且被拒绝的请求根本不会执行到业务代码,也就不会走到exit的成功分支)
  • OCCUPIED_PASS(预通过)是什么场景下用的?(匀速排队模式下,当前窗口的请求可以”预占”下一个窗口的配额,下一篇讲FlowSlot的时候会详细说)

搞懂了指标统计,我们就有了”数据基础”。下一篇,我们正式进入功能Slot的分析——先从最核心的FlowSlot(限流降级)开始。

© 版权声明
THE END
喜欢就支持一下吧
点赞14
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!