Sentinel源码深度解析

对于Sentinel这个框架,我看的是原生的,没有与Spring Cloud 整合的,也就是sentinel-core包下相关的源码,因为清楚了原始的Sentinel框架的原理,再与Spring Cloud其他的组件,如与ribbon、feign的整合,就不难了。
sentinel-core包下有一个annotation包,这个包下有一个注解:@SentinelResource,该注解很重要,它实现了限流的作用。对于Java而言,其实注解本身是没有什么特殊意义的,之所有可以起到作用,是由于有其他的类判断并处理了对应的注解。对于@SentinelResource注解而言,也是如此。它实际上是借助了Spring AOP,也就是说,有一个切面类,即SentinelResourceAspect,会处理所有方法上添加了@SentinelResource注解的类,对该方法执行切面逻辑,那就看看SentinelResourceAspect#invokeResourceWithSentinel()方法,如下图所示:
在这里插入图片描述
可知,首先是解析@SentinelResource注解,拿到value、entryType和resourceType的值。在try代码块中调用SphU#entry()方法,这是的Sentinel的核心方法点进去看看,如下图所示:
在这里插入图片描述
里面有一个Env对象,看名字应该是Sentinel的环境变量对象,看看该类,如下图所示:
在这里插入图片描述
发现,有一个static代码块,里面只调用了InitExecutor#doInit()方法。源码中,有init的方法,一定要看看,会有初始化的逻辑,进入该方法看看,如下图所示:
在这里插入图片描述
代码很多,核心就只有几点:通过Sentinel的SPI,加载InitFunc接口的实现类,然后通过类上的@InitOrder注解的value值排序,最后调用InitFunc#init()方法。加载逻辑如下图所示:
在这里插入图片描述
该接口的几个实现类大概看了下,还是跟初始化相关的,目前来看不太重要,先不管,看主线逻辑。回到 SphU#entry()方法,看他的调用链路,如下图所示:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
ContextUtil.getContext()得到的值为null,并且Constants.ON的值为true,因此最终进入我标的红色了箭头的逻辑中,在这段代码中,CtSph#lookProcessChain()方法,看看该方法,如下图所示:
在这里插入图片描述
核心的逻辑就是调用SlotChainProvider#newSlotChain(),得到ProcessorSlotChain并返回该对象,看看该方法做了什么,如下图所示:
在这里插入图片描述
上述代码可知,也只通过Sentinel的SPI机制,加载SlotChainBuilder接口的实现类,得到的实现类为DefaultSlotChainBuilder,如下图所示:
在这里插入图片描述
最终得到的是DefaultSlotChainBuilder#build()方法返回的结果,看看该方法,如下图所示:
在这里插入图片描述
看看com.alibaba.csp.sentinel.slotchain. ProcessorSlot文件,如下图所示:
在这里插入图片描述
这些类都是ProcessorSlot接口的实现类,都会通过Sentinel的SPI加载并实例化。由于使用的是SpiLoader#loadInstanceListSorted()方法加载的,看方法名字知道是排序的,主要是通过@Spi注解中的order属性的大小来排序的,看一个实现类:
在这里插入图片描述
看看排序逻辑:
在这里插入图片描述
可知是降序,而com.alibaba.csp.sentinel.slotchain. ProcessorSlot文件中的每个实现类@SPI注解的order值就是越来越大的,因此可以知道顺序是最下面的排在第一位,以此类推。但是:遍历ProcessorSlot实现类的集合,调用了DefaultProcessorSlotChain#addLast()方法,传入一个个ProcessorSlot实现类,因此最终顺序是跟之前得到的集合的顺序相反,也就是按照com.alibaba.csp.sentinel.slotchain. ProcessorSlot中实现类的顺序。再回到CtSph# entryWithPriority()方法,得到的chain实际上就是DefaultProcessorSlotChain,而该对象中,存储了ProcessorSlot实现类的集合,顺序上面已经说清楚了,这里不再赘述,并且chain誓不为空的,因此走else的逻辑:调用DefaultProcessorSlotChain#entry()方法,如下图所示:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
至于为什么先调用NodeSelectorSlot#entry()方法,这里不再赘述,看看该方法,如下图所示:
在这里插入图片描述
这个方法并没有做太多了逻辑,无非是做设置操作,继续看NodeSelectorSlotfireEntry()方法,如下图所示:
在这里插入图片描述
再就是调用ClusterBuilderSlot#entry()方法了,看看该方法,如下图所示:
在这里插入图片描述
创建了一个ClusterNode对象,设置到了DefaultNode对象的clusterNode属性中,这个后续会用到。再就是调用StatisticSlot#entry()方法了,看看该方法,如下图所示:
在这里插入图片描述
后面全是几个catch代码块,暂时不用看,看try代码块的逻辑即可:还是调用StatisticSlot#fireEntry()方法,实际上最终会调用下一个ProcessorSlot接口实现类的entry()方法,这个稍后再看。下面还调用了两个方法。即DefaultNode#increaseThreadNum()方法、DefaultNode#addPassRequest()方法。看这两个方法的名字,知道是做统计的,统计线程的数量和通过的请求数量,比如点进第一个方法看看,如下图所示:
在这里插入图片描述
在这里插入图片描述
curThreadNum是LongAdder对象,就是当前线程数+1,使用LongAdder是为了保证数据线程安全。同样的,调用DefaultNode#addPassRequest(),传入count,也就是1,看看该方法,如下图所示:
在这里插入图片描述
在这里插入图片描述
这里实际上是用到了滑动窗口算法,分别统计,每秒钟和每分钟通过的请求数,以每秒通过的请求数为例,即调用this.rollingCounterInSecond.addPass(count)方法,count为1,如下图所示:
在这里插入图片描述
在该方法中,调用this.data.currentWindow(),拿到当前滑动窗口,点进去看看,是如何拿到当前滑动窗口的,如下图所示:
在这里插入图片描述
调用this.currentWindow()方法,传入当前时间,如下图所示:
在这里插入图片描述
在这里插入图片描述
直接看else中代码的逻辑,传入的当前时间不会是小于0。
调用this.calculateTimeIdx(),传入当前时间,得到一个下标 idx。
调用this.calculateWindowStart()方法,传入当前时间,拿到滑动窗口的开始时间,看看这两个方法:
在这里插入图片描述
windowLengthInMs属性的值不知道是多少,array是一个数组,但是不知道长度,这需要再往前看看,如下图所示:
在这里插入图片描述
最终需要知道sampleCount和intervalInMs的值,而这两个值是通过LeapArray的有参构传入的,最终需要看看LeapArray实在哪里是实例化的。由于该对象是在Node中使用的,不管是DefaultNode还是ClusterNode,都是如此,在他们的类中,或者父类中找,最终在他们的父类StatisticNode中发现了,如下图所示:
在这里插入图片描述
SampleCountProperty.SAMPLE_COUNT = 2、IntervalProperty.INTERVAL = 1000、array的长度为2。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
再回到this.calculateTimeIdx()方法和this.calculateWindowStart()方法,传入当前时间,计算拿到滑动窗口的开始时间,如下图所示:
在这里插入图片描述
其中,windowLengthInMs = 1000 / 2 = 500。array.length() = 2。
其实这个滑动窗口,实际上是把1s分成了两个500ms,滑动窗口统计的就是这一秒内通过的请求数,准确地说就是统计这两个500ms的请求数。根据这两个计算的方法,以当前时间开始往后数,有三种情况,举例计算,结果如下:
① 如果经过的时间小于或者等于500ms,如400ms:
下标idx :int timeId = 400 / 500 = 0,timeId%2 = 0;
windowStart =400 - 400 % 500 = 0
② 如果经过的时间在大于500ms,但是小等于1000ms,如800:
下标idx :int timeId = 800 / 500 = 1,timeId%2 = 1;
windowStart =800 - 800 % 500 = 500
③ 如果大于1000ms,如1200:
下标idx :int timeId = 1200 / 500 = 2,timeId%2 = 0;
windowStart =1200 - 1200 % 500 = 1000

再回到LeapArray# currentWindow()方法,如下图所示:
在这里插入图片描述

在while循环中,有四种情况:
array.get(),传入上面计算好的下标idx,得到WindowWrap对象
① 如果WindowWrap为空,说明着这个时间段内,还没有建立WindowWrap对象,因此创建WindowWrap对象,调用有参构造,传入:windowLengthInMs(500ms)、windowStart(上面计算好的)、MetricBucket。再通过CAS,将创建的WindowWrap对象设置到array的对应下标;
② 如果计算出来的windowStart等于WindowWrap的windowStart,说明此时的时间正好在这个窗口的时间范围内 ,因此直接返回该WindowWrap对象,用于后续的处理,至于是怎么样的处理,后面说;
③ 如果计算出来的windowStart大于WindowWrap的windowStart,则表示旧的WindowWrap对象已经不在滑动窗港口的统计范围内,因此需要调用LeapArray#resetWindowTo()方法,传入WindowWrap对象和windowStart,进行重置,具体看看该方法,如下图所示:
在这里插入图片描述在这里插入图片描述
在这里插入图片描述
可知,重置就是将WindowWrap对象的windowStart设置为传入的windowStart(计算得来的)。再看看WindowWrap#value()#reset()方法,如下图所示:
在这里插入图片描述
其中,counters属性是一个LongAdder数组,传入MetricEvent的类型,拿到具体的LongAdder对象,看看MetricEvent是个什么类,如下图所示:
在这里插入图片描述
可以看出是用于不同类型的统计,统计这些不同的指标,便于后续计算,最后调用LongAdder#reset()方法,将所有的指标清零。
④ 如果计算出来的windowStart大于WindowWrap的windowStart,按道理说,不应该出现这种情况,这实际上是出现了"时钟回拨"的问题,表示Linux服务器的时间出现了问题,因为是通过获取Linux的当前时间,计算得来的windowStart,因此针对这种情况,直接新建一个WindowWrap对象并返回。
最后,再回到ArrayMetric#addPass()方法中,如下图所示:
在这里插入图片描述
拿到当前WindowWrap对象后,调用WindowWrap#value(),得到MetricBucket对象,再调用MetricBucket#addPass()方法,传入count,即1,如下图所示:
在这里插入图片描述
在这里插入图片描述
给MetricEvent.PASS对应的LongAdder值+1。类似的,增加其他指标的值,如下图所示:
在这里插入图片描述
上述这些操作操作,都是在StatisticSlot中完成的,因此它的作用就是对各种指标进行统计,便于后续根据这些指标做不同的处理。
再就是调用AuthoritySlot#entry()方法,如下图所示:
在这里插入图片描述
百名单校验,不满足的AuthorityRuleChecker#passCheck()方法的,抛AuthorityException异常。再看下一个,即SystemSlot#entry()方法,如下图所示:
在这里插入图片描述
在这里插入图片描述
判断qps、maxThread、maxRt、highestSystemLoad、highestCpuUsage等等。以qps为例,当通过Sentinel控制台页面,设置系统QPS规则后,点击保存,最后Sentinel控制台的后台接口,会发送一个请求到对应的应用,具体调用链路我就不详细展开了,总而言之,会调用到SystemPropertyListener#configUpdate()方法,传入List,如下图所示:
在这里插入图片描述
在这里插入图片描述
最终将再Sentinel控制台设置的规则的值到qps上。回到SystemRuleManager#loadSystemConf()方法中,就会进行判断,如下图所示:
在这里插入图片描述
而通过调用Constants.ENTRY_NODE#successQps()方法拿到的currentQps值,实际上也在StatisticSlot类中统计得来的,再经过计算,得到需要的结果,successQps()方法的计算如下图所示:
在这里插入图片描述
当SystemSlot#entry()方法处理完了对应的逻辑,就会调用FlowSlot#entry()方法,具体代码如下图所示:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
有四种流控规则,以第二种为例,即RateLimiterController#canPass()方法,这些流控规则都是可以在Sentinel控制台指定的。该方法如下图所示:
在这里插入图片描述
注释其实说的很清楚:先获取当前时间currentTime,count是每秒能允许通过的最大请求数,因此costTime算出的是平均两个请求之间的时长,上次请求通过的时间加上costTime,得到的结果expectedTime就是预期通过的时间,最后判断:得到的预期时间是否小于等于当前时间?如果是,则走if逻辑,将latestPassedTime设置为currentTime,记录本次请求通过的时间,并返回true,表示请求可以正常通过(其实在这里我有一个疑问,为什么不是预期时间是否大于等于当前时间?如果每次请求的间隔+上次请求通过的时间得到的预期时间越大,那不是表示每秒中通过的请求数越少吗?不应该是更满足流控规则吗?);如果说是预期的时间大于当前时间,则走else逻辑,通过预期时间减去当前时间,得到waitTime,如果waitTime大于maxQueueingTimeMs,则直接返回false,表示请求不能通过。如果waitTime的值大于0,则线程在这里休眠waitTime长的时间,最后返回true,表示请求可以通过。
如果是WarmUpController,则看看WarmUpController#canPass()方法,如下图所示:
在这里插入图片描述
使用的是令牌桶算法进行流量的控制,该算法就不在这里详细解释了。现在就只剩最后一个了,即DegradeSlot#entry()方法,如下图所示:
在这里插入图片描述
在这里插入图片描述
在这里是断路器的实现逻辑,具体如下:首先拿到当前状态,判断currentState是否等于State.CLOSED,如果是,则表示断路器没有打开,请求可以直接通过。否则判断currentState是否是State.OPEN,如果是,则先调用AbstractCircuitBreaker#retryTimeoutArrived()方法,判断当前时间是否大于nextRetryTimestamp,如果是,则调用AbstractCircuitBreaker#fromOpenToHalfOpen()方法,将currentState设置为State.HALF_OPEN,也就是断路器半开。那么,最关键的问题来了,断路器的currentState,是在哪里设置成的State.OPEN呢?如下图所示:
在这里插入图片描述
也就是在该类的第94行,如下图所示:
在这里插入图片描述
再看看AbstractCircuitBreaker#fromCloseToOpen()方法,是在那里被调用的呢?如下图所示:
在这里插入图片描述
接着网上看它的调用链路,发现只有两个地方调用了,如下图所示:
在这里插入图片描述
以ExceptionCircuitBreaker为例,如下图所示:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
可以看出,这一块的代码实际上是从DegradeSlot#exit()方法开始,一层层往下调用的。
调用DegradeSlot#exit()时,首先判断,异常不能是BlockException,因为这是异常时不满足Sentinel规则抛出的,在Sentinel看来是正常的,不应该被断路器处理,触发断路器应该是非BlockException的异常(对ExceptionCircuitBreaker而言)。通过DegradeRuleManager#getCircuitBreakers()方法,拿到断路器的列表,遍历断路器列表,调用CircuitBreaker.onRequestComplete()方法,这里还是以ExceptionCircuitBreaker为例说明,即调用ExceptionCircuitBreaker#onRequestComplete()方法,如下图所示:
在这里插入图片描述
如果发生了异常,则拿到errorCount值 +1,同时totalCount的值也 +1,再调用ExceptionCircuitBreaker#handleStateChangeWhenThresholdExceeded()方法,传入异常,如下图所示:
在这里插入图片描述
该方法中,会判断当前currentState的值是否等于State.OPEN?是则不处理。如果不是的话才会进入if代码块继续处理。先判断currentState是否等于State.HALF_OPEN?如果是,则判断传入的异常是否为null,是则表示,该接口已经恢复正常,调用没有报错了,这时可以调用ExceptionCircuitBreaker#fromHalfOpenToClose()方法,将currentState通过CAS,由State.HALF_OPEN改为State.CLOSED;如果传入的异常部位null,则将将currentState通过CAS,由State.HALF_OPEN改为State.OPEN。这是currentState的值等于State.HALF_OPEN的逻辑。如果currentState的值不等于State.HALF_OPEN,且最外层的if已经判断了currentState的值也不等于State.OPEN,则此时currentState的值就是State.CLOSED了。继续else的逻辑,首先拿到errCount和totalCount的值,totalCount的值必须大于minRequestAmount的值,这个值是通过Sentinel控制台设置的,表示最少要通过的请求数量,只有大于了这个值才会进去if逻辑。继续,如果strategy为1(也可为2,通过Sentinel控制台设置)的话,errCount 除以 totalCount,得到一个比例,是curCount,判断该比例是否大于threshold(通过Sentinel设置的一个阈值),如果是的话,调用ExceptionCircuitBreaker#transformToOpen()方法,如下图所示:
在这里插入图片描述
在该方法中会判断currentState的值,如果是State.CLOSED,则通过CAS将currentState的值由State.CLOSED修改为State.OPEN;如果是State.HALF_OPEN,则通过CAS将currentState的值由State.HALF_OPEN修改为State.OPEN。
在这里,用通俗的大白话最后总结一下断路器(以ExceptionCircuitBreaker为例):

  1. 调用DegradeSlot#exit()方法:
    ① 如果调用某个接口发生了异常,注意不是BlockException,有就会将统计异常的LongAdder属性+1,同时请求通过总数的LongAdder属性+1,如果没有发生异常,则只有 请求通过总数的LongAdder属性+1;
    ② 如果此时断路器的状态是“全开”,不做处理;
    ③ 如果此时断路器的状态是“半开”,如果调用某个接口发生了异常,则将断路器状态设置为“全开”;否则设置断路器状态设置为“关闭”;
    ④ 如果此时断路器的状态是“关闭”,则获取到接口发生异常的数量 除以 调用接口的总数量,拿到一个比例值,如果之前通过Sentinel设置最少通过的请求数为5,发生异常的比例是50%,如果请求的的数量只有4次,即便,有两次异常了,也不会触发断路器,因为总数是4次,小于5。如果一共有10次请求,刚好地10次发生了5个异常,则此时出发断路器,将断路器的状态设置为“全开”;
  2. 调用DegradeSlot#exit()方法,最终调用AbstractCircuitBreaker#tryPass()方法:
    ① 如果此时断路器状态为"关闭",则直接返回true,表示该请求可以通过;
    ② 如果此时断路器状态为“半开”,则直接返回false,表示请求不能通过,抛出DegradeException异常;
    ③ 如果此时断路器状态为“全开”,则判断当前时间是否大于nextRetryTimestamp的值,而nextRetryTimestamp的值是 当前时间加上recoveryTimeoutMs的值(recoveryTimeoutMs也是通过Sentinel控制台设置的,比如设置10s),当断路器状态由“关闭”改为“全开”,或者由“半开”改为“全开”,都出重新设置nextRetryTimestamp的值,即 当前时间加上recoveryTimeoutMs的值。最后再调用AbstractCircuitBreaker#fromOpenToHalfOpen()方法,通过CAS,将断路器状态由“全开”改为“半开”,CAS设置成功,返回true,表示该请求可以通过。

回到SentinelResourceAspect类中,再看看SentinelResourceAspect#invokeResourceWithSentinel()方法,如下图所示:
在这里插入图片描述
假如请求超过了定义的流控规则,则会抛出BlockException,然后再catch代码块中,会调用SentinelResourceAspect#handleBlockException()方法,看看该方法,如下图所示:
在这里插入图片描述
在这里插入图片描述
可知,如果在@SentinelResource注解中配置了blockHandler属性,该属性会指定一个方法名,并且保证该方法是静态的,可以在该方法中写对应的逻辑,比如一个友好提示等,最终该方法会通过反射被调用。再回到SentinelResourceAspect#handleBlockException()方法中,如果发生的是不是BlockException,则会进入下面的catch代码块中,调用SentinelResourceAspect#handleFallback()方法,如下图所示:
在这里插入图片描述拿到@SentinelResource注解中配置的fallback(指定的是方法,也应该写成静态方法)和fallbackClass的值,最终通过反射调用。

小结一下:
其实讲到这里,也就清楚了,如果Sentinel想要整合进其他的框架中去,除了引入Sentinel以来外,更重要的是,如何让先让SphU.entry()方法执行,再执行需要做限流的方法,最后再执行entry#exit()方法,而entry则是SphU.entry()方法返回的结果。当然,一般还要有try/catch块包裹,用于处理抛出BlockException的情况。
比如整合Spring MVC,是不是可以考虑写一个拦截器呢,在拦截器的前置方法中调用SphU.entry()方法,再将SphU.entry()返回的对象entry放入ThreadLocal中,在拦截器的后置方法中,通过ThreadLocal拿到entry,再调用entry#exit()方法,这样,是不是就起到了限流的作用呢?如果只想对某些特定的接口做限流,也简单,加一些判断逻辑即可。如果是整合其他的框架,大家可以考虑该怎么做,思路是一样的。

  • 24
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值