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对象中。