1、什么是Sentinel:Sentinel是阿里开源的项目,提供了流量控制、熔断降级、系统负载保护等多个维度来保障服务之间的稳定性。 2012年,Sentinel诞生于阿里巴巴,其主要目标是流量控制。2013-2017年,Sentinel迅速发展,并成为阿里巴巴所有微服务的基本组成部分。 它已在6000多个应用程序中使用,涵盖了几乎所有核心电子商务场景。2018年,Sentinel演变为一个开源项目。2020年,Sentinel Golang发布。 Sentinel 具有以下特征:丰富的应用场景 :Sentinel 承接了阿里巴巴近 10 年的双十一大促流量的核心场景,例如秒杀(即 完善的 SPI 扩展点:Sentinel 提供简单易用、完善的 SPI 扩展接口。您可以通过实现扩展接口来快 Sentinel的生态圈 Sentinel主要特性:关于Sentinel与Hystrix的区别见:https://yq.aliyun.com/articles/633786/ 到这已经学习Sentinel的基本的使用,在很多的特性和Hystrix有很多类似的功能。以下是Sentinel和Hystrix的对比。 Sentinel 的使用Sentinel 的使用可以分为两个部分:
在这里我们看下控制台的使用 Sentinel中的管理控制台2.1 获取 Sentinel 控制台您可以从 release 页面 下载最新版本的控制台 jar 包。 您也可以从最新版本的源码自行构建 Sentinel 控制台:
2.2 sentinel服务启动java -server -Xms64m -Xmx256m -Dserver.port=8849 -Dcsp.sentinel.dashboard.server=localhost:8849 -Dproject.name=sentinel-dashboard -jar /work/sentinel-dashboard-1.7.1.jar 开机启动:启动命令可以加入到启动的 rc.local 配置文件, 之后做到开机启动 启动 sentinel/usr/bin/su - root -c "nohup java -server -Xms64m -Xmx256m -Dserver.port=8849 -Dcsp.sentinel.dashboard.server=localhost:8849 -Dproject.name=sentinel-dashboard -jar /work/sentinel-dashboard-1.7.1.jar 2>&1 &" 除了流量控制以外,对调用链路中不稳定的资源进行熔断降级也是保障高可用的重要措施之一。 由于调用关系的复杂性,如果调用链路中的某个资源不稳定,最终会导致请求发生堆积。Sentinel 熔断降级会在调用链路中某个资源出现不稳定状态时(例如调用超时或异常比例升高),对这个资源的调用进行限制,让请求快速失败,避免影响到其它的资源而导致级联错误。当资源被降级后,在接下来的降级时间窗口之内,对该资源的调用都自动熔断(默认行为是抛出 DegradeException)。 关于熔断降级的介绍见:Sentinel熔断降级。 下面就使用基于注解的方式实现Sentinel的熔断降级的demo。
使用如下命令启动控制台: nohup java -server -Xms64m -Xmx256m -Dserver.port=8849 -Dcsp.sentinel.dashboard.server=localhost:8849 -Dproject.name=sentinel-dashboard -jar /work/sentinel-dashboard-1.7.1.jar & 其中 -Dserver.port=8849用于指定 Sentinel 控制台端口为 8849 , 这个端口可以按需指定。 从 Sentinel 1.6.0 起,Sentinel 控制台引入基本的登录功能,默认用户名和密码都是
https://github.com/alibaba/Sentinel/releases/download/1.6.3/sentinel-dashboard-1.7.1.jar (2)启动 [root@192 ~]# java -Dserver.port=8888 -Dcsp.sentinel.dashboard.server=localhost:8888 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard-1.6.3.jarINFO: log base dir is: /root/logs/csp/INFO: log name use pid is: false. ____ _ __ _ _/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \\\/ ___)| |_)| | | | | || (_| | ) ) ) )' |____| .__|_| |_|_| |_\__, | / / / /=========|_|==============|___/=/_/_/_/:: Spring Boot :: (v2.0.5.RELEASE)2020-02-08 13:07:29.316 INFO 114031 --- [ main] c.a.c.s.dashboard.DashboardApplication : Starting DashboardApplication on 192.168.180.137 with PID 114031 (/root/sentinel-dashboard-1.6.3.jar started by root in /root)2020-02-08 13:07:29.319 INFO 114031 --- [ main] c.a.c.s.dashboard.DashboardApplication : No active profile set, falling back to default profiles: default2020-02-08 13:07:29.456 INFO 114031 --- [ main] ConfigServletWebServerApplicationContext : Refreshing org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@59690aa4: startup date [Sat Feb 08 13:07:29 CST 2020]; root of context hierarchy2020-02-08 13:07:33.783 INFO 114031 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8888 (http) 启动 Sentinel 控制台需要 JDK 版本为 1.8 及以上版本。 查看机器列表以及健康情况 客户端能接入控制台控制台启动后,客户端需要按照以下步骤接入到控制台。 父工程引入 alibaba实现的SpringCloud <dependencyManagement><dependencies><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-dependencies</artifactId><version>Greenwich.RELEASE</version><type>pom</type><scope>import</scope></dependency><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-alibaba-dependencies</artifactId><version>2.1.0.RELEASE</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement> 子工程中引入 sentinel <dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency> ( 2)配置启动参数 在工程的application.yml中添加Sentinel 控制台配置信息 spring:cloud:sentinel:transport:dashboard: 192.168.180.137:8888 #sentinel控制台的请求地址 这里的 spring.cloud.sentinel.transport.dashboard 配置控制台的请求路径。 Sentinel与Hystrix的区别迁移方案 2 使用 Sentinel 来进行熔断与限流Sentinel 可以简单的分为 Sentinel 核心库和 Dashboard。核心库不依赖 Dashboard,但是结合
Sentinel 的所有规则都可以在内存态中动态地查询及修改,修改之后立即生效. 先把可能需要保护的资源定义好,之后再配置规则。 也可以理解为,只要有了资源,我们就可以在任何时候灵活地定义各种流量控制规则。在编码的时候,只需要考虑这个代码是否需要保护,如果需要保 1. 定义资源资源是 Sentinel 的关键概念。它可以是 Java 应用程序中的任何内容,例如,由应用程序提供的服务,或由应用程序调用的其它应用提供的服务,RPC接口方法,甚至可以是一段代码。 只要通过 Sentinel API 定义的代码,就是资源,能够被 Sentinel 保护起来。大部分情况下,可以使用方法签名,URL,甚至服务名称作为资源名来标示资源。 把需要控制流量的代码用 Sentinel的关键代码 SphU.entry("资源名") 和 entry.exit() 包围起来即可。 实例代码: Entry entry = null;try {// 定义一个sentinel保护的资源,名称为test-sentinel-apientry = SphU.entry(resourceName);// 模拟执行被保护的业务逻辑耗时Thread.sleep(100);return a;} catch (BlockException e) {// 如果被保护的资源被限流或者降级了,就会抛出BlockExceptionlog.warn("资源被限流或降级了", e);return "资源被限流或降级了";} catch (InterruptedException e) {return "发生InterruptedException";} finally {if (entry != null) {entry.exit();}ContextUtil.exit();}} 在下面的例子中, 用 try-with-resources 来定义资源。参考代码如下: public static void main(String[] args) {// 配置规则.initFlowRules();while (true) {// 1.5.0 版本开始可以直接利用 try-with-resources 特性try (Entry entry = SphU.entry("HelloWorld")) {// 被保护的逻辑System.out.println("hello world"); } catch (BlockException ex) {// 处理被流控的逻辑System.out.println("blocked!"); }}} 资源注解@SentinelResource 也可以使用Sentinel提供的注解@SentinelResource来定义资源,实例如下: @SentinelResource("HelloWorld")public void helloWorld() {// 资源中的逻辑System.out.println("hello world");}
blockHandler 对应处理 BlockException的函数名称,可选项。blockHandler 函数访问范围需要是 public,返回类型需要与原方法相匹配,参数类型需要和原方法相匹配并且最后加一个额外的参数,类型为 BlockException。blockHandler 函数默认需要和原方法在同一个类中。若希望使用其他类的函数,则可以指定 blockHandlerClass 为对应的类的 Class 对象,注意对应的函数必需为 static 函数,否则无法解析。
fallback 函数签名和位置要求:
defaultFallback 函数签名要求:
2. 定义规则规则主要有流控规则、 熔断降级规则、系统规则、权限规则、热点参数规则等: 一段硬编码的方式定义流量控制规则如下: private void initSystemRule() {List<SystemRule> rules = new ArrayList<>();SystemRule rule = new SystemRule();rule.setHighestSystemLoad(10);rules.add(rule);SystemRuleManager.loadRules(rules);} 加载规则: FlowRuleManager.loadRules(List<FlowRule> rules); // 修改流控规则DegradeRuleManager.loadRules(List<DegradeRule> rules); // 修改降级规则SystemRuleManager.loadRules(List<SystemRule> rules); // 修改系统规则AuthorityRuleManager.loadRules(List<AuthorityRule> rules); // 修改授权规则 3 sentinel 熔断降级1 什么是熔断降级熔断降级对调用链路中不稳定的资源进行熔断降级是保障高可用的重要措施之一。 由于调用关系的复杂性,如果调用链路中的某个资源不稳定,最终会导致请求发生堆积。Sentinel 熔断降级会在调用链路中某个资源出现不稳定状态时(例如调用超时或异常比例升高),对这个资源的调用进行限制,让请求快速失败,避免影响到其它的资源而导致级联错误。当资源被降级后,在接下来的降级时间窗口之内,对该资源的调用都自动熔断(默认行为是抛出 DegradeException) 2. 熔断降级规则熔断降级规则包含下面几个重要的属性:
3 几种降级策略我们通常用以下几种降级策略:
4 熔断降级代码实现可以通过调用 DegradeRuleManager.loadRules() 方法来用硬编码的方式定义流量控制规则。 @PostConstructpublic void initSentinelRule(){//熔断规则: 5s内调用接口出现异常次数超过5的时候, 进行熔断List<DegradeRule> degradeRules = new ArrayList<>();DegradeRule rule = new DegradeRule();rule.setResource("queryGoodsInfo");rule.setCount(5);rule.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_COUNT);//熔断规则rule.setTimeWindow(5);degradeRules.add(rule);DegradeRuleManager.loadRules(degradeRules);}
5 控制台降级规则配置 参数
6 与Hystrix的熔断对比:Hystrix常用的线程池隔离会造成线程上下切换的overhead比较大;Hystrix使用的信号量隔离对某个资源调用的并发数进行控制,效果不错,但是无法对慢调用进行自动降级; Sentinel通过并发线程数的流量控制提供信号量隔离的功能;此外,Sentinel支持的熔断降级维度更多,可对多种指标进行流控、熔断,且提供了实时监控和控制面板,功能更为强大。 4 Sentinel 流控(限流)流量控制(Flow Control),原理是监控应用流量的QPS或并发线程数等指标,当达到指定阈值时对流量进行控制,避免系统被瞬时的流量高峰冲垮,保障应用高可用性。 通过流控规则来指定允许该资源通过的请求次数,例如下面的代码定义了资源 HelloWorld 每秒最多只能通过 20 个请求。 参考的规则定义如下: private static void initFlowRules(){List<FlowRule> rules = new ArrayList<>();FlowRule rule = new FlowRule();rule.setResource("HelloWorld");rule.setGrade(RuleConstant.FLOW_GRADE_QPS);// Set limit QPS to 20.rule.setCount(20);rules.add(rule);FlowRuleManager.loadRules(rules);} 一条限流规则主要由下面几个因素组成,我们可以组合这些元素来实现不同的限流效果:
基本的参数资源名:唯一名称,默认请求路径 针对来源:Sentinel可以针对调用者进行限流,填写微服务名,默认为default(不区分来源) 阈值类型/单机阈值: 1.QPS:每秒请求数,当前调用该api的QPS到达阈值的时候进行限流 2.线程数:当调用该api的线程数到达阈值的时候,进行限流 是否集群:是否为集群 流控的几种 |
Field | 说明 | 默认值 |
---|---|---|
resource | 资源名,资源名是限流规则的作用对象 | |
count | 限流阈值 | |
grade | 限流阈值类型,QPS 或线程数模式 | QPS 模式 |
limitApp | 流控针对的调用来源 | default ,代表不区分调用来源 |
strategy | 判断的根据是资源自身,还是根据其它关联资源 (refResource ),还是根据链路入口 | 根据资源本身 |
controlBehavior | 流控效果(直接拒绝 / 排队等待 / 慢启动模式) | 直接拒绝 |
频繁刷新请求,1秒访问2次请求,正常,超过设置的阈值,将报默认的错误。
再次的1秒访问2次请求,访问正常。超过2次,访问异常
调用关系包括调用方、被调用方;一个方法又可能会调用其它方法,形成一个调用链路的层次关系。Sentinel 通过 NodeSelectorSlot
建立不同资源间的调用的关系,并且通过 ClusterBuilderSlot
记录每个资源的实时统计信息。
当两个资源之间具有资源争抢或者依赖关系的时候,这两个资源便具有了关联。
比如对数据库同一个字段的读操作和写操作存在争抢,读的速度过高会影响写得速度,写的速度过高会影响读的速度。如果放任读写操作争抢资源,则争抢本身带来的开销会降低整体的吞吐量。可使用关联限流来避免具有关联关系的资源之间过度的争抢.
举例来说,read_db
和 write_db
这两个资源分别代表数据库读写,我们可以给 read_db
设置限流规则来达到写优先的目的。具体的方法:
设置 `strategy` 为 `RuleConstant.STRATEGY_RELATE`设置 `refResource` 为 `write_db`。这样当写库操作过于频繁时,读数据的请求会被限流。
还有一个例子,电商的 下订单 和 支付两个操作,需要优先保障 支付, 可以根据 支付接口的 流量阈值,来对订单接口进行限制,从而保护支付的目的。
添加2个请求
@SentinelResource(value = "test1", blockHandler = "exceptionHandler")@GetMapping("/test1")public String test1(){log.info(Thread.currentThread().getName() + "\t" + "...test1");return "-------hello baby,i am test1";}// Block 异常处理函数,参数最后多一个 BlockException,其余与原函数一致.public String exceptionHandler(BlockException ex){// Do some log here.ex.printStackTrace();log.info(Thread.currentThread().getName() + "\t" + "...exceptionHandler");return String.format("error: test1 is not OK");}@SentinelResource(value = "test1_ref")@GetMapping("/test1_ref")public String test1_ref(){log.info(Thread.currentThread().getName() + "\t" + "...test1_related");return "-------hello baby,i am test1_ref";}
// 关联模式流控 QPS控制在1以内String refResource = "test1_ref";FlowRule rRule = new FlowRule("test1").setCount(1) // QPS控制在1以内.setStrategy(RuleConstant.STRATEGY_RELATE).setRefResource(refResource);rules.add(rRule);FlowRuleManager.loadRules(rules);
选择QPS,单机阈值为1,选择关联,关联资源为/test_ref,这里用Jmeter模拟高并发,请求/test_ref。
在大批量线程高并发访问/test_ref,导致/test失效了
链路类型的关联也类似,就不再演示了。多个请求调用同一微服务。
当流量突然增大的时候,我们常常会希望系统从空闲状态到繁忙状态的切换的时间长一些。即如果系统在此之前长期处于空闲的状态,我们希望处理请求的数量是缓步的增多,经过预期的时间以后,到达系统处理请求个数的最大值。Warm Up(冷启动,预热)模式就是为了实现这个目的的。
默认 coldFactor 为 3,即请求 QPS 从 threshold / 3 开始,经预热时长逐渐升至设定的 QPS 阈值。
@SentinelResource(value = "testWarmUP", blockHandler = "exceptionHandlerOfWarmUp")@GetMapping("/testWarmUP")public String testWarmUP(){log.info(Thread.currentThread().getName() + "\t" + "...test1");return "-------hello baby,i am testWarmUP";}
FlowRule warmUPRule = new FlowRule();warmUPRule.setResource("testWarmUP");warmUPRule.setCount(20);warmUPRule.setGrade(RuleConstant.FLOW_GRADE_QPS);warmUPRule.setLimitApp("default");warmUPRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP);warmUPRule.setWarmUpPeriodSec(10);
先在单机阈值10/3,3的时候,预热10秒后,慢慢将阈值升至20。刚开始刷/testWarmUP,会出现默认错误,预热时间到了后,阈值增加,没超过阈值刷新,请求正常。
通常冷启动的过程系统允许通过的 QPS 曲线如下图所示:
如秒杀系统在开启瞬间,会有很多流量上来,很可能把系统打死,预热方式就是为了保护系统,可慢慢的把流量放进来,慢慢的把阈值增长到设置的阈值。
匀速排队(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER)方式会严格控制请求通过的间隔时间,也即是让请求以均匀的速度通过,对应的是漏桶算法。阈值必须设置为QPS。
这种方式主要用于处理间隔性突发的流量,例如消息队列。想象一下这样的场景,在某一秒有大量的请求到来,而接下来的几秒则处于空闲状态,我们希望系统能够在接下来的空闲期间逐渐处理这些请求,而不是在第一秒直接拒绝多余的请求。
某瞬时来了大流量的请求, 而如果此时要处理所有请求,很可能会导致系统负载过高,影响稳定性。但其实可能后面几秒之内都没有消息投递,若直接把多余的消息丢掉则没有充分利用系统处理消息的能力。Sentinel的Rate Limiter模式能在某一段时间间隔内以匀速方式处理这样的请求, 充分利用系统的处理能力, 也就是削峰填谷, 保证资源的稳定性.
Sentinel会以固定的间隔时间让请求通过, 访问资源。当请求到来的时候,如果当前请求距离上个通过的请求通过的时间间隔不小于预设值,则让当前请求通过;否则,计算当前请求的预期通过时间,如果该请求的预期通过时间小于规则预设的 timeout 时间,则该请求会等待直到预设时间到来通过;反之,则马上抛出阻塞异常。
使用Sentinel的这种策略, 简单点说, 就是使用一个时间段(比如20s的时间)处理某一瞬时产生的大量请求, 起到一个削峰填谷的作用, 从而充分利用系统的处理能力, 下图能很形象的展示这种场景: X轴代表时间, Y轴代表系统处理的请求.
模拟2个用户同时并发的访问资源,发出100个请求,
如果设置QPS阈值为1, 拒绝策略修改为Rate Limiter匀速RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER方式, 还需要设置setMaxQueueingTimeMs(20 * 1000)表示每一请求最长等待时间, 这里等待时间大一点, 以保证让所有请求都能正常通过;
假设这里设置的排队等待时间过小的话, 导致排队等待的请求超时而抛出异常BlockException, 最终结果可能是这100个并发请求中只有一个请求或几个才能正常通过, 所以使用这种模式得根据访问资源的耗时时间决定排队等待时间. 按照目前这种设置, QPS阈值为10的话, 每一个请求相当于是以匀速100ms左右通过.
@SentinelResource(value = "testLineUp", blockHandler = "exceptionHandlerOftestLineUp")@GetMapping("/testLineUp")public String testLineUp(){log.info(Thread.currentThread().getName() + "\t" + "...test1");return "-------hello baby,i am testLineUp";}
FlowRule lineUpRule = new FlowRule();lineUpRule.setResource("testLineUp");lineUpRule.setCount(10);lineUpRule.setGrade(RuleConstant.FLOW_GRADE_QPS);lineUpRule.setLimitApp("default");lineUpRule.setMaxQueueingTimeMs(20 * 1000);// CONTROL_BEHAVIOR_DEFAULT means requests more than threshold will be rejected immediately.// CONTROL_BEHAVIOR_DEFAULT将超过阈值的流量立即拒绝掉.lineUpRule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER);rules.add(lineUpRule);
何为热点?热点即经常访问的数据。很多时候我们希望统计某个热点数据中访问频次最高的 Top K 数据,并对其访问进行限制。比如:
商品 ID 为参数,统计一段时间内最常购买的商品 ID 并进行限制
用户 ID 为参数,针对一段时间内频繁访问的用户 ID 进行限制 热点参数限流会统计传入参数中的热点参数,并根据配置的限流阈值与模式,对包含热点参数的资源调用进行限流。热点参数限流可以看做是一种特殊的流量控制,仅对包含热点参数的资源调用生效。 使用该规则需要引入依赖:
热点参数规则(ParamFlowRule
)类似于流量控制规则(FlowRule
):
属性 | 说明 | 默认值 |
---|---|---|
resource | 资源名,必填 | |
count | 限流阈值,必填 | |
grade | 限流模式 | QPS 模式 |
durationInSec | 统计窗口时间长度(单位为秒),1.6.0 版本开始支持 | 1s |
controlBehavior | 流控效果(支持快速失败和匀速排队模式),1.6.0 版本开始支持 | 快速失败 |
maxQueueingTimeMs | 最大排队等待时长(仅在匀速排队模式生效),1.6.0 版本开始支持 | 0ms |
paramIdx | 热点参数的索引,必填,对应 SphU.entry(xxx, args) 中的参数索引位置 | |
paramFlowItemList | 参数例外项,可以针对指定的参数值单独设置限流阈值,不受前面 count 阈值的限制。仅支持基本类型和字符串类型 | |
clusterMode | 是否是集群参数流控规则 | false |
clusterConfig | 集群流控相关配置 |
@GetMapping("/byHotKey")@SentinelResource(value = "byHotKey",blockHandler = "userAccessError")public String test4(@RequestParam(value = "userId", required = false) String userId,@RequestParam(value = "goodId", required = false) int goodId){log.info(Thread.currentThread().getName() + "\t" + "...byHotKey");return "-----------by HotKey: UserId";}
可以通过 ParamFlowRuleManager 的 loadRules 方法更新热点参数规则,下面是官方实例:
ParamFlowRule rule = new ParamFlowRule(resourceName).setParamIdx(0).setCount(5);// 针对 int 类型的参数 PARAM_B,单独设置限流 QPS 阈值为 10,而不是全局的阈值 5.ParamFlowItem item = new ParamFlowItem().setObject(String.valueOf(PARAM_B)).setClassType(int.class.getName()).setCount(10);rule.setParamFlowItemList(Collections.singletonList(item));ParamFlowRuleManager.loadRules(Collections.singletonList(rule));
具体的限流代码如下:
ParamFlowRule pRule = new ParamFlowRule("byHotKey").setParamIdx(1).setCount(1);// 针对 参数值1000,单独设置限流 QPS 阈值为 5,而不是全局的阈值 1.ParamFlowItem item = new ParamFlowItem().setObject(String.valueOf(1000)).setClassType(int.class.getName()).setCount(5);pRule.setParamFlowItemList(Collections.singletonList(item));ParamFlowRuleManager.loadRules(Collections.singletonList(pRule));
请参见视频
在开始之前,我们先了解一下系统保护的目的:
保证系统不被拖垮
在系统稳定的前提下,保持系统的吞吐量
长期以来,系统保护的思路是根据硬指标,即系统的负载 (load1) 来做系统过载保护。当系统负载高于某个阈值,就禁止或者减少流量的进入;当 load 开始好转,则恢复流量的进入。这个思路给我们带来了不可避免的两个问题:
load 是一个“结果”,如果根据 load 的情况来调节流量的通过率,那么就始终有延迟性。也就意味着通过率的任何调整,都会过一段时间才能看到效果。当前通过率是使 load 恶化的一个动作,那么也至少要过 1 秒之后才能观测到;同理,如果当前通过率调整是让 load 好转的一个动作,也需要 1 秒之后才能继续调整,这样就浪费了系统的处理能力。所以我们看到的曲线,总是会有抖动。
恢复慢。想象一下这样的一个场景(真实),出现了这样一个问题,下游应用不可靠,导致应用 RT 很高,从而 load 到了一个很高的点。过了一段时间之后下游应用恢复了,应用 RT 也相应减少。这个时候,其实应该大幅度增大流量的通过率;但是由于这个时候 load 仍然很高,通过率的恢复仍然不高。
系统保护的目标是 在系统不被拖垮的情况下,提高系统的吞吐率,而不是 load 一定要到低于某个阈值。如果我们还是按照固有的思维,超过特定的 load 就禁止流量进入,系统 load 恢复就放开流量,这样做的结果是无论我们怎么调参数,调比例,都是按照果来调节因,都无法取得良好的效果。
Sentinel 在系统自适应保护的做法是,用 load1 作为启动自适应保护的因子,而允许通过的流量由处理请求的能力,即请求的响应时间以及当前系统正在处理的请求速率来决定。
系统规则支持以下的模式:
Load 自适应(仅对 Linux/Unix-like 机器生效):系统的 load1 作为启发指标,进行自适应系统保护。当系统 load1 超过设定的启发值,且系统当前的并发线程数超过估算的系统容量时才会触发系统保护(BBR 阶段)。系统容量由系统的 maxQps * minRt
估算得出。设定参考值一般是 CPU cores * 2.5
。
CPU usage(1.5.0+ 版本):当系统 CPU 使用率超过阈值即触发系统保护(取值范围 0.0-1.0),比较灵敏。
平均 RT:当单台机器上所有入口流量的平均 RT 达到阈值即触发系统保护,单位是毫秒。
并发线程数:当单台机器上所有入口流量的并发线程数达到阈值即触发系统保护。
入口 QPS:当单台机器上所有入口流量的 QPS 达到阈值即触发系统保护。
系统保护规则是从应用级别的入口流量进行控制,从单台机器的 load、CPU 使用率、平均 RT、入口 QPS 和并发线程数等几个维度监控应用指标,让系统尽可能跑在最大吞吐量的同时保证系统整体的稳定性。
系统保护规则是应用整体维度的,而不是资源维度的,并且仅对入口流量生效。入口流量指的是进入应用的流量(EntryType.IN
),比如 Web 服务或 Dubbo 服务端接收的请求,都属于入口流量。
系统规则的参数说明:
highestSystemLoad 最大的 load1,参考值 -1 (不生效)
avgRt 所有入口流量的平均响应时间 -1 (不生效)
maxThread 入口流量的最大并发数 -1 (不生效)
qps 所有入口资源的 QPS -1 (不生效)
硬编码的方式定义流量控制规则如下:
List<SystemRule> srules = new ArrayList<>();SystemRule srule = new SystemRule();srule.setAvgRt(3000);srules.add(srule);SystemRuleManager.loadRules(srules);
很多时候,我们需要根据调用方来限制资源是否通过,这时候可以使用 Sentinel 的访问控制(黑白名单)的功能。黑白名单根据资源的请求来源(origin)限制资源是否通过,若配置白名单则只有请求来源位于白名单内时才可通过;若配置黑名单则请求来源位于黑名单时不通过,其余的请求通过。
调用方信息通过 ContextUtil.enter(resourceName, origin) 方法中的 origin 参数传入。
授权规则,即黑白名单规则(AuthorityRule)非常简单,主要有以下配置项:
resource:资源名,即限流规则的作用对象
limitApp:对应的黑名单/白名单,不同 origin 用 , 分隔,如 appA,appB
strategy:限制模式,AUTHORITY_WHITE 为白名单模式,AUTHORITY_BLACK 为黑名单模式,默认为白名单模式 比如我们希望控制对资源 test 的访问设置白名单,只有来源为 appA 和 appB 的请求才可通过,则可以配置如下白名单规则:
AuthorityRule rule = new AuthorityRule();rule.setResource("test");rule.setStrategy(RuleConstant.AUTHORITY_WHITE);rule.setLimitApp("appA,appB");AuthorityRuleManager.loadRules(Collections.singletonList(rule));
resource是sentinel中最重要的一个概念,sentinel通过资源来保护具体的业务代码或其他后方服务。sentinel把复杂的逻辑给屏蔽掉了,用户只需要为受保护的代码或服务定义一个资源,然后定义规则就可以了,剩下的通通交给sentinel来处理了。并且资源和规则是解耦的,规则甚至可以在运行时动态修改。定义完资源后,就可以通过在程序中埋点来保护你自己的服务了,埋点的方式有两种:
try-catch 方式(通过 SphU.entry(...)
),当 catch 到BlockException时执行异常处理(或fallback)
if-else 方式(通过 SphO.entry(...)
),当返回 false 时执行异常处理(或fallback)
以上这两种方式都是通过硬编码的形式定义资源然后进行资源埋点的,对业务代码的侵入太大,从0.1.1版本开始,sentinel加入了注解的支持,可以通过注解来定义资源,具体的注解为:SentinelResource 。通过注解除了可以定义资源外,还可以指定 blockHandler 和 fallback 方法。
在sentinel中具体表示资源的类是:ResourceWrapper ,他是一个抽象的包装类,包装了资源的 Name 和EntryType。他有两个实现类,分别是:StringResourceWrapper 和 MethodResourceWrapper。顾名思义,StringResourceWrapper 是通过对一串字符串进行包装,是一个通用的资源包装类,MethodResourceWrapper 是对方法调用的包装。
Context是对资源操作时的上下文环境,每个资源操作(针对Resource进行的entry/exit
)必须属于一个Context,如果程序中未指定Context,会创建name为"sentinel_default_context"的默认Context。一个Context生命周期内可能有多个资源操作,Context生命周期内的最后一个资源exit时会清理该Context,这也预示这整个Context生命周期的结束。Context主要属性如下:
public class Context {// context名字,默认名字 "sentinel_default_context"private final String name;// context入口节点,每个context必须有一个entranceNodeprivate DefaultNode entranceNode;// context当前entry,Context生命周期中可能有多个Entry,所有curEntry会有变化private Entry curEntry;// The origin of this context (usually indicate different invokers, e.g. service consumer name or origin IP).private String origin = "";private final boolean async;}
注意:一个Context生命期内Context只能初始化一次,因为是存到ThreadLocal中,并且只有在非null时才会进行初始化。
如果想在调用 SphU.entry() 或 SphO.entry() 前,自定义一个context,则通过ContextUtil.enter()方法来创建。context是保存在ThreadLocal中的,每次执行的时候会优先到ThreadLocal中获取,为null时会调用 MyContextUtil.myEnter(Constants.CONTEXT_DEFAULT_NAME, "", resourceWrapper.getType())
创建一个context。当Entry执行exit方法时,如果entry的parent节点为null,表示是当前Context中最外层的Entry了,此时将ThreadLocal中的context清空。
Context的创建与销毁
首先我们要清楚的一点就是,每次执行entry()方法,试图冲破一个资源时,都会生成一个上下文。这个上下文中会保存着调用链的根节点和当前的入口。
Context是通过ContextUtil创建的,具体的方法是trueEntry,代码如下:
protected static Context trueEnter(String name, String origin) {// 先从ThreadLocal中获取Context context = contextHolder.get();if (context == null) {// 如果ThreadLocal中获取不到Context// 则根据name从map中获取根节点,只要是相同的资源名,就能直接从map中获取到nodeMap<String, DefaultNode> localCacheNameMap = contextNameNodeMap;DefaultNode node = localCacheNameMap.get(name);if (node == null) {// 省略部分代码try {LOCK.lock();node = contextNameNodeMap.get(name);if (node == null) {// 省略部分代码// 创建一个新的入口节点node = new EntranceNode(new StringResourceWrapper(name, EntryType.IN), null);Constants.ROOT.addChild(node);// 省略部分代码}} finally {LOCK.unlock();}}// 创建一个新的Context,并设置Context的根节点,即设置EntranceNodecontext = new Context(node, name);context.setOrigin(origin);// 将该Context保存到ThreadLocal中去contextHolder.set(context);}return context;}
上面的代码中我省略了部分代码,只保留了核心的部分。从源码中还是可以比较清晰的看出生成Context的过程:
1.先从ThreadLocal中获取,如果能获取到直接返回,如果获取不到则继续第2步
2.从一个static的map中根据上下文的名称获取,如果能获取到则直接返回,否则继续第3步
3.加锁后进行一次double check,如果还是没能从map中获取到,则创建一个EntranceNode,并把该EntranceNode添加到一个全局的ROOT节点中去,然后将该节点添加到map中去(这部分代码在上述代码中省略了)
4.根据EntranceNode创建一个上下文,并将该上下文保存到ThreadLocal中去,下一个请求可以直接获取
那保存在ThreadLocal中的上下文什么时候会清除呢?从代码中可以看到具体的清除工作在ContextUtil的exit方法中,当执行该方法时,会将保存在ThreadLocal中的context对象清除,具体的代码非常简单,这里就不贴代码了。
那ContextUtil.exit方法什么时候会被调用呢?有两种情况:一是主动调用ContextUtil.exit的时候,二是当一个入口Entry要退出,执行该Entry的trueExit方法的时候,此时会触发ContextUtil.exit的方法。但是有一个前提,就是当前Entry的父Entry为null时,此时说明该Entry已经是最顶层的根节点了,可以清除context。
刚才在Context身影中也看到了Entry的出现,现在就谈谈Entry。每次执行 SphU.entry() 或 SphO.entry() 都会返回一个Entry,Entry表示一次资源操作,内部会保存当前invocation信息。在一个Context生命周期中多次资源操作,也就是对应多个Entry,这些Entry形成parent/child结构保存在Entry实例中,entry类CtEntry结构如下:
class CtEntry extends Entry {protected Entry parent = null;protected Entry child = null;protected ProcessorSlot<Object> chain;protected Context context;}public abstract class Entry implements AutoCloseable {private long createTime;private Node curNode;/*** {@link Node} of the specific origin, Usually the origin is the Service Consumer.*/private Node originNode;private Throwable error; // 是否出现异常protected ResourceWrapper resourceWrapper; // 资源信息}
Entry实例代码中出现了Node,这个又是什么东东呢 😦,咱们接着往下看:
Node(关于StatisticNode的讨论放到下一小节)默认实现类DefaultNode,该类还有一个子类EntranceNode;context有一个entranceNode属性,Entry中有一个curNode属性。
EntranceNode:该类的创建是在初始化Context时完成的(ContextUtil.trueEnter方法),注意该类是针对Context维度的,也就是一个context有且仅有一个EntranceNode。
DefaultNode:该类的创建是在NodeSelectorSlot.entry完成的,当不存在context.name对应的DefaultNode时会新建(new DefaultNode(resourceWrapper, null),对应resouce)并保存到本地缓存(NodeSelectorSlot中private volatile Map<String, DefaultNode> map);获取到context.name对应的DefaultNode后会将该DefaultNode设置到当前context的curEntry.curNode属性,也就是说,在NodeSelectorSlot中是一个context有且仅有一个DefaultNode。
看到这里,你是不是有疑问?为什么一个context有且仅有一个DefaultNode,我们的resouece跑哪去了呢,其实,这里的一个context有且仅有一个DefaultNode是在NodeSelectorSlot范围内,NodeSelectorSlot是ProcessorSlotChain中的一环,获取ProcessorSlotChain是根据Resource维度来的。总结为一句话就是:针对同一个Resource,多个context对应多个DefaultNode;针对不同Resource,(不管是否是同一个context)对应多个不同DefaultNode。这还没看明白 : (,好吧,我不bb了,上图吧:
public class DefaultNode extends StatisticNode {private ResourceWrapper id;/*** The list of all child nodes.* 子节点集合/private volatile Set childList = new HashSet<>();/** Associated cluster node.*/private ClusterNode clusterNode;}一个Resouce只有一个clusterNode,多个defaultNode对应一个clusterNode,如果defaultNode.clusterNode为null,则在ClusterBuilderSlot.entry中会进行初始化。同一个Resource,对应同一个ProcessorSlotChain,这块处理逻辑在lookProcessChain方法中,如下:ProcessorSlot lookProcessChain(ResourceWrapper resourceWrapper) {ProcessorSlotChain chain = chainMap.get(resourceWrapper);if (chain == null) {synchronized (LOCK) {chain = chainMap.get(resourceWrapper);if (chain == null) {// Entry size limit.if (chainMap.size() >= Constants.MAX_SLOT_CHAIN_SIZE) {return null;}chain = SlotChainProvider.newSlotChain();Map<ResourceWrapper, ProcessorSlotChain> newMap = newHashMap<ResourceWrapper, ProcessorSlotChain>(chainMap.size() + 1);newMap.putAll(chainMap);newMap.put(resourceWrapper, chain);chainMap = newMap;}}
StatisticNode中保存了资源的实时统计数据(基于滑动时间窗口机制),通过这些统计数据,sentinel才能进行限流、降级等一系列操作。StatisticNode属性如下:public class StatisticNode implements Node {/*** 秒级的滑动时间窗口(时间窗口单位500ms)*/private transient volatile Metric rollingCounterInSecond = newArrayMetric(SampleCountProperty.SAMPLE_COUNT,IntervalProperty.INTERVAL);/*** 分钟级的滑动时间窗口(时间窗口单位1s)*/private transient Metric rollingCounterInMinute = new ArrayMetric(60, 60 * 1000,false);/*** The counter for thread count.线程个数用户触发线程数流控*/private LongAdder curThreadNum = new LongAdder();}public class ArrayMetric implements Metric {private final LeapArray<MetricBucket> data;}public class MetricBucket {// 保存统计值private final LongAdder[] counters;// 最小rtprivate volatile long minRt;}
其中MetricBucket.counters数组大小为MetricEvent枚举值的个数,每个枚举对应一个统计项,比如PASS表示通过个数,限流可根据通过的个数和设置的限流规则配置count大小比较,得出是否触发限流操作,所有枚举值如下:
public enum MetricEvent {PASS, // Normal pass.BLOCK, // Normal block.EXCEPTION,SUCCESS,RT,OCCUPIED_PASS}
slot是另一个sentinel中非常重要的概念,sentinel的工作流程就是围绕着一个个插槽所组成的插槽链来展开的。需要注意的是每个插槽都有自己的职责,他们各司其职完好的配合,通过一定的编排顺序,来达到最终的限流降级的目的。默认的各个插槽之间的顺序是固定的,因为有的插槽需要依赖其他的插槽计算出来的结果才能进行工作。
但是这并不意味着我们只能按照框架的定义来,sentinel 通过 SlotChainBuilder 作为 SPI 接口,使得 Slot Chain 具备了扩展的能力。我们可以通过实现 SlotsChainBuilder 接口加入自定义的 slot 并自定义编排各个 slot 之间的顺序,从而可以给 sentinel 添加自定义的功能。
那SlotChain是在哪创建的呢?是在 CtSph.lookProcessChain() 方法中创建的,并且该方法会根据当前请求的资源先去一个静态的HashMap中获取,如果获取不到才会创建,创建后会保存到HashMap中。这就意味着,同一个资源会全局共享一个SlotChain。默认生成ProcessorSlotChain为:
// DefaultSlotChainBuilderpublic ProcessorSlotChain build() {ProcessorSlotChain chain = new DefaultProcessorSlotChain();chain.addLast(new NodeSelectorSlot());chain.addLast(new ClusterBuilderSlot());chain.addLast(new LogSlot());chain.addLast(new StatisticSlot());chain.addLast(new SystemSlot());chain.addLast(new AuthoritySlot());chain.addLast(new FlowSlot());chain.addLast(new DegradeSlot());return chain;
这里大概的介绍下每种Slot的功能职责:
NodeSelectorSlot
负责收集资源的路径,并将这些资源的调用路径,以树状结构存储起来,用于根据调用路径来限流降级;
ClusterBuilderSlot
则用于存储资源的统计信息以及调用者信息,例如该资源的 RT, QPS, thread count 等等,这些信息将用作为多维度限流,降级的依据;
StatisticsSlot
则用于记录,统计不同维度的 runtime 信息;
SystemSlot
则通过系统的状态,例如 load1 等,来控制总的入口流量;
AuthoritySlot
则根据黑白名单,来做黑白名单控制;
FlowSlot
则用于根据预设的限流规则,以及前面 slot 统计的状态,来进行限流;
DegradeSlot
则通过统计信息,以及预设的规则,来做熔断降级;
每个Slot执行完业务逻辑处理后,会调用fireEntry()方法,该方法将会触发下一个节点的entry方法,下一个节点又会调用他的fireEntry,以此类推直到最后一个Slot,由此就形成了sentinel的责任链。
下面我们就来详细研究下这些Slot的原理。
NodeSelectorSlot
是用来构造调用链的,具体的是将资源的调用路径,封装成一个一个的节点,再组成一个树状的结构来形成一个完整的调用链,NodeSelectorSlot
是所有Slot中最关键也是最复杂的一个Slot,这里涉及到以下几个核心的概念:
Resource
资源是 Sentinel 的关键概念。它可以是 Java 应用程序中的任何内容,例如,由应用程序提供的服务,或由应用程序调用的其它服务,甚至可以是一段代码。
只要通过 Sentinel API 定义的代码,就是资源,能够被 Sentinel 保护起来。大部分情况下,可以使用方法签名,URL,甚至服务名称作为资源名来标示资源。
简单来说,资源就是 Sentinel 用来保护系统的一个媒介。源码中用来包装资源的类是:com.alibaba.csp.sentinel.slotchain.ResourceWrapper
,他有两个子类:StringResourceWrapper
和 MethodResourceWrapper
,通过名字就知道可以将一段字符串或一个方法包装为一个资源。
打个比方,我有一个服务A,请求非常多,经常会被陡增的流量冲垮,为了防止这种情况,简单的做法,我们可以定义一个 Sentinel 的资源,通过该资源来对请求进行调整,使得允许通过的请求不会把服务A搞崩溃。
每个资源的状态也是不同的,这取决于资源后端的服务,有的资源可能比较稳定,有的资源可能不太稳定。那么在整个调用链中,Sentinel 需要对不稳定资源进行控制。当调用链路中某个资源出现不稳定,例如,表现为 timeout,或者异常比例升高的时候,则对这个资源的调用进行限制,并让请求快速失败,避免影响到其它的资源,最终导致雪崩的后果。
Context
上下文是一个用来保存调用链当前状态的元数据的类,每次进入一个资源时,就会创建一个上下文。相同的资源名可能会创建多个上下文。一个Context中包含了三个核心的对象:
1)当前调用链的根节点:EntranceNode
2)当前的入口:Entry
3)当前入口所关联的节点:Node
上下文中只会保存一个当前正在处理的入口Entry,另外还会保存调用链的根节点。需要注意的是,每次进入一个新的资源时,都会创建一个新的上下文。
Entry
每次调用 SphU#entry()
都会生成一个Entry入口,该入口中会保存了以下数据:入口的创建时间,当前入口所关联的节点,当前入口所关联的调用源对应的节点。Entry是一个抽象类,他只有一个实现类,在CtSph中的一个静态类:CtEntry
Node
节点是用来保存某个资源的各种实时统计信息的,他是一个接口,通过访问节点,就可以获取到对应资源的实时状态,以此为依据进行限流和降级操作。
可能看到这里,大家还是比较懵,这么多类到底有什么用,接下来就让我们更进一步,挖掘一下这些类的作用,在这之前,我先给大家展示一下他们之间的关系,如下图所示:
这里把几种Node的作用先大概介绍下:
节点 | 作用 |
---|---|
StatisticNode | 执行具体的资源统计操作 |
DefaultNode | 该节点持有指定上下文中指定资源的统计信息,当在同一个上下文中多次调用entry方法时,该节点可能下会创建有一系列的子节点。 另外每个DefaultNode中会关联一个ClusterNode |
ClusterNode | 该节点中保存了资源的总体的运行时统计信息,包括rt,线程数,qps等等,相同的资源会全局共享同一个ClusterNode,不管他属于哪个上下文 |
EntranceNode | 该节点表示一棵调用链树的入口节点,通过他可以获取调用链树中所有的子节点 |
当在一个上下文中多次调用了 SphU#entry() 方法时,就会创建一棵调用链树。具体的代码在entry方法中创建CtEntry对象时:
CtEntry(ResourceWrapper resourceWrapper, ProcessorSlot<Object> chain, Context context) {super(resourceWrapper);this.chain = chain;this.context = context;// 获取「上下文」中上一次的入口parent = context.getCurEntry();if (parent != null) {// 然后将当前入口设置为上一次入口的子节点((CtEntry)parent).child = this;}// 设置「上下文」的当前入口为该类本身context.setCurEntry(this);}
这里可能看代码没有那么直观,可以用一些图形来描述一下这个过程。
创建context
context的创建在上面已经分析过了,初始化的时候,context中的curEntry属性是没有值的,如下图所示:
创建Entry
每创建一个新的Entry对象时,都会重新设置context的curEntry,并将context原来的curEntry设置为该新Entry对象的父节点,如下图所示:
退出Entry
某个Entry退出时,将会重新设置context的curEntry,当该Entry是最顶层的一个入口时,将会把ThreadLocal中保存的context也清除掉,如下图所示:
上面的过程是构造了一棵调用链的树,但是这棵树只有树干,没有叶子,那叶子节点是在什么时候创建的呢?DefaultNode就是叶子节点,在叶子节点中保存着目标资源在当前状态下的统计信息。通过分析,我们知道了叶子节点是在NodeSelectorSlot的entry方法中创建的。具体的代码如下:
@Overridepublic void entry(Context context, ResourceWrapper resourceWrapper, Object obj, int count, Object... args) throws Throwable {// 根据「上下文」的名称获取DefaultNode// 多线程环境下,每个线程都会创建一个context,// 只要资源名相同,则context的名称也相同,那么获取到的节点就相同DefaultNode node = map.get(context.getName());if (node == null) {synchronized (this) {node = map.get(context.getName());if (node == null) {// 如果当前「上下文」中没有该节点,则创建一个DefaultNode节点node = Env.nodeBuilder.buildTreeNode(resourceWrapper, null);// 省略部分代码}// 将当前node作为「上下文」的最后一个节点的子节点添加进去// 如果context的curEntry.parent.curNode为null,则添加到entranceNode中去// 否则添加到context的curEntry.parent.curNode中去((DefaultNode)context.getLastNode()).addChild(node);}}// 将该节点设置为「上下文」中的当前节点// 实际是将当前节点赋值给context中curEntry的curNode// 在Context的getLastNode中会用到在此处设置的curNodecontext.setCurNode(node);fireEntry(context, resourceWrapper, node, count, args);}
上面的代码可以分解成下面这些步骤:
1)获取当前上下文对应的DefaultNode,如果没有的话会为当前的调用新生成一个DefaultNode节点,它的作用是对资源进行各种统计度量以便进行流控;
2)将新创建的DefaultNode节点,添加到context中,作为「entranceNode」或者「curEntry.parent.curNode」的子节点;
3)将DefaultNode节点,添加到context中,作为「curEntry」的curNode。
上面的第2步,不是每次都会执行。我们先看第3步,把当前DefaultNode设置为context的curNode,实际上是把当前节点赋值给context中curEntry的curNode,用图形表示就是这样:
多次创建不同的Entry,并且执行NodeSelectorSlot的entry方法后,就会变成这样一棵调用链树:
PS:这里图中的node0,node1,node2可能是相同的node,因为在同一个context中从map中获取的node是同一个,这里只是为了表述的更清楚所以用了不同的节点名。
保存子节点
上面已经分析了叶子节点的构造过程,叶子节点是保存在各个Entry的curNode属性中的。
我们知道context中只保存了入口节点和当前Entry,那子节点是什么时候保存的呢,其实子节点就是上面代码中的第2步中保存的。
下面我们来分析上面的第2步的情况:
第一次调用NodeSelectorSlot的entry方法时,map中肯定是没有DefaultNode的,那就会进入第2步中,创建一个node,创建完成后会把该节点加入到context的lastNode的子节点中去。我们先看一下context的getLastNode方法:
public Node getLastNode() {// 如果curEntry不存在时,返回entranceNode// 否则返回curEntry的lastNode,// 需要注意的是curEntry的lastNode是获取的parent的curNode,// 如果每次进入的资源不同,就会每次都创建一个CtEntry,则parent为null,// 所以curEntry.getLastNode()也为nullif (curEntry != null && curEntry.getLastNode() != null) {return curEntry.getLastNode();} else {return entranceNode;}}
代码中我们可以知道,lastNode的值可能是context中的entranceNode也可能是curEntry.parent.curNode,但是他们都是「DefaultNode」类型的节点,DefaultNode的所有子节点是保存在一个HashSet中的。
第一次调用getLastNode方法时,context中curEntry是null,因为curEntry是在第3步中才赋值的。所以,lastNode最初的值就是context的entranceNode。那么将node添加到entranceNode的子节点中去之后就变成了下面这样:
紧接着再进入一次,资源名不同,会再次生成一个新的Entry,上面的图形就变成下图这样:
此时再次调用context的getLastNode方法,因为此时curEntry的parent不再是null了,所以获取到的lastNode是curEntry.parent.curNode,在上图中可以很方便的看出,这个节点就是node0。那么把当前节点node1添加到lastNode的子节点中去,上面的图形就变成下图这样:
然后将当前node设置给context的curNode,上面的图形就变成下图这样:
假如再创建一个Entry,然后再进入一次不同的资源名,上面的图就变成下面这样:
至此NodeSelectorSlot的基本功能已经大致分析清楚了。
PS:以上的分析是基于每次执行SphU.entry(name)时,资源名都是不一样的前提下。如果资源名都一样的话,那么生成的node都相同,则只会再第一次把node加入到entranceNode的子节点中去,其他的时候,只会创建一个新的Entry,然后替换context中的curEntry的值。
NodeSelectorSlot的entry方法执行完之后,会调用fireEntry方法,此时会触发ClusterBuilderSlot的entry方法。
ClusterBuilderSlot的entry方法比较简单,具体代码如下:
@Overridepublic void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, Object... args) throws Throwable {if (clusterNode == null) {synchronized (lock) {if (clusterNode == null) {// Create the cluster node.clusterNode = Env.nodeBuilder.buildClusterNode();// 将clusterNode保存到全局的map中去HashMap<ResourceWrapper, ClusterNode> newMap = new HashMap<ResourceWrapper, ClusterNode>(16);newMap.putAll(clusterNodeMap);newMap.put(node.getId(), clusterNode);clusterNodeMap = newMap;}}}// 将clusterNode塞到DefaultNode中去node.setClusterNode(clusterNode);// 省略部分代码fireEntry(context, resourceWrapper, node, count, args);}
NodeSelectorSlot的职责比较简单,主要做了两件事:
一、为每个资源创建一个clusterNode,然后把clusterNode塞到DefaultNode中去
二、将clusterNode保持到全局的map中去,用资源作为map的key
PS:一个资源只有一个ClusterNode,但是可以有多个DefaultNode
StatisticSlot负责来统计资源的实时状态,具体的代码如下:
@Overridepublic void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, Object... args) throws Throwable {try {// 触发下一个Slot的entry方法fireEntry(context, resourceWrapper, node, count, args);// 如果能通过SlotChain中后面的Slot的entry方法,说明没有被限流或降级// 统计信息node.increaseThreadNum();node.addPassRequest();// 省略部分代码} catch (BlockException e) {context.getCurEntry().setError(e);// Add block count.node.increaseBlockedQps();// 省略部分代码throw e;} catch (Throwable e) {context.getCurEntry().setError(e);// Should not happennode.increaseExceptionQps();// 省略部分代码throw e;}}@Overridepublic void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {DefaultNode node = (DefaultNode)context.getCurNode();if (context.getCurEntry().getError() == null) {long rt = TimeUtil.currentTimeMillis() - context.getCurEntry().getCreateTime();if (rt > Constants.TIME_DROP_VALVE) {rt = Constants.TIME_DROP_VALVE;}node.rt(rt);// 省略部分代码node.decreaseThreadNum();// 省略部分代码}fireExit(context, resourceWrapper, count);}
代码分成了两部分,第一部分是entry方法,该方法首先会触发后续slot的entry方法,即SystemSlot、FlowSlot、DegradeSlot等的规则,如果规则不通过,就会抛出BlockException,则会在node中统计被block的数量。反之会在node中统计通过的请求数和线程数等信息。第二部分是在exit方法中,当退出该Entry入口时,会统计rt的时间,并减少线程数。
这些统计的实时数据会被后续的校验规则所使用,具体的统计方式是通过 滑动窗口
来实现的。
SystemSlot就是根据总的请求统计信息,来做流控,主要是防止系统被搞垮,具体的代码如下:
@Overridepublic void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,boolean prioritized, Object... args) throws Throwable {SystemRuleManager.checkSystem(resourceWrapper);fireEntry(context, resourceWrapper, node, count, prioritized, args);}
public static void checkSystem(ResourceWrapper resourceWrapper) throws BlockException {if (resourceWrapper == null) {return;}// Ensure the checking switch is on.if (!checkSystemStatus.get()) {return;}// for inbound traffic onlyif (resourceWrapper.getEntryType() != EntryType.IN) {return;}// total qpsdouble currentQps = Constants.ENTRY_NODE == null ? 0.0 : Constants.ENTRY_NODE.successQps();if (currentQps > qps) {throw new SystemBlockException(resourceWrapper.getName(), "qps");}// total threadint currentThread = Constants.ENTRY_NODE == null ? 0 : Constants.ENTRY_NODE.curThreadNum();if (currentThread > maxThread) {throw new SystemBlockException(resourceWrapper.getName(), "thread");}double rt = Constants.ENTRY_NODE == null ? 0 : Constants.ENTRY_NODE.avgRt();if (rt > maxRt) {throw new SystemBlockException(resourceWrapper.getName(), "rt");}// BBR算法// load. BBR algorithm.if (highestSystemLoadIsSet && getCurrentSystemAvgLoad() > highestSystemLoad) {if (!checkBbr(currentThread)) {throw new SystemBlockException(resourceWrapper.getName(), "load");}}// cpu usageif (highestCpuUsageIsSet && getCurrentCpuUsage() > highestCpuUsage) {throw new SystemBlockException(resourceWrapper.getName(), "cpu");}}
其中的Constants.ENTRY_NODE是一个全局的ClusterNode,该节点的值是在StatisticsSlot中进行统计的。
当前的统计值和系统配置的进行比较,各个维度超过范围抛BlockException
AuthoritySlot做的事也比较简单,主要是根据黑白名单进行过滤,只要有一条规则校验不通过,就抛出异常。
@Overridepublic void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args)throws Throwable {checkBlackWhiteAuthority(resourceWrapper, context);fireEntry(context, resourceWrapper, node, count, prioritized, args);}void checkBlackWhiteAuthority(ResourceWrapper resource, Context context) throws AuthorityException {// 通过监听来的规则集Map<String, Set<AuthorityRule>> authorityRules = AuthorityRuleManager.getAuthorityRules();if (authorityRules == null) {return;}// 根据资源名称获取相应的规则Set<AuthorityRule> rules = authorityRules.get(resource.getName());if (rules == null) {return;}for (AuthorityRule rule : rules) {// 黑名单白名单验证// 只要有一条规则校验不通过,就抛出AuthorityExceptionif (!AuthorityRuleChecker.passCheck(rule, context)) {throw new AuthorityException(context.getOrigin(), rule);}}}
FlowSlot主要是根据前面统计好的信息,与设置的限流规则进行匹配校验,如果规则校验不通过则进行限流,具体的代码如下:
@Overridepublic void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,boolean prioritized, Object... args) throws Throwable {checkFlow(resourceWrapper, context, node, count, prioritized);fireEntry(context, resourceWrapper, node, count, prioritized, args);}void checkFlow(ResourceWrapper resource, Context context, DefaultNode node, int count, boolean prioritized)throws BlockException {checker.checkFlow(ruleProvider, resource, context, node, count, prioritized);}
DegradeSlot主要是根据前面统计好的信息,与设置的降级规则进行匹配校验,如果规则校验不通过则进行降级,具体的代码如下:
@Overridepublic void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count,boolean prioritized, Object... args) throws Throwable {performChecking(context, resourceWrapper);fireEntry(context, resourceWrapper, node, count, prioritized, args);}void performChecking(Context context, ResourceWrapper r) throws BlockException {List<CircuitBreaker> circuitBreakers = DegradeRuleManager.getCircuitBreakers(r.getName());if (circuitBreakers == null || circuitBreakers.isEmpty()) {return;}for (CircuitBreaker cb : circuitBreakers) {if (!cb.tryPass(context)) {throw new DegradeException(cb.getRule().getLimitApp(), cb.getRule());}}}
Chain 是链条的意思,从build的方法可看出,ProcessorSlotChain 是一个链表,里面添加了很多个 Slot。都是 ProcessorSlot 的子类。具体的实现需要到 DefaultProcessorSlotChain 中去看。
public class DefaultProcessorSlotChain extends ProcessorSlotChain {AbstractLinkedProcessorSlot<?> first = new AbstractLinkedProcessorSlot<Object>() {@Overridepublic void entry(Context context, ResourceWrapper resourceWrapper, Object t, int count, boolean prioritized, Object... args)throws Throwable {super.fireEntry(context, resourceWrapper, t, count, prioritized, args);}@Overridepublic void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {super.fireExit(context, resourceWrapper, count, args);}};AbstractLinkedProcessorSlot<?> end = first;@Overridepublic void addFirst(AbstractLinkedProcessorSlot<?> protocolProcessor) {protocolProcessor.setNext(first.getNext());first.setNext(protocolProcessor);if (end == first) {end = protocolProcessor;}}@Overridepublic void addLast(AbstractLinkedProcessorSlot<?> protocolProcessor) {end.setNext(protocolProcessor);end = protocolProcessor;}/*** Same as {@link #addLast(AbstractLinkedProcessorSlot)}.** @param next processor to be added.*/@Overridepublic void setNext(AbstractLinkedProcessorSlot<?> next) {addLast(next);}@Overridepublic AbstractLinkedProcessorSlot<?> getNext() {return first.getNext();}@Overridepublic void entry(Context context, ResourceWrapper resourceWrapper, Object t, int count, boolean prioritized, Object... args)throws Throwable {first.transformEntry(context, resourceWrapper, t, count, prioritized, args);}@Overridepublic void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {first.exit(context, resourceWrapper, count, args);}}
DefaultProcessorSlotChain中有两个AbstractLinkedProcessorSlot类型的变量:first和end,这就是链表的头结点和尾节点。
创建DefaultProcessorSlotChain对象时,首先创建了首节点,然后把首节点赋值给了尾节点,可以用下图表示:
将第一个节点添加到链表中后,整个链表的结构变成了如下图这样:
将所有的节点都加入到链表中后,整个链表的结构变成了如下图所示:
这样就将所有的Slot对象添加到了链表中去了,每一个Slot都是继承自AbstractLinkedProcessorSlot。而AbstractLinkedProcessorSlot是一种责任链的设计,每个对象中都有一个next属性,指向的是另一个AbstractLinkedProcessorSlot对象。其实责任链模式在很多框架中都有,比如Netty中是通过pipeline来实现的。
知道了SlotChain是如何创建的了,那接下来就要看下是如何执行Slot的entry方法的了
从这里可以看到,从fireEntry方法中就开始传递执行entry了,这里会执行当前节点的下一个节点transformEntry方法,上面已经分析过了,transformEntry方法会触发当前节点的entry,也就是说fireEntry方法实际是触发了下一个节点的entry方法。
从最初的调用Chain的entry()方法,转变成了调用SlotChain中Slot的entry()方法。从 @SpiOrder(-10000)
知道,SlotChain中的第一个Slot节点是NodeSelectorSlot。
sentinel的限流降级等功能,主要是通过一个SlotChain实现的。在链式插槽中,有7个核心的Slot,这些Slot各司其职,可以分为以下几种类型:
一、进行资源调用路径构造的NodeSelectorSlot和ClusterBuilderSlot
二、进行资源的实时状态统计的StatisticsSlot
三、进行系统保护,限流,降级等规则校验的SystemSlot、AuthoritySlot、FlowSlot、DegradeSlot
后面几个Slot依赖于前面几个Slot统计的结果。至此,每种Slot的功能已经基本分析清楚了。
滑动窗口可以先拆为滑动跟窗口两个词,先介绍下窗口
,你可以这么理解,一段是时间就是窗口,比如说我们可以把这个1s认为是1个窗口
这个样子我们就能将1分钟就可以划分成60个窗口
了,这个没毛病吧。如下图我们就分成了60个窗口
(这个多了我们就画5个表示一下)
比如现在处于第1秒上,那1s那个窗口就是当前窗口
,就如下图中红框表示。
好了,窗口就介绍完了,现在在来看下滑动
,滑动很简单,比如说现在时间由第1秒变成了第2秒,就是从当前这个窗口---->下一个窗口就可以了,这个时候下一个窗口就变成了当前窗口,之前那个当前窗口就变成了上一个窗口,这个过程其实就是滑动。
好了,介绍完了滑动窗口,我们再来介绍下这个sentinel的滑动窗口的实现原理。
其实你要是理解了上面这个滑动窗口的意思,sentinel实现原理就简单了。
先是介绍下窗口中里面都存储些啥。也就是上面这个小框框都有啥。
它得有个开始时间吧,不然你怎么知道这个窗口是什么时候开始的
还得有个窗口的长度吧,不然你咋知道窗口啥时候结束,通过这个开始时间+窗口长度=窗口结束时间,就比如说上面的1s,间隔1s
最后就是要在这个窗口里面统计的东西,你总不能白搞些窗口,搞些滑动吧。所以这里就存储了一堆要统计的指标(qps,rt等等)
说完了这一个小窗口里面的东西,就得来说说是怎么划分这个小窗口,怎么管理这些小窗口的了,也就是我们的视野得往上提高一下了,不能总聚在这个小窗口上。
要知道有多少个小窗口,在sentinel中也就是sampleCount,比如说我们有60个窗口。
还有就是intervalInMs,这个intervalInMs是用来计算这个窗口长度的,intervalInMs/窗口数量= 窗口长度。也就是我给你1分钟,你给我分成60个窗口,这个时候窗口长度就是1s了,那如果我给你1s,你给我分2个窗口,这个时候窗口长度就是500毫秒了,这个1分钟,就是intervalInMs。
再就是存储这个窗口的容器(这里是数组),毕竟那么多窗口,还得提供计算当前时间窗口的方法等等
最后我们就来看看这个当前时间窗口是怎么计算的。
咱们就拿 60个窗口,这个60个窗口放在数组中,窗口长度是1s 来计算,看看当前时间戳的一个时间窗口是是在数组中哪个位置。
比如说当前时间戳是1609085401454 ms,算出秒 = 1609085401454 /1000(窗口长度)
在数组的位置 = 算出秒 %数组长度
我们再来计算下 某个时间戳对应窗口的起始时间,还是以1609085401454 来计算
窗口startTime = 1609085401454 - 1609085401454%1000(窗口长度)=454
这里1609085401454%1000(窗口长度) 能算出来它的毫秒值,也就是454 , 减去这个后就变成了1609085401000
好了,sentinel 滑动窗口原理就介绍完成了。
我们来介绍下使用这个滑动窗口都来统计啥
public enum MetricEvent {/*** Normal pass.*/PASS,// 通过/*** Normal block.*/BLOCK,// 拒绝的EXCEPTION,// 异常SUCCESS,//成功RT,// 耗时/*** Passed in future quota (pre-occupied, since 1.5.0).*/OCCUPIED_PASS}1234567891011121314151617
这是最基本的指标,然后通过这些指标,又可以计算出来比如说最大,最小,平均等等的一些指标。
我们先来看下这个窗口里面的统计指标的实现 MetricBucket
3.1 MetricBucket
这个MetricBucket 是由LongAdder数组组成的,一个LongAdder就是一个MetricEvent ,也就是第二小节里面的PASS ,BLOCK等等。
我们稍微看下就可以了
可以看到它在实例化的时候创建一个LongAdder 数据,个数就是那堆event的数量。这个LongAdder 是jdk8里面的原子操作类,你可以把它简单认为AtomicLong。然后下面就是一堆get 跟add 的方法了,这里我们就不看了。
接下来再来看看那这个窗口的实现WindowWrap类
3.2 WindowWrap
先来看下这个成员
窗口长度,窗口startTime ,指标统计的 都有了,下面的就没啥好看的了,我们再来看下的一个方法吧
就是判断某个时间戳是不是在这个窗口中
时间戳要大于等于窗口开始时间 && 小于这个结束时间。
接下来再来看下这个管理窗口的类LeapArray
3.3 LeapArray
看下它的成员, 窗口长度, 样本数sampleCount 也就是窗口个数, intervalInMs ,再就是窗口数组
看看它的构造方法
这个构造方法其实就是计算出来这个窗口长度,创建了窗口数组。
再来看下一个很重要的方法
|