sentinel介绍及原理

sentinel作用

Sentinel 是面向分布式服务架构的流量控制组件,主要以流量为切入点,从限流、流量整形、熔断降级、系统负载保护、热点防护等多个维度来帮助开发者保障微服务的稳定性。

sentinel整体设计的很精巧,只需要一个sentinel-core便可以运行,它提供了诸如服务降级、黑白名单校验、QPS、线程数、系统负载、CPU负载、流控等功能,可谓是功能非常的强大。

sentinel使用

SphU.entry

手动执行限流逻辑

sentinel使用SphU或者SphO标示一个被保护的资源,比如:

Entry entry = SphU.entry("HelloWorld", EntryType.IN);

上述代码标示了一个名为HelloWorld的被保护资源,并且检查入口流量(SystemSlot只对入口流量生效)。在这行代码之后,便可以访问被保护的资源了。

SentinelResource

在需要限流的方法上添加@SentinelResource注解,通过该注解指定rule resource,sentinel会根据resource加载rule,并执行限流逻辑;该方法需要添加切面SentinelResourceAspect,实际SentinelResourceAspect使用的同样是 SphU.entry

基本原理

sentinel在内部创建了一个责任链,责任链是由一系列ProcessorSlot对象组成的,每个ProcessorSlot对象负责不同的功能,外部请求是否允许访问资源,需要通过责任链的校验,只有校验通过的,才可以访问资源,如果被校验失败,会抛出BlockException异常。

sentinel提供了8个ProcessorSlot的实现类,下面实现类功能介绍:

  • DegradeSlot:用于服务降级,如果发现服务超时次数或者报错次数超过限制,DegradeSlot将禁止再次访问服务,等待一段时间后,DegradeSlot试探性的放过一个请求,然后根据该请求的处理情况,决定是否再次降级。
  • AuthoritySlot:黑白名单校验,按照字符串匹配,如果在黑名单,则禁止访问。
  • ClusterBuilderSlot:构建ClusterNode对象,该对象用于统计访问资源的QPS、线程数、异常、响应时间等,每个资源对应一个ClusterNode对象。
  • SystemSlot:校验QPS、并发线程数、系统负载、CPU使用率、平均响应时间是否超过限制,使用滑动窗口算法统计上述这些数据。
  • StatisticSlot:用于从多个维度(入口流量、调用者、当前被访问资源)统计响应时间、并发线程数、处理失败个数、处理成功个数等。
  • FlowSlot:用于流控,可以根据QPS或者每秒并发线程数控制,当QPS或者并发线程数超过设定值,便会抛出FlowException异常。FlowSlot依赖于StatisticSlot的统计数据。
  • NodeSelectorSlot:负责收集资源路径,并将这些资源的调用路径,以树状结构存储起来,用于根据调用路径来限流降级、数据统计。
  • LogSlot:打印日志。

比如本文开头的例子,当请求要访问HelloWorld资源时,该请求需要顺次经过上述这些slot的检查,同时当访问结束时StatisticSlot里面也记录下HelloWorld资源被访问的统计数据,当后面的请求再次访问该资源时,FlowSlot、DegradeSlot可以使用这些统计数据做检查。
sentinel使用SPI加载这些slot,并根据注解@Spi的属性order对它们排序,value越小优先级越高。在sentinel中,这些slot的顺序是:

我们也可以添加自定义的slot,只需要实现ProcessorSlot接口,在com.alibaba.csp.sentinel.slotchain.ProcessorSlot文件中添加自定义类的全限定名,然后使用注解@Spi order指定顺序即可。

SphU.entry()

StringResourceWrapper

entry()方法内部首先创建一个StringResourceWrapper对象,该对象表示被保护的资源,资源使用字符串命名,StringResourceWrapper对象有三个参数:

//资源名,也就是entry()方法的第一个入参
protected final String name;
//表示是入口流量(IN)还是出口流量(OUT),
//两个参数的区别在于是否被SystemSlot检查,IN会被检查,OUT不会,默认是OUT
protected final EntryType entryType;
//表示资源类型,sentinel提供了common、web、sql、api等类型,资源类型用于统计使用
protected final int resourceType;

任何一个被保护的资源都被封装成StringResourceWrapper对象,sentinel也是使用该对象识别被保护资源。

Entry

有了表示资源的对象后,接下来创建Entry对象,这个对象也是SphU.entry()方法的返回值,Entry对象持有资源对象,ProcessorSlot链,sentinel上下文对象Context,通过Entry对象应用程序可以窥探sentinel内部情况。

SphU.entry()通过一系列的调用最终调用到CtSph的entryWithPriority()方法上:

//resourceWrapper:是StringResourceWrapper对象,表示资源
//count:表示令牌数,默认是1,一般一个请求对应一个令牌,也可以指定一个请求对应多个令牌,如果令牌不够,则禁止访问
//prioritized:在FlowSlot里面使用,没找到具体的使用含义,有看懂的小伙伴可以告知一下
private Entry entryWithPriority(ResourceWrapper resourceWrapper, int count, boolean prioritized, Object... args)
    throws BlockException {
    //构建上下文对象,上下文对象存储在ThreadLocal中
    Context context = ContextUtil.getContext();
    if (context instanceof NullContext) {
        return new CtEntry(resourceWrapper, null, context);
    }
    //一般的线程第一次访问资源,context都是null,我们也可以在应用程序中使用ContextUtil自己创建Context对象
    if (context == null) {
        //下面创建了一个名字为sentinel_default_context的Context对象
        context = InternalContextUtil.internalEnter(Constants.CONTEXT_DEFAULT_NAME);
    }
    //全局开关,可以使用它来关闭sentinel
    if (!Constants.ON) {
        return new CtEntry(resourceWrapper, null, context);
    }
    //使用SPI构建slot链,每个slot对象都有一个next属性,可以使用该属性指定下一个slot对象
    ProcessorSlot<Object> chain = lookProcessChain(resourceWrapper);
    if (chain == null) {
        return new CtEntry(resourceWrapper, null, context);
    }
    //创建Entry对象
    Entry e = new CtEntry(resourceWrapper, chain, context);
    try {
        //对该请求,遍历每个slot对象
        chain.entry(context, resourceWrapper, null, count, prioritized, args);
    } catch (BlockException e1) {
        e.exit(count, args);
        throw e1;
    } catch (Throwable e1) {
        RecordLog.info("Sentinel unexpected exception", e1);
    }
    return e;
}

entryWithPriority()方法首先创建一个Context对象,这个对象将会贯穿整个请求的过程,一些共享数据可以放在这里面,既可以使用上面的代码创建名字为sentinel_default_context的Context对象,也可以在应用程序中创建Context对象,如果在应用程序中创建的话,上面代码就不会再次创建了:

//第一个参数表示Context名字,
//第二个参数表示请求方或者调用方的名字,当需要根据调用方进行控制的时候,第二个参数就会起作用
ContextUtil.enter("HelloWorld", "app");
Entry entry = SphU.entry("HelloWorld", EntryType.IN);

创建完Context对象后,使用SPI构建slot链,之后是创建Entry对象,之后就是遍历slot链以决定是否允许该请求访问资源。

entry.exit()

访问完资源后,需要调用entry.exit()以告知sentinel结束访问,sentinel会做一些资源的清理和数据统计工作。

entry.exit()方法最后调用到CtEntry.exitForContext()方法上:

protected void exitForContext(Context context, int count, Object... args) throws ErrorEntryFreeException {
    if (context != null) {
        if (context instanceof NullContext) {
            return;
        }
        //如果Context对象记录的Entry对象不是当前对象,
        //意味着entry.exit()与SphU.entry()不是成对出现的,
        //sentinel要求两者必须成对出现,而且要一一对应,否则抛出异常
        //Context有父子关系,这个在文章后面介绍
        if (context.getCurEntry() != this) {
            String curEntryNameInContext = context.getCurEntry() == null ? null
                : context.getCurEntry().getResourceWrapper().getName();
            // Clean previous call stack.
            CtEntry e = (CtEntry) context.getCurEntry();
            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的exit方法,每个slot清理和统计数据
            if (chain != null) {
                chain.exit(context, resourceWrapper, count, args);
            }
            //遍历exitHandlers,相当于回调,一般的DegradeSlot有回调,
            //DegradeSlot根据服务访问状态,决定是否将降级状态由HALF_OPEN变为OPEN
            callExitHandlersAndCleanUp(context);

            //设置为上一级Context对象
            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();
                }
            }
            //设置当前对象的this.context = null
            clearEntryContext();
        }
    }
}

entry.exit()相对比较简单,它按照顺序再次遍历访问每个slot的exit()方法。

Context

Context是sentinel中的上下文对象,Context贯穿整个资源的访问过程。Context保存在ThreadLocal中。
创建Context有多种方式,可以像第二小节里面一样,创建一个默认的Context对象,也可以在访问资源前使用ContextUtil创建Context对象:

//name表示Context的名称或者链路入口的名称,origin表示调用来源的名称,默认为空字符串
public static Context enter(String name, String origin);
public static Context enter(String name);

无论是上面两种创建方式还是第二小节里面的创建方式,最终都是调用ContextUtil.trueEnter()方法:

protected static Context trueEnter(String name, String origin) {
    //contextHolder是ThreadLocal<Context>类型
    Context context = contextHolder.get();
    if (context == null) {
        //contextNameNodeMap持有系统所有的入口节点
        Map<String, DefaultNode> localCacheNameMap = contextNameNodeMap;
        DefaultNode node = localCacheNameMap.get(name);
        if (node == null) {
            //sentinel最大只能支撑2000个入口节点,如果超过2000个,sentinel无法提供对资源的保护
            if (localCacheNameMap.size() > Constants.MAX_CONTEXT_NAME_SIZE) {
                setNullContext();
                return NULL_CONTEXT;
            } else {
                LOCK.lock();
                try {
                    node = contextNameNodeMap.get(name);
                    if (node == null) {
                        if (contextNameNodeMap.size() > Constants.MAX_CONTEXT_NAME_SIZE) {
                            setNullContext();
                            return NULL_CONTEXT;
                        } else {
                            //创建入口节点
                            node = new EntranceNode(new StringResourceWrapper(name, EntryType.IN), null);
                            //入口节点作为虚拟根节点的子节点
                            Constants.ROOT.addChild(node);
                            Map<String, DefaultNode> newMap = new HashMap<>(contextNameNodeMap.size() + 1);
                            newMap.putAll(contextNameNodeMap);
                            newMap.put(name, node);
                            contextNameNodeMap = newMap;
                        }
                    }
                } finally {
                    LOCK.unlock();
                }
            }
        }
        //创建Context对象,可以看到Context对象与入口节点一一对应
        context = new Context(node, name);
        //设置调用来源
        context.setOrigin(origin);
        contextHolder.set(context);
    }
    return context;
}

Context对象持有名称和一个入口节点对象,入口节点与对应了线程访问的第一个资源,Context对象对应了线程对资源的一次访问,一个线程对应一个Context对象。而且每个入口节点对象都是虚拟根对象ROOT的子节点,虚拟根对象的定义如下:

//ROOT_ID=machine-root
public final static DefaultNode ROOT = new EntranceNode(new StringResourceWrapper(ROOT_ID, EntryType.IN),
    new ClusterNode(ROOT_ID, ResourceTypeConstants.COMMON));

虚拟根对象的名字为machine-root。总的来说,Context是为了在访问资源的过程中保存共享数据使用的。
下面详细介绍一下sentinel中的访问链路树。
假如使用如下代码访问资源(来源官网):

  ContextUtil.enter("entrance1", "appA");
  Entry nodeA = SphU.entry("nodeA");
  if (nodeA != null) {
    nodeA.exit();
  }
  ContextUtil.exit();

  ContextUtil.enter("entrance2", "appA");
  nodeA = SphU.entry("nodeB");
  if (nodeA != null) {
    nodeA.exit();
  }
  ContextUtil.exit();

以上代码将在内存中生成以下结构:

               machine-root
               /         \
              /           \
      entrance1         entrance2    -------表示入口节点对象EntranceNode
            /               \
           /                 \
   DefaultNode(nodeA)   DefaultNode(nodeB)   ---------内部创建DefaultNode节点
              |               |          
              |               |
    ClusterNode(nodeA)   ClusterNode(nodeB)    ------------记录资源的访问数据

再看下面这个访问方式:

  ContextUtil.enter("entrance1", "appA");
  Entry nodeA = SphU.entry("nodeA");
  Entry nodeB = SphU.entry("nodeB");
  	if (nodeB != null) {
    	nodeB.exit();
  	}
  if (nodeA != null) {
    nodeA.exit();
  }
  ContextUtil.exit();

上面这个代码创建的访问链路树如下:

              machine-root
               /          
              /            
        entrance1      -------表示入口节点对象EntranceNode
            /               
           /                 
   DefaultNode(nodeA)  ---------内部创建DefaultNode节点,持有一个ClusterNode对象
          /               
         /            
 DefaultNode(nodeB)  ------------记录资源的访问数据,持有一个ClusterNode对象    

每调用一次SphU.entry()方法都会在访问链路树上增加一个子节点,通过这个树可以还原出资源的访问路径。
每访问一个资源,Context对象都使用curEntry属性记录下正在访问资源对应的Entry对象,Entry对象有一个parent属性记录下父Entry,比如上面代码中,nodeB的父Entry是nodeA,Entry还有一个curNode属性,该属性记录了对应的DefaultNode对象。每个DefaultNode对象还有一个ClusterNode类的属性clusterNode,clusterNode的作用是记录被访问的资源的统计数据,比如平均响应时间、总请求数、QPS等,FlowSlot便是依据这些数据来判断是否允许访问资源。Context可以通过上述这些属性构建出一个完整的资源访问树,并将资源访问数据更新到对应的ClusterNode对象中。

参考