Sentinel源码分析(第一篇):理解Context、Entry、Node

3,903 阅读13分钟

1. 前言

这篇文章主要分析Sentinel中的Context、Entry、Node的作用和关系。理解Context、Entry、Node的作用和关系,对掌握Sentinel如何实现限流非常的重要。个人认为,将Sentinel的这三个概念和Sentinel统计数据的滑动窗口实现原理理解清楚,Sentinel限流的实现原理也就明白了。

2. Context

2.1. Context的定义

在Sentinel的源码中,是这样描述Context的:

This class holds metadata of current invocation 。。。

更多对Context的描述可以看一下源代码。源码中描述Context持有当前调用链的信息,这些信息主要包括:

  • entranceNode:当前调用链的入口节点
  • curEntry:当前调用链此刻的是哪一个Entry对象在执行
  • name:context的名称
  • origin:调用源名称

上面这几个信息直接在Context中定义,可以很好理解。除了这些信息,Context还持有获取和修改当前节点,获取源节点、最新一个节点的能力,具体可以看如下代码:

    /**
     * 获取当前节点
     * @return
     */
    public Node getCurNode() {
        return curEntry.getCurNode();
    }
    
    /**
     * 设置当前节点
     * @param node
     * @return
     */
    public Context setCurNode(Node node) {
        this.curEntry.setCurNode(node);
        return this;
    }
    
     /**
     * 获取最新节点
     * Get the parent {@link Node} of the current.
     *
     * @return the parent node of the current.
     */
    public Node getLastNode() {
        if (curEntry != null && curEntry.getLastNode() != null) {
            return curEntry.getLastNode();
        } else {
            return entranceNode;
        }
    }

    /**
     * 获取当前节点
     * @return
     */
    public Node getOriginNode() {
        return curEntry == null ? null : curEntry.getOriginNode();
    }

可以看到Context对当前节点的持有能力其实是通过curEntry来实现的。

2.2. Context的创建

Sentinel创建Context的时机有:

  • 显式使用ContextUtil.entry("contextName")
  • 使用SphU.entry("resource")创建Entry的时候如果没有context则创建一个
2.2.1. ContextUtil创建Context

使用ContextUtil.entry()创建Context的时候,最终是由trueEntry()方法创建Context的。源码如下:

#源码位置:ContextUtil.java

/**
 * 线程中保存上下文的ThreadLocal
 */
private static ThreadLocal<Context> contextHolder = new ThreadLocal<>();

/**
 * 持有所有的EntranceNode,每一个EntranceNode和一个名称不同的上下文关联
 */
private static volatile Map<String, DefaultNode> contextNameNodeMap = new HashMap<>();
    
/**
 * 创建一个context
 * @param name 资源名称
 * @param origin 请求来源
 * @return
 */
protected static Context trueEnter(String name, String origin) {
    //先从本地线程中获取
    Context context = contextHolder.get();
    //本地线程中没有
    if (context == null) {
        //查看缓存中是否存在EntranceNode类型的调用链入口节点
        Map<String, DefaultNode> localCacheNameMap = contextNameNodeMap;
        DefaultNode node = localCacheNameMap.get(name);
        //如果没有
        if (node == null) {
            --- 省略部分代码 ---
           node = new EntranceNode(new StringResourceWrapper(name, EntryType.IN), null);
           // Add entrance node.
            Constants.ROOT.addChild(node);
            Map<String, DefaultNode> newMap = new HashMap<>(contextNameNodeMap.size() + 1);
            newMap.putAll(contextNameNodeMap);
            newMap.put(name, node);
            contextNameNodeMap = newMap;
          --- 省略部分代码 ---
         }
        //将EntranceNode和origin赋值给context,创建上下文
        context = new Context(node, name);
        context.setOrigin(origin);
        contextHolder.set(context);
    }
    return context;
}

通过源码可以看到,Sentinel会使用Threadlocal保存Context,所以同一个线程中只会有一个context对象,在创建Context的时候会创建一个EntranceNode,表示调用的入口节点。

2.2.2. Sph.entry()创建Context

在创建Entry的时候,如果不存在Context,会调用ContextUtil创建Context,所以实际还是调用ContextUtil.trueEntry()。

#源码位置:CtSph#entryWithPriority()

//获取当前线程中的上下文
Context context = ContextUtil.getContext();
//如果是NullContext,则返回一个没有chain的entry对象,意味着不需要进行slot的检查
if (context instanceof NullContext) {
    // The {@link NullContext} indicates that the amount of context has exceeded the threshold,
    // so here init the entry only. No rule checking will be done.
    return new CtEntry(resourceWrapper, null, context);
}
//如果不存在context,创建一个默认的context
if (context == null) {
    // Using default context.
    context = MyContextUtil.myEnter(Constants.CONTEXT_DEFAULT_NAME, "", resourceWrapper.getType());
}

2.3. Context的销毁

Context的销毁很简单,当调用ContextUtil.exit()方法时将ThreadLocal中的值设置为null即把Context销毁。

#源码位置:ContextUtil.java

public static void exit() {
    Context context = contextHolder.get();
    if (context != null && context.getCurEntry() == null) {
        contextHolder.set(null);
    }
}

2.4 Context小结

通过源码,可以看到Context的创建和销毁很简单。需要特别注意的是:

  • Context是使用ThreadLocal来保存的,但并不是代表在同一个线程中不会改变,比如在线程中对Context进行创建-退出-创建-退出,则会一个线程有多个Context,但是同一时间只有一个,当然,一般是不会这么操作的。所以,Context不是代表线程的上下文,而是代表一次调用链的进入和退出这期间的上下文。
  • Context创建的时候会创建一个调用链入口节点对象EntranceNode。
  • Context中持有当前Entry、当前节点、源节点等信息,不过这些信息是在后面创建Entry或者Node的时候赋值的。

3. Entry

3.1. Entry的定义

Sentinel中Entry可以理解为每次进入资源的一个凭证,如果调用SphO.entry()或者SphU.entry()能获取Entry对象,代表获取了凭证,没有被限流,否则抛出一个BlockException。

Entry中持有本次对资源调用的相关信息:

  • createTime:创建该Entry的时间戳。
  • curNode:Entry当前是在哪个节点。
  • orginNode:Entry的调用源节点。
  • resourceWrapper:Entry关联的资源信息。

3.2. CtEntry

上面说的Entry是一个抽象的类,在Sentinel中真正创建的Entry是CtEntry。CtEntry继承了Entry相关的信息,还持有如下信息:

  • parent:当前Entry的父Entry
  • child:当前Entry的子Entry
  • context:当前的Context
  • chain:当前的调用链

可以看到,CtEntry中对加强了Entry的能力,持有Context和调用链的信息。在同一个上下文中多次进行资源的调用会生成一个链表,这个链表就是通过parent和child实现的。

3.3. Entry的创建

当调用SphU.entry()或者SphO.entry()时,如果能够获取到资源,则返回一个Entry对象,否则抛出异常。调用entry()方法创建Entry的时候,最后会进入entryWithPriority()方法创建对象。

#源码位置:CtSph#entryWithPriority()

//获取插槽处理链
ProcessorSlot<Object> chain = lookProcessChain(resourceWrapper);
/*
 * Means amount of resources (slot chain) exceeds {@link Constants.MAX_SLOT_CHAIN_SIZE},
 * so no rule checking will be done.
 */
//如果不存在插槽处理链,则创建一个没有chain的entry对象,意味着不需要进行slot的检查
if (chain == null) {
    return new CtEntry(resourceWrapper, null, context);
}
//创建一个entry
Entry e = new CtEntry(resourceWrapper, chain, context);

正常的流程会创建一个带有chain的Entry,chain代表的是插槽处理链,具体的创建后续再说。现在重点看一下CtEntry的创建。

#源码位子:CtEntry.java

CtEntry(ResourceWrapper resourceWrapper, ProcessorSlot<Object> chain, Context context) {
    super(resourceWrapper);
    this.chain = chain;
    this.context = context;

    setUpEntryFor(context);
}

private void setUpEntryFor(Context context) {
    // The entry should not be associated to NullContext.
    if (context instanceof NullContext) {
        return;
    }
    #产生entry链
    this.parent = context.getCurEntry();
    if (parent != null) {
        ((CtEntry)parent).child = this;
    }
    #更新context的当curEntry
    context.setCurEntry(this);
}

可以看到,在创建CtEntry的时候,会做三件事,一个就是为当前的CtEntry指定相关的属性,比如chain、context,二是如果在同一个Context中多次调用进入资源,会生产entry链,通过parent和child维护。三是将当期进入的entry记录在Context中。

3.4. Entry的销毁

当需要退出当前的entry调用时,使用entry.exit()即可以将当期的entry销毁,源码如下:

#源码位置:CtEntry.java

/**
 * 退出
 * @param context
 * @param count
 * @param args
 * @throws ErrorEntryFreeException
 */
protected void exitForContext(Context context, int count, Object... args) throws ErrorEntryFreeException {
    if (context != null) {
        // Null context should exit without clean-up.
        if (context instanceof NullContext) {
            return;
        }
        if (context.getCurEntry() != this) {
            //如果退出的不是当前的entry对象的场景

            //获取当前entry对应的资源名
            String curEntryNameInContext = context.getCurEntry() == null ? null : context.getCurEntry().getResourceWrapper().getName();
            // Clean previous call stack.
            CtEntry e = (CtEntry)context.getCurEntry();
            //从Context的curEntry开始退出,并抛出异常,退出的时候应该从curEntry开始
            while (e != null) {
                e.exit(count, args);
                e = (CtEntry)e.parent;
            }
            String errorMessage = String.format("The order of entry exit can't be paired with the order of entry"
                + ", current entry in context: <%s>, but expected: <%s>", curEntryNameInContext, resourceWrapper.getName());
            throw new ErrorEntryFreeException(errorMessage);
        } else {
            //退出所有的slot
            if (chain != null) {
                chain.exit(context, resourceWrapper, count, args);
            }
            // Restore the call stack.
            //将Context的curEntry设置为parent,并将parent的child设置为null
            context.setCurEntry(parent);
            if (parent != null) {
                ((CtEntry)parent).child = null;
            }
            if (parent == null) {
                //如果是默认的上下文,退出
                // Default context (auto entered) will be exited automatically.
                if (ContextUtil.isDefaultContext(context)) {
                    ContextUtil.exit();
                }
            }
            // Clean the reference of context in current entry to avoid duplicate exit.
            //清除对Context的引用
            clearEntryContext();
        }
    }
}

可以看到,销毁entry的时候,如果调用exit()的顺序和进入的顺序不一致,会取context中的curEntry开始进行退出,然后抛出异常提示。

3.5. Entry小结

到此,Entry基本创建的流程完毕,需要注意的是:

  • Entry可以理解为对资源的获取和释放的一个凭证,记录获取到这个资源的相关信息。
  • 在用一个Context中多次进入获取资源会形成调用链,Entry通过parent和child属性实现。
  • 在创建Entry的时候会将当前的Entry对象赋值给Context的curEntry。
  • 在释放Entry的时候,一定要按照调用链从最后一个Entry开始释放。

4. Node

4.1. Node的定义

Sentinel中是这样描述Node的:

Holds real-time statistics for resources.

Node中保存了对资源的实时数据的统计,Sentinel中的限流或者降级等功能就是通过Node中的数据进行判断的。Node是一个接口,里面定义了各种操作request、exception、rt、qps、thread的方法。

4.2. 各种类型的Node

先看一下Node继承结构图

4.2.1. StatisticNode

StatisticNode实现了Node接口,从图中可以看出,其他的Node都继承了该接口。所以这个Node是一个最基础的Node,没有特性。StatisticNode中使用秒级和分钟级两个滑动窗口、一个代表线程数的原子变量记录资源的各种指标,除此没有其他特殊的地方。

#源码位置:StatisticNode.java

/**
 * 持有秒级别的统计数据指标,默认一秒,样本数2
 * Holds statistics of the recent {@code INTERVAL} seconds. The {@code INTERVAL} is divided into time spans
 * by given {@code sampleCount}.
 */
private transient volatile Metric rollingCounterInSecond = new ArrayMetric(SampleCountProperty.SAMPLE_COUNT,
    IntervalProperty.INTERVAL);

/**
 * 持有分钟级别的数据指标,单位一分钟,样本数量为60,即每秒一个样本
 * Holds statistics of the recent 60 seconds. The windowLengthInMs is deliberately set to 1000 milliseconds,
 * meaning each bucket per second, in this way we can get accurate statistics of each second.
 */
private transient Metric rollingCounterInMinute = new ArrayMetric(60, 60 * 1000, false);

/**
 * 线程总数
 * The counter for thread count.
 */
private LongAdder curThreadNum = new LongAdder();

StatisticNode实现了Node中相关的接口,并且使用三个数据结构保存相关的数据 ,为其他Node实现特殊的统计维度提供基础实现。

在Sentinel中单独使用StatisticNode来作为统计节点的地方是统计来源节点相关的指标的时候,来源节点创建则是在ClusterBuilderSlot中。具体内容后面在讲整个调用链创建的时候会说到,这里只是简单提一下。

4.2.2. DefaultNode

Sentinel源码中是这样描述DefaultNode的:

* <p>
 * A {@link Node} used to hold statistics for specific resource name in the specific context.
 * Each distinct resource in each distinct {@link Context} will corresponding to a {@link DefaultNode}.
 * </p>
 * <p>
 * This class may have a list of sub {@link DefaultNode}s. Child nodes will be created when
 * calling {@link SphU}#entry() or {@link SphO}@entry() multiple times in the same {@link Context}.
 * </p>

从源码描述中可以看出,DefaultNode持有指定的Context和指定的Resource的统计数据,意味着DefaultNode是以Context和Resource为维度的统计节点。这一点非常的重要,因为如果想对某个Context中某个Resource的进行限流等操作,就需要使用DefaultNode类型的节点来统计数据。源码中还描述到,多次在同一个上下文中获取资源,会产生一些子节点,这些子节点保存在DefaultNode的childList中。

 /**
 * 子节点列表
 * The list of all child nodes.
 */
private volatile Set<Node> childList = new HashSet<>();

这些子节点有什么用?接下来讲EntranceNode的时候会讲到这个问题。 除了增加一个childList属性,DefaultNode还持有一个ClusterNode节点,至于有什么作用,在马上讲到的ClusterNode中再说。

DefaultNode会在NodeSelectorSlot中创建,并且赋值给当前Context的curEntry的curNode。具体会在讲NodeSelectorSlot的时候再细说。

4.2.3. ClusterNode

Sentinel中是这样描述ClusterNode的:

* <p>
 * This class stores summary runtime statistics of the resource, including rt, thread count, qps
 * and so on. Same resource shares the same {@link ClusterNode} globally, no matter in which
 * {@link com.alibaba.csp.sentinel.context.Context}.
 * </p>
 * <p>
 * To distinguish invocation from different origin (declared in
 * {@link ContextUtil#enter(String name, String origin)}),
 * one {@link ClusterNode} holds an {@link #originCountMap}, this map holds {@link StatisticNode}
 * of different origin. Use {@link #getOrCreateOriginNode(String)} to get {@link Node} of the specific
 * origin.<br/>

从描述中可以知道,ClusterNode保存的是同一个Resource的相关的统计信息,是以Resource为维度的,不区分Context,这个是和DefaultNode的区别。

为了区分从不同来源的访问,在ClusterNode中使用StatisticNode保存不同来源的统计信息。这就是在讲StatisticNode的时候说到StatisticNode被直接创建使用的地方。

/**
 * <p>The origin map holds the pair: (origin, originNode) for one specific resource.</p>
 * <p>
 * The longer the application runs, the more stable this mapping will become.
 * So we didn''t use concurrent map here, but a lock, as this lock only happens
 * at the very beginning while concurrent map will hold the lock all the time.
 * </p>
 */
private Map<String, StatisticNode> originCountMap = new HashMap<String, StatisticNode>();

/**
 * <p>Get {@link Node} of the specific origin. Usually the origin is the Service Consumer's app name.</p>
 * <p>If the origin node for given origin is absent, then a new {@link StatisticNode}
 * for the origin will be created and returned.</p>
 *
 * @param origin The caller's name, which is designated in the {@code parameter} parameter
 *               {@link ContextUtil#enter(String name, String origin)}.
 * @return the {@link Node} of the specific origin
 */
public Node getOrCreateOriginNode(String origin) {
    StatisticNode statisticNode = originCountMap.get(origin);
    if (statisticNode == null) {
        try {
            lock.lock();
            statisticNode = originCountMap.get(origin);
            if (statisticNode == null) {
                // The node is absent, create a new node for the origin.
                statisticNode = new StatisticNode();
                HashMap<String, StatisticNode> newMap = new HashMap<>(originCountMap.size() + 1);
                newMap.putAll(originCountMap);
                newMap.put(origin, statisticNode);
                originCountMap = newMap;
            }
        } finally {
            lock.unlock();
        }
    }
    return statisticNode;
}

上面就是保存和创建源节点统计信息相关的逻辑。

在上一小节讲DefaultNode的时候,DefaultNode中会持有一个ClusterNode,可以看到DefaultNode中对各种指标进行修改的时候,都会去修改ClusterNode相应的指标,这是因为DefaultNode统计的时候区分了Context,但ClusterNode不区分,是针对整个Resource的,那么只要Resource有新的请求进入,都会被ClusterNode记录。

ClusterNode是在ClusterBuilderSlot处理的时候创建并赋值给DefaultNode节点的,具体会在讲ClusterBuilderSlot作用的时候再细说。

4.2.4. EntranceNode

Sentinel源码中是这样描述EntranceNode的:

/**
 * <p>
 * A {@link Node} represents the entrance of the invocation tree.
 * </p>
 * <p>
 * One {@link Context} will related to a {@link EntranceNode},
 * which represents the entrance of the invocation tree. New {@link EntranceNode} will be created if
 * current context does't have one. Note that same context name will share same {@link EntranceNode}
 * globally.
 * </p>

EntranceNode代表调用链的入口节点,持有某个Context中调用的信息,同一个Context共享一个EntranceNode。EntranceNode的统计维度为Context。需要注意的是EntranceNode继承了DefaultNode。

在前面讲Context创建的时候讲到会去创建一个EntranceNode,这正是创建EntranceNode的时机。

在讲DefaultNode的时候,我们讲到DefaultNode中有一个子节点列表childList,在同一上下文中多次获取资源的时候,会将所有的节点保存到里面。所以childList会将Context中不管是不是同一个Resource维度的Node都保存到里面,也就是childList中的是同一个Context中的Node,这样就可以统计Context维度的数据了。看EntranceNode中统计各种指标都是根据childList来统计的。

4.3. Node小结

Node代表Sentinel中各种指标的实时统计信息,根据不同的维度有不同的指标,并且创建的时机也不一样,具体如下表:

节点类型 统计维度 创建时机
StatisticNode 基本的统计节点,没有维度之分 没有
DefaultNode Context*Resource NodeSelectorSlot
ClusterNode Resource ClusterBuilderSlot
EntranceNode Context ContextUtil中创建Context的时候
来源节点(StatisticNode类型) Resource*Origin ClusterBuilderSlot

5. 总结

本篇主要是分析Context、Entry、Node相关的概念和作用,创建的时机,他们之间的关系。Sentinel是怎么记录多个维度的数据统计的。将这三个放在一起分析,主要是它们之间存在各种关联,并且都可以理解为存储数据的地方。

在本篇中讲到的chain链,还有NodeSelectorSlot、ClusterBuilderSlot,将会在下一篇中进行分析。在下一篇中,主要讲Sentinel整个Slot插槽的处理流程,以及NodeSelectorSlot、ClusterBuilderSlot的作用,并将Context、Entry、Node在多个Context和多个的Resource等组合下怎么生成调用链用画图的方式展示出来,进一步理解三者的关联关系,以及为什么不同的节点能够统计不同维度的数据。

6. 参考资料

Sentinel核心类