![图片[1]-资源指标统计实现全解析(上篇):NodeSelectorSlot与ClusterBuilderSlot - 速优课-速优课](http://www.suyouke.com/wp-content/uploads/2026/08/doubao_img_2304x1728_20260803_094414-1024x768.png)
资源指标统计实现全解析(上篇):NodeSelectorSlot与ClusterBuilderSlot
前言
前面我们了解了Sentinel的核心概念、责任链模式和整体工作流程。从这篇开始,我们将深入每个ProcessorSlot的实现细节,看看它们具体是怎么工作的。
按照责任链的顺序,第一个Slot是NodeSelectorSlot,第二个是ClusterBuilderSlot。这两个Slot虽然不直接做限流熔断,但它们是整个统计体系的基础——负责构建各种Node,为后续的统计和限流做准备。
今天这篇,我们就来深入分析这两个Slot的实现原理。
一、NodeSelectorSlot:节点选择器
1.1 作用概述
NodeSelectorSlot是责任链的第一个Slot,它的核心职责有两个:
- 为资源创建DefaultNode:资源首次被访问时,创建对应的DefaultNode
- 维护调用树:把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 - 速优课-速优课](https://www.suyouke.com/wp-content/uploads/2026/08/image-15.png)
执行后:
- 新创建的DefaultNode(GET:/hello)被加到entranceNode的childList里
![图片[3]-资源指标统计实现全解析(上篇):NodeSelectorSlot与ClusterBuilderSlot - 速优课-速优课](https://www.suyouke.com/wp-content/uploads/2026/08/image-16.png)
此时的调用树结构:
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 - 速优课-速优课](https://www.suyouke.com/wp-content/uploads/2026/08/image-17-1024x377.png)
这是不是很像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,它的职责是:
- 创建全局唯一的ClusterNode:资源第一次被访问时创建
- 关联DefaultNode和ClusterNode:把ClusterNode赋值给DefaultNode.clusterNode
- 创建按来源的统计节点:如果有调用来源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 - 速优课-速优课](https://www.suyouke.com/wp-content/uploads/2026/08/image-18-1024x542.png)
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,创建来源Node | ClusterNode(全局唯一)+ originNode |
几个关键理解点:
- 为什么一个资源有多个DefaultNode? 因为不同的调用入口(Context.name)需要分开统计,实现按入口限流
- 为什么需要ClusterNode? 为了全局统计,不用遍历多个DefaultNode,性能更好
- CopyOnWrite + 双重检查锁:Sentinel里用烂了的套路,读多写少场景下的经典优化
- Slot的顺序很重要:前面的Slot为后面的Slot准备数据,顺序不能乱
思考一下:
- 为什么NodeSelectorSlot的map是非静态的,而ClusterBuilderSlot的clusterNodeMap是静态的?(因为NodeSelectorSlot按Context分,每个资源每个Context一个DefaultNode;而ClusterNode是全局唯一的,所有资源共享一个map)
- originCountMap为什么用StatisticNode而不是DefaultNode?(按来源统计不需要构建调用树,只要基础统计能力就行,StatisticNode足够了)
下一篇,我们将继续分析StatisticSlot——这是Sentinel最核心的统计Slot,所有指标数据的更新都在这里完成。










请登录后查看评论内容