资源指标统计实现全解析(上篇):NodeSelectorSlot与ClusterBuilderSlot

图片[1]-资源指标统计实现全解析(上篇):NodeSelectorSlot与ClusterBuilderSlot - 速优课-速优课

资源指标统计实现全解析(上篇):NodeSelectorSlot与ClusterBuilderSlot

前言

前面我们了解了Sentinel的核心概念、责任链模式和整体工作流程。从这篇开始,我们将深入每个ProcessorSlot的实现细节,看看它们具体是怎么工作的。

按照责任链的顺序,第一个Slot是NodeSelectorSlot,第二个是ClusterBuilderSlot。这两个Slot虽然不直接做限流熔断,但它们是整个统计体系的基础——负责构建各种Node,为后续的统计和限流做准备。

今天这篇,我们就来深入分析这两个Slot的实现原理。

一、NodeSelectorSlot:节点选择器

1.1 作用概述

NodeSelectorSlot是责任链的第一个Slot,它的核心职责有两个:

  1. 为资源创建DefaultNode:资源首次被访问时,创建对应的DefaultNode
  2. 维护调用树:把DefaultNode挂到调用树上,设置Context.curNode

为什么它是第一个?因为后面所有的Slot都依赖DefaultNode来做统计和判断,所以必须先把Node构建好。

1.2 源码分析

先看NodeSelectorSlot的完整源码:

public class NodeSelectorSlot extends AbstractLinkedProcessorSlot<Object> {
    // Context的name -> 资源的DefaultNode
    private volatile Map<String, DefaultNode> map = new HashMap<>(10);
​
    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, Object obj, 
                      int count, boolean prioritized, Object... args) throws Throwable {
        // 用Context名称作为key,从缓存中取DefaultNode
        DefaultNode node = map.get(context.getName());
        if (node == null) {
            synchronized (this) {
                node = map.get(context.getName());
                if (node == null) {
                    // 创建DefaultNode
                    node = new DefaultNode(resourceWrapper, null);
                    // 用CopyOnWrite的方式更新map
                    HashMap<String, DefaultNode> cacheMap = new HashMap<>(map.size());
                    cacheMap.putAll(map);
                    cacheMap.put(context.getName(), node);
                    map = cacheMap;
                    // 绑定到调用树
                    ((DefaultNode) context.getLastNode()).addChild(node);
                }
            }
        }
        // 设置当前节点
        context.setCurNode(node);
        // 传给下一个Slot
        fireEntry(context, resourceWrapper, node, count, prioritized, args);
    }
​
    @Override
    public void exit(Context context, ResourceWrapper resourceWrapper, 
                     int count, Object... args) {
        fireExit(context, resourceWrapper, count, args);
    }
}

代码不长,但信息量很大。我们来一步步拆解。

map字段的含义

private volatile Map<String, DefaultNode> map = new HashMap<>(10);

注意几个关键点:

  • 非静态字段:每个NodeSelectorSlot实例有自己的map
  • key是Context.name:不是资源ID,而是调用上下文的名称
  • value是DefaultNode:该上下文下这个资源的统计节点

为什么说”每个NodeSelectorSlot实例”?因为一个资源对应一个ProcessorSlotChain,一个ProcessorSlotChain对应一个NodeSelectorSlot。所以这个map缓存的是:同一个资源,在不同调用上下文中的DefaultNode

换句话说:同一个资源ID,可能有多个DefaultNode——每个Context.name对应一个。这就是为什么之前说”一个资源可能有多个DefaultNode”。

为什么这么设计?

举个例子就明白了:

同一个支付接口,既通过Spring MVC暴露给前端,又通过Dubbo暴露给内部服务。

  • Web请求进来,Context名称是sentinel_spring_web_context
  • Dubbo请求进来,Context名称是别的

这样设计的好处是:可以针对不同入口的流量做不同的限流策略。比如只限制前端的QPS,不限制内部服务的调用。

创建DefaultNode的过程

用了经典的双重检查锁(DCL)模式:

DefaultNode node = map.get(context.getName());
if (node == null) {
    synchronized (this) {
        node = map.get(context.getName());
        if (node == null) {
            // 创建并更新map
        }
    }
}

更新map的时候用了CopyOnWrite的方式:

  • 新建一个HashMap
  • 把旧数据复制过去
  • 把新数据加进去
  • 替换引用

为什么这么做?因为map是volatile的,写的时候复制一份再替换,读的时候就不需要加锁了,读多写少的场景下性能更好

这种CopyOnWrite的写法在Sentinel源码里随处可见,后面还会遇到很多次。

绑定调用树

((DefaultNode) context.getLastNode()).addChild(node);

这行代码是NodeSelectorSlot里最难理解的一行。它的作用是把新创建的DefaultNode加到调用树上。

context.getLastNode()是什么?就是当前调用链路上的上一个节点。新节点作为它的子节点加进去。

具体分两种情况,我们结合例子来看。


1.3 场景一:单次SphU.entry(普通接口调用)

最简单的场景:一个请求进来,只调用一次SphU.entry。

比如一个Spring MVC接口:

@RestController
public class WebMvcTestController {
    @GetMapping("/hello")
    public String apiHello() throws BlockException {
        doBusiness();
        return "Hello!";
    }
}

当访问/hello接口时:

执行前:

  • Context.entranceNode的childList为空
  • Context.curEntry的parent和child都是null
图片[2]-资源指标统计实现全解析(上篇):NodeSelectorSlot与ClusterBuilderSlot - 速优课-速优课

执行后:

  • 新创建的DefaultNode(GET:/hello)被加到entranceNode的childList里
图片[3]-资源指标统计实现全解析(上篇):NodeSelectorSlot与ClusterBuilderSlot - 速优课-速优课

此时的调用树结构:

ROOT (machine-root)
            |
    EntranceNode (sentinel_spring_web_context)
            |
    DefaultNode (GET:/hello)

如果再访问另一个接口/err,调用树就变成:

ROOT (machine-root)
            |
    EntranceNode (sentinel_spring_web_context)
           / \
          /   \
GET:/hello   GET:/err

EntranceNode下面挂着Web项目所有接口的DefaultNode。


1.4 场景二:多次SphU.entry(嵌套调用)

复杂一点的场景:一次请求中多次调用SphU.entry。

比如接口里还要调用其他服务:

@GetMapping("/hello")
public String apiHello() throws BlockException {
    Entry entry = null;
    try {
        entry = SphU.entry("POST:http://wujiuye.com/hello2", EntryType.OUT);
        doBusiness();
        return "Hello!";
    } finally {
        if (entry != null) {
            entry.exit(1);
        }
    }
}

这里有个细节:虽然代码里写了ContextUtil.enter("my_context"),但实际上不会创建新的Context。因为当前线程已经有一个Context了(sentinel_spring_web_context),ContextUtil.enter会直接复用。

记住:Context是ThreadLocal的,一个线程只有一个Context,以第一次enter的为准。

所以,GET:/hello是调用链的入口资源,POST:/hello2是它的子资源。

调用树结构变成:

ROOT (machine-root)
            |
    EntranceNode (sentinel_spring_web_context)
            |
    DefaultNode (GET:/hello)
            |
    DefaultNode (POST:/hello2)

此时有两个CtEntry,构成双向链表:

图片[4]-资源指标统计实现全解析(上篇):NodeSelectorSlot与ClusterBuilderSlot - 速优课-速优课

这是不是很像JVM的栈帧?调用方法时栈帧入栈,方法执行完栈帧出栈。CtEntry也是一样——调用SphU.entry时创建CtEntry并设为curEntry,调用exit时还原为父CtEntry。


1.5 设置Context.curNode

context.setCurNode(node);

这行代码把当前资源的DefaultNode设为Context的当前节点。实际上是设置到了CtEntry的curNode字段上。

为什么要设置curNode?因为后续的统计、限流、熔断都需要拿到当前资源的Node。比如:

  • Tracer.trace()记录异常时,需要从Context拿到当前资源的DefaultNode
  • StatisticSlot统计数据时,也需要操作当前Node

从这里也能看出NodeSelectorSlot必须排在前面的原因——后面的Slot都要用到Node。

二、ClusterBuilderSlot:集群节点构建器

2.1 为什么需要ClusterNode

在讲ClusterBuilderSlot之前,先思考一个问题:

NodeSelectorSlot为每个资源在每个Context下都创建了一个DefaultNode。那如果我想知道这个资源总的QPS是多少,怎么办?

答案是:遍历所有Context下的DefaultNode,加起来。

但这样性能太差了。所以Sentinel设计了ClusterNode——每个资源全局唯一的统计节点,用来统计该资源的总QPS、总异常数、总线程数等全局指标。

2.2 作用概述

ClusterBuilderSlot是责任链的第二个Slot,它的职责是:

  1. 创建全局唯一的ClusterNode:资源第一次被访问时创建
  2. 关联DefaultNode和ClusterNode:把ClusterNode赋值给DefaultNode.clusterNode
  3. 创建按来源的统计节点:如果有调用来源origin,为每个origin创建一个StatisticNode

为什么必须排在NodeSelectorSlot后面?很简单——必须先有DefaultNode,才能把ClusterNode塞给它。

2.3 源码分析

先看ClusterBuilderSlot的核心代码:

public class ClusterBuilderSlot extends AbstractLinkedProcessorSlot<DefaultNode> {
    // 全局缓存:资源 -> ClusterNode
    private static volatile Map<ResourceWrapper, ClusterNode> clusterNodeMap = new HashMap<>();
    private static final Object lock = new Object();

    // 当前资源的ClusterNode(非静态,每个资源一个)
    private volatile ClusterNode clusterNode = null;

    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, 
                      int count, boolean prioritized, Object... args) throws Throwable {
        // 1. 创建ClusterNode(双重检查锁)
        if (clusterNode == null) {
            synchronized (lock) {
                if (clusterNode == null) {
                    clusterNode = new ClusterNode(resourceWrapper.getName(), 
                                                  resourceWrapper.getResourceType());
                    // CopyOnWrite更新全局map
                    HashMap<ResourceWrapper, ClusterNode> newMap = 
                        new HashMap<>(Math.max(clusterNodeMap.size(), 16));
                    newMap.putAll(clusterNodeMap);
                    newMap.put(node.getId(), clusterNode);
                    clusterNodeMap = newMap;
                }
            }
        }
        // 2. 关联DefaultNode和ClusterNode
        node.setClusterNode(clusterNode);

        // 3. 如果有调用来源,创建来源的StatisticNode
        if (!"".equals(context.getOrigin())) {
            Node originNode = node.getClusterNode().getOrCreateOriginNode(context.getOrigin());
            context.getCurEntry().setOriginNode(originNode);
        }

        fireEntry(context, resourceWrapper, node, count, prioritized, args);
    }

    @Override
    public void exit(Context context, ResourceWrapper resourceWrapper, 
                     int count, Object... args) {
        fireExit(context, resourceWrapper, count, args);
    }
}

两个缓存字段

private static volatile Map<ResourceWrapper, ClusterNode> clusterNodeMap = new HashMap<>();
private volatile ClusterNode clusterNode = null;

这里有两个缓存,有点意思:

  • clusterNodeMap(静态):全局缓存,所有资源共享,存的是资源ID到ClusterNode的映射
  • clusterNode(非静态):每个ClusterBuilderSlot实例一个,直接存当前资源的ClusterNode

为什么要两个?因为一个资源对应一个ProcessorSlotChain,也就对应一个ClusterBuilderSlot实例。用非静态字段存一份,每次用的时候直接取,不用再从全局map里查,省了一次map查找——这又是一个性能优化的小细节。

创建ClusterNode

和NodeSelectorSlot一样,也是双重检查锁 + CopyOnWrite更新map的套路。

创建完之后,把ClusterNode设置到DefaultNode上:

node.setClusterNode(clusterNode);

这样DefaultNode就持有了ClusterNode的引用。后续统计数据时,DefaultNode会同时更新自己和ClusterNode的数据。

DefaultNode和ClusterNode的关系:

图片[5]-资源指标统计实现全解析(上篇):NodeSelectorSlot与ClusterBuilderSlot - 速优课-速优课

2.4 按调用来源统计

ClusterNode里还有一个originCountMap:

public class ClusterNode extends StatisticNode {
    private final String name;
    private final int resourceType;
    // 来源 -> StatisticNode
    private Map<String, StatisticNode> originCountMap = new HashMap<>();
}

这个map是干嘛的?按调用来源统计指标数据。

什么是调用来源?就是谁调用了这个接口。比如:

  • HTTP请求可以在header里加S-user参数标识来源
  • Dubbo调用可以通过application参数标识来源

如果上游服务传递了origin参数,ClusterBuilderSlot就会为这个origin创建一个StatisticNode,存在originCountMap里。

创建逻辑在getOrCreateOriginNode方法里:

public Node getOrCreateOriginNode(String origin) {
    StatisticNode statisticNode = originCountMap.get(origin);
    if (statisticNode == null) {
        try {
            lock.lock();
            statisticNode = originCountMap.get(origin);
            if (statisticNode == null) {
                statisticNode = new StatisticNode();
                // 又是CopyOnWrite
                HashMap<String, StatisticNode> newMap = new HashMap<>(originCountMap.size() + 1);
                newMap.putAll(originCountMap);
                newMap.put(origin, statisticNode);
                originCountMap = newMap;
            }
        } finally {
            lock.unlock();
        }
    }
    return statisticNode;
}

还是熟悉的味道:双重检查锁 + CopyOnWrite。

创建完之后,ClusterBuilderSlot会把这个originNode设置到CtEntry上:

context.getCurEntry().setOriginNode(originNode);

方便后续的Slot使用——比如按来源限流的AuthoritySlot,就需要用到这个originNode。

小结论:如果你的自定义Slot需要用到originNode,那必须把你的Slot放在ClusterBuilderSlot后面。

2.5 ClusterNode的用途

ClusterNode是全局统计节点,很多地方都会用到它:

  • 限流降级:FlowSlot用ClusterNode的统计数据来判断是否触发限流
  • 熔断降级:DegradeSlot用ClusterNode的统计数据来判断是否触发熔断
  • 按来源限流:用originCountMap里的StatisticNode做来源维度的限流

可以说,ClusterNode是Sentinel功能实现的数据基础

总结与思考

这篇文章我们分析了NodeSelectorSlot和ClusterBuilderSlot两个Slot的实现。

回顾一下两个Slot的分工:

Slot职责产出物
NodeSelectorSlot创建DefaultNode,构建调用树DefaultNode(每个Context一个)
ClusterBuilderSlot创建ClusterNode,创建来源NodeClusterNode(全局唯一)+ originNode

几个关键理解点:

  1. 为什么一个资源有多个DefaultNode? 因为不同的调用入口(Context.name)需要分开统计,实现按入口限流
  2. 为什么需要ClusterNode? 为了全局统计,不用遍历多个DefaultNode,性能更好
  3. CopyOnWrite + 双重检查锁:Sentinel里用烂了的套路,读多写少场景下的经典优化
  4. Slot的顺序很重要:前面的Slot为后面的Slot准备数据,顺序不能乱

思考一下:

  • 为什么NodeSelectorSlot的map是非静态的,而ClusterBuilderSlot的clusterNodeMap是静态的?(因为NodeSelectorSlot按Context分,每个资源每个Context一个DefaultNode;而ClusterNode是全局唯一的,所有资源共享一个map)
  • originCountMap为什么用StatisticNode而不是DefaultNode?(按来源统计不需要构建调用树,只要基础统计能力就行,StatisticNode足够了)

下一篇,我们将继续分析StatisticSlot——这是Sentinel最核心的统计Slot,所有指标数据的更新都在这里完成。

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

请登录后发表评论

    请登录后查看评论内容

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