断路器CircuitBreaker
2023年影响极大的真实生产故障:语雀崩了(2023.10.23)、阿里系大部分产品(2023.11.12)、阿里云产品控制台(2023.11)。
1. 微服务雪崩问题
1.1 雪崩问题概述
复杂分布式体系结构中的应用程序有数十个依赖关系,每个依赖关系在某些时候将不可避免地失败。
多个微服务之间调用的时候,假设微服务A调用微服务B和微服务C,微服务B和微服务C又调用其它的微服务,这就是所谓的"扇出"。如果扇出的链路上某个微服务的调用响应时间过长或者不可用,对微服务A的调用就会占用越来越多的系统资源,进而引起系统崩溃,所谓的"雪崩效应"。
对于高流量的应用来说,单一的后端依赖可能会导致所有服务器上的所有资源都在几秒钟内饱和。比失败更糟糕的是,这些应用程序还可能导致服务之间的延迟增加,备份队列,线程和其他系统资源紧张,导致整个系统发生更多的级联故障。这些都表示需要对故障和延迟进行隔离和管理,以便单个依赖关系的失败,不能取消整个应用程序或系统。
1.2 解决雪崩问题
禁止服务雪崩故障,需要将有问题的节点进行快速熔断(快速返回失败处理或者返回默认兜底数据【服务降级】)。 "断路器"本身是一种开关装置,当某个服务单元发生故障之后,通过断路器的故障监控(类似熔断保险丝),向调用方返回一个符合预期的、可处理的备选响应(FallBack),而不是长时间的等待或者抛出调用方无法处理的异常,这样就保证了服务调用方的线程不会被长时间、不必要地占用,从而避免了故障在分布式系统中的蔓延,乃至雪崩。
1.3 断路器的需求
- 服务熔断: 类比保险丝,闭合状态(CLOSE)可以使用, 达到最大访问后,直接拒绝状态(OPEN),此时服务调用方使用服务降级处理
- 服务降级: 服务器忙,请稍后再试。不让客户端等待并立刻返回一个友好提示
- 服务限流: 秒杀高并发等操作,严禁一窝蜂的过来拥挤,使用队列有序进行
- 服务限时: 在某个时间段开启,限制开启时才
- 服务预热: 服务强度进行递增而不是直接放开
- 近实时监控
- 问题处理
2. 断路器之Hystrix
Hystrix是一个用于处理分布式系统的延迟和容错的开源库,在分布式系统里,许多依赖不可避免的会调用失败,比如超时、异常等,Hystrix能够保证在一个依赖出问题的情况下,不会导致整体服务失败,避免级联故障,以提高分布式系统的弹性。 目前Hystrix已经停更,官方推荐使用Resilience4j。
2. 断路器之Circuit Breaker
CircuitBreaker的目的是保护分布式系统免受故障和异常,提高系统的可用性和健壮性。当一个组件或服务出现故障时,CircuitBreaker会迅速切换到开放OPEN状态(保险丝跳闸断电),阻止请求发送到该组件或服务从而避免更多的请求发送到该组件或服务。这可以减少对该组件或服务的负载,防止该组件或服务进一步崩溃,并使整个系统能够继续正常运行。同时,CircuitBreaker还可以提高系统的可用性和健壮性,因为它可以在分布式系统的各个组件之间自动切换,从而避免单点故障的问题。 Circuit Breaker只是一套规范和接口,落地实现者是Resilience4J和Spring Retry。
3. 断路器之Resilience4j
官网为:https://resilience4j.readme.io/docs/getting-started: Resilience4j是一个专为函数式编程设计的轻量级容错库。Resilience4j提供高阶函数(装饰器),以通过断路器、速率限制器、重试或隔板增强任何功能接口、lambda表达式或方法引用。您可以在任何函数式接口、lambda表达式或方法引用上堆善多个装饰器。优点是您可以选择您需要的装饰器,而没有其他选择。
Resilience4j2需要Java 17。Resilience4j目前提供了以下模块:
- resilience4j-circuitbreaker: 熔断
- resilience4j-ratelimiter: 限流
- resilience4j-bulkhead: 隔离
- resilience4j-retry: 自动重试(同步,异步)
- resilience4j-cache: 结果缓存
- resilience4j-timelimiter: 超时处理
相关中文文档地址:https://github.com/lmhmhl/Resilience4j-Guides-Chinese/blob/main/getting-start/index.md
4. Resilience4j之circuitbreaker(断路器)
4.1 断路器三大状态和转换
断路器通过有限状态机实现,有三个普通状态:关闭、开启、半开,还有两个特殊状态:禁用、强制开启。 当熔断器关闭时,所有的请求都会通过熔断器。
- 如果失败率超过设定的阈值,熔断器就会从关闭状态转换到打开状态,这时所有的请求都会被拒绝。
- 当经过一段时间后,熔断器会从打开状态转换到半开状态,这时仅有一定数量的请求会被放入,并重新计算失败率
- 如果失败率超过阈值,则变为打开状态,如果失败率低于阈值,则变为关闭状态
断路器使用滑动窗口来存储和统计调用的结果。你可以选择基于调用数量的滑动窗口或者基于时间的滑动窗口: - 基于访问数量的滑动窗口统计最近N次调用的返回结果,而基于时间的滑动窗口统计了最近N秒的调用
断路器额外还支持两种特殊的状态:总是允许访问(禁用DISABLED)和总是拒绝访问(强制开启FORCE_OPEN)。在这两种状态下,不会产生断路器事件(除了状态转换),也不会记录任何指标。退出这些状态的唯一方法是触发状态转换或重置断路器。
4.2 常见参数配置
failure-rate-threshold | 以百分比配置失败率峰值 |
---|---|
sliding-window-type | 断路器的滑动窗口期类型 可以基于"次数"(COUNT_BASED)或者"时间"(TIME_BASED)进行熔断,默认是COUNT_BASED。 |
sliding-window-size | 若COUNT_BASED,则10次调用中有50%失败(即5次)打开熔断断路器; 若为TIME_BASED则,此时还有额外的两个设置属性,含义为:在N秒内(sliding-window-size)100%(slow-call-rate-threshold)的请求超过N秒(slow-call-duration-threshold)打开断路器。 |
slowCallRateThreshold | 以百分比的方式配置,断路器把调用时间大于slowCallDurationThreshold的调用视为慢调用,当慢调用比例大于等于峰值时,断路器开启,并进入服务降级。 |
slowCallDurationThreshold | 配置调用时间的峰值,高于该峰值的视为慢调用。 |
permitted-number-of-calls-in-half-open-state | 运行断路器在HALF_OPEN状态下时进行N次调用,如果故障或慢速调用仍然高于阈值,断路器再次进入打开状态。 |
minimum-number-of-calls | 在每个滑动窗口期样本数,配置断路器计算错误率或者慢调用率的最小调用数。比如设置为5意味着,在计算故障率之前,必须至少调用5次。如果只记录了4次,即使4次都失败了,断路器也不会进入到打开状态。 |
wait-duration-in-open-state | 从OPEN到HALF_OPEN状态需要等待的时间 |
4.3 基于时间的滑动窗口
- 在cloud-provider-payment8001工程中创建PayCircuitController类:
@RestController
public class PayCircuitController {
//=========Resilience4j CircuitBreaker 的例子
@GetMapping(value = "/pay/circuit/{id}")
public String myCircuit(@PathVariable("id") Integer id) {
// 模拟报错
if(id == -4) throw new RuntimeException("----circuit id 不能负数");
// 模拟超时
if(id == 9999){
try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); }
}
return "Hello, circuit! inputId: "+id+" \t " + IdUtil.simpleUUID();
}
}
- 修改PayFeignApi类,增加/pay/circuit接口调用:
/**
* Resilience4j CircuitBreaker 的例子
* @param id
*/
@GetMapping(value = "/pay/circuit/{id}")
public String myCircuit(@PathVariable("id") Integer id);
- 修改cloud-consumer-feign-order80工程,添加相关依赖:
<!--resilience4j-circuitbreaker-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>
<!-- 由于断路保护等需要AOP实现,所以必须导入AOP包 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
- 新建cloud-consumer-feign-order80工程的application-circuit_cout.yml文件:
spring:
cloud:
openfeign:
# 开启circuitbreaker和分组激活
circuitbreaker:
enabled: true
group:
enabled: true
resilience4j:
circuitbreaker:
configs:
default:
failure-rate-threshold: 50 #设置50%的调用失败时打开断路器,超过失败请求百分⽐CircuitBreaker变为OPEN状态。
sliding-window-type: COUNT_BASED # 滑动窗口的类型
sliding-window-size: 6 #滑动窗⼝的⼤⼩配置COUNT_BASED表示6个请求,配置TIME_BASED表示6秒
minimum-number-of-calls: 6 #断路器计算失败率或慢调用率之前所需的最小样本(每个滑动窗口周期)。
# 如果minimumNumberOfCalls为10,则必须最少记录10个样本,然后才能计算失败率。如果只记录了9次调用,即使所有9次调用都失败,断路器也不会开启。
automatic-transition-from-open-to-half-open-enabled: true # 是否启用自动从开启状态过渡到半开状态,默认值为true。
# 如果启用,CircuitBreaker将自动从开启状态过渡到半开状态,并允许一些请求通过以测试服务是否恢复正常
wait-duration-in-open-state:
seconds: 5
permitted-number-of-calls-in-half-open-state: 2 #半开状态允许的最大请求数,默认值为10。在半开状态下,CircuitBreaker将允许最多permittedNumberOfCallsInHalfOpenState个请求通过,
# 如果其中有任何一个请求失败,CircuitBreaker将重新进入开启状态。
record-exceptions:
- java.lang.Throwable
instances:
cloud-payment-service:
base-config: default
server:
port: 80
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
application:
name: cloud-consumer-order
cloud:
consul:
host: localhost
port: 8500
discovery:
prefer-ip-address: true #优先使用服务ip进行注册
service-name: ${spring.application.name}
openfeign:
client:
config:
default:
connectTimeout: 3000
#读取超时时间
readTimeout: 3000
httpclient:
hc5:
enabled: true
compression:
response:
enabled: true
request:
enabled: true
mime-types: text/xml,application/xml,application/json
min-request-size: 2048
profiles:
active: circuit_count
logging:
level:
com:
rocket:
springcloud:
common:
apis:
PayFeignApi: debug
- 在cloud-consumer-feign-order80工程中,新建OrderCircuitController类
@RestController
@Slf4j
public class OrderCircuitController {
@Resource
private PayFeignApi payFeignApi;
@GetMapping(value = "/feign/pay/circuit/{id}")
@CircuitBreaker(name = "cloud-payment-service", fallbackMethod = "myCircuitFallback")
public ResultData myCircuitBreaker(@PathVariable("id") Integer id) {
return ResultData.success(payFeignApi.myCircuit(id));
}
// myCircuitFallback就是服务降级后的兜底处理方法
public ResultData myCircuitFallback(Integer id,Throwable t) {
// 这里是容错处理逻辑,返回备用结果
log.error("调用/feign/pay/circuit报错", t);
return ResultData.fail(ReturnCodeEnum.RC500.getCode(), "myCircuitFallback,系统繁忙,请稍后再试-----/(ㄒoㄒ)/~~");
}
}
- 测试(按照错误次数达到多少后开启短路)
通过调用错误,达到50%错误后触发熔断并给出服务降级,告知调用者服务不可用
4.4 基于时间的滑动窗口
基于时间的滑动窗口是通过有N个桶的环形数组实现。如果滑动窗口的大小为10秒,这个环形数组总是有10个桶,每个桶统计了在这一秒发生的所有调用的结果(部分统计结果),数组中的第一个桶存储了当前这一秒内的所有调用的结果,其他的桶存储了之前每秒调用的结果。
滑动窗口不会单独存储所有的调用结果,而是对每个桶内的统计结果和总的统计值进行增量的更新,当新的调用结果被记录时,总的统计值会进行增量更新。
检索快照(总的统计值)的时间复杂度为0(1),因为快照已经预先统计好了,并且和滑动窗口大小无关。关于此方法实现的空间需求(内存消耗)约等于0(n)。由于每次调用结果(元组)不会被单独存储,只是对N个桶进行单独统计和一次总分的统计。
每个桶在进行部分统计时存在三个整型,为了计算,失败调用数,慢调用数,总调用数。还有一个long类型变量,存储所有调用的响应时间。
- 在cloud-consumer-feign-order80工程中,新建application-circuit_time.yml配置:
spring:
cloud:
openfeign:
# 开启circuitbreaker和分组激活
circuitbreaker:
enabled: true
group:
enabled: true
# 按照时间:TIME_BASED 的例子
resilience4j:
timelimiter:
configs:
default:
# timelimiter 默认限制远程调用1s,超于1s就超时异常,配置了降级,就走降级逻辑,
# 为了避免干扰后续配置,手动设置为10s
timeout-duration:
seconds: 10
circuitbreaker:
configs:
default:
failure-rate-threshold: 50 #设置50%的调用失败时打开断路器,超过失败请求百分⽐CircuitBreaker变为OPEN状态。
# 慢调用时间阈值,高于这个阈值的视为慢调用并增加慢调用比例。
slow-call-duration-threshold:
seconds: 2
# 慢调用百分比峰值,断路器把调用时间⼤于slowCallDurationThreshold,视为慢调用,
# 当慢调用比例高于阈值,断路器打开,并开启服务降级
slow-call-rate-threshold: 30
sliding-window-type: TIME_BASED # 滑动窗口的类型
sliding-window-size: 2 #滑动窗口的大小配置,配置TIME_BASED表示2秒
minimum-number-of-calls: 2 #断路器计算失败率或慢调用率之前所需的最小样本(每个滑动窗口周期)。
permitted-number-of-calls-in-half-open-state: 2 #半开状态允许的最大请求数,默认值为10。
wait-duration-in-open-state:
seconds: 5s #从OPEN到HALF_OPEN状态需要等待的时间
record-exceptions:
- java.lang.Throwable
instances:
cloud-payment-service:
base-config: default
- 修改application.yml, 激活circuit_time配置
spring:
profiles:
active: circuit_time
- 测试
正常访问也受到了牵连,因为服务熔断短时间不能访问了:
总结:断路器开启或者关闭的条件
- 当满足一定的峰值和失败率达到一定条件后,断路器将会进入OPEN状态(保险丝跳闸),服务熔断。
- OPEN的时候,所有请求都不会调用主业务逻辑方法,而是直接走fallbackmetnod兜底背锅方法,服务降级。
- 一段时间之后,这个时候断路器会从OPEN进入到HALF OPEN半开状态,会放几个请求过去探探链路是否通?如成功,断路器会关闭CLOSE(类似保险丝闭合,恢复可用);否则继续开启,重复上述步骤。
5. Resilience4j之BulkHead(隔离)
bulkhead翻译为(船的)舱壁/(飞机的)隔板: Resilience4j中的bulkhead主要用来依赖隔离和负载保护,通过限制对于下游服务的最大并发数量,目前有两种实现方式:
SemaphoreBulkhead
使用了信号量FixedThreadPoolBulkhead
使用了有界队列和固定大小线程池
5.1 信号量舱壁(SemaphoreBulkhead)原理
当信号量有空闲时,进入系统的请求会直接获取信号量并开始业务处理。
当信号量全被占用时,接下来的请求将会进入阻塞状态,SemaphoreBulkhead提供了一个阻塞计时器,如果阻塞状态的请求在阻塞计时内无法获取到信号量则系统会拒绝这些请求。若请求在阻塞计时内获取到了信号量,那将直接获取信号量并执行相应的业务处理。
5.2 基于SemaphoreBulkhead编码配置
- 在cloud-provider-payment8001工程中,修改PayCircuitController,添加以下方法:
//=========Resilience4j BulkHead 的例子
@GetMapping(value = "/pay/bulkhead/{id}")
public String myBulkHead(@PathVariable("id") Integer id) {
// 模拟报错
if(id == -4) throw new RuntimeException("----bulkhead id 不能负数");
// 模拟超时
if(id == 9999){
try { TimeUnit.SECONDS.sleep(5); } catch (InterruptedException e) { e.printStackTrace(); }
}
return "Hello, bulkhead! inputId: "+id+" \t " + IdUtil.simpleUUID();
}
- PayFeignApi中新增舱壁方法:
/**
* Resilience4j Bulkhead 的例子
*/
@GetMapping(value = "/pay/bulkhead/{id}")
public String myBulkhead(@PathVariable("id") Integer id);
- 修改cloud-consumer-feign-order80工程,添加pom依赖:
<!--resilience4j-bulkhead-->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-bulkhead</artifactId>
</dependency>
- OrderCircuitController中新增支持舱壁隔离特性接口:
// (船的)舱壁,隔离
@GetMapping(value = "/consumer/pay/bulkhead/{id}")
@Bulkhead(name = "cloud-payment-service",fallbackMethod = "myBulkheadFallback",type = Bulkhead.Type.SEMAPHORE)
public String myBulkhead(@PathVariable("id") Integer id) {
return payFeignApi.myBulkhead(id);
}
public String myBulkheadFallback(Throwable t) {
return "myBulkheadFallback,隔板超出最大数量限制,系统繁忙,请稍后再试-----/(ㄒoㄒ)/~~";
}
- 测试代码
5.3 固定线程池舱壁(FixedThreadPoolBulkhead)原理
FixedThreadPoolBulkhead的功能与SemaphoreBulkhead一样也是用于限制并发执行的次数的,但是二者的实现原理存在差别而且表现效果也存在细微的差别。FixedThreadPoolBulkhead使用一个固定线程池和一个等待队列来实现舱壁。
当线程池中存在空闲时,则此时进入系统的请求将直接进入线程池开启新线程或使用空闲线程来处理请求。
当线程池中无空闲时时,接下来的请求将进入等待队列,若等待队列仍然无剩余空间时接下来的请求将直接被拒绝,在队列中的请求等待线程池出现空闲时,将进入线程池进行业务处理。
另外:ThreadPoolBulkhead只对CompletableFuture方法有效,所以我们必创建返回CompletableFuture类型的方法
5.4 基于FixedThreadPoolBulkhead编码配置
- 修改cloud-consumer-feign-order80工程,新增application-bulkhead_thread.yml文件:
spring:
cloud:
openfeign:
# 开启circuitbreaker和分组激活
circuitbreaker:
enabled: true
group:
# 设置为false 新启线程和原来主线程脱离
enabled: false
resilience4j:
timelimiter:
configs:
default:
# timelimiter 默认限制远程调用1s,超于1s就超时异常,配置了降级,就走降级逻辑,
# 为了避免干扰后续配置,手动设置为20s
timeout-duration:
seconds: 20
thread-pool-bulkhead:
configs:
default:
core-thread-pool-size: 1
max-thread-pool-size: 1
queue-capacity: 1
# 支持局部设置舱壁参数
# instances:
# cloud-payment-service:
# baseConfig: default
FixedThreadPoolBulkhead的底层就是通过ThreadPoolExecutor实现的。按照ThreadPoolExecutor工作机制,max线程数是包含core线程数的,当前线程数要超过core线程数目时,会将当前线程放到队列中,队列满了再申请创建线程,但申请线程数+core线程数不得超过max线程数。所以yml配置参数说明允许最多并发是2(max线程池1+队列1)。
2. 修改application.yml, 激活bulkhead_thread配置
spring:
profiles:
active: bulkhead_thread
- OrderCircuitController中新增支持线程池特性接口:
// (船的)舱壁,隔离,ThreadPool
@GetMapping(value = "/consumer/pay/bulkheadThread/{id}")
@Bulkhead(name = "cloud-payment-service",fallbackMethod = "myBulkheadPoolFallback",type = Bulkhead.Type.THREADPOOL)
public CompletableFuture<String> myBulkheadThread(@PathVariable("id") Integer id) {
log.info(Thread.currentThread().getName()+"\t"+"enter the method!!!");
try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
log.info(Thread.currentThread().getName()+"\t"+"exist the method!!!");
return CompletableFuture.supplyAsync(() -> payFeignApi.myBulkhead(id) + "\t" + " Bulkhead.Type.THREADPOOL");
}
public CompletableFuture<String> myBulkheadPoolFallback(Integer id,Throwable t) {
return CompletableFuture.supplyAsync(() -> "Bulkhead.Type.THREADPOOL,系统繁忙,请稍后再试-----/(ㄒoㄒ)/~~");
}
- 测试
6. Resilience4j之RateLimiter(限流)
限流 就是限制最大访问流量。系统能提供的最大并发是有限的,同时来的请求又太多,就需要限流。主要通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速,以保护应用系统,一旦达到限制速率则可以拒绝服务、排队或 等待、降级等处理。
6.1 常见的限流算法
- 漏斗算法(Leaky Bucket)
指的是一个固定容量的漏桶,按照设定常量固定速率流出水滴,类似医院打吊针,不管你源头流量多大,我设定匀速流出。如果流入水滴超出了桶的容量,则流入的水滴将会溢出了(被丢弃),而漏桶容量是不变的。缺点: 因为漏桶的漏出速率是固定的参数(桶大小和桶洞大小都固定,导致漏出速率固定),这就导致高峰和低谷的接口处理能力不变,从效率的角度来看,低谷的时候接口效率不高。
- 令牌桶算法(Token Bucket)
SpringCloud默认使用该算法,令牌桶算法中有一个固定容量的桶(Bucket),桶内会定期生成令牌(Token)。每个令牌代表允许发送一个请求。持有令牌的请求会被处理,如果没有令牌,表示当前请求超出了允许的流量,通常可以选择拒绝请求或进行排队。只要这些请求的总量没有超过桶的容量,允许在短时间内发送比平常速率更多的请求。 - 滚动时间窗口(tumbling time window)
允许固定数量的请求进入超过数量就拒绝或者排队,等下一个时间段进入。
由于是在一个时间间隔内进行限制,如果用户在上个时间间隔结束前请求(但没有超过限制),同时在当前时间间隔刚开始请求(同样没超过限制),在各自的时间窗口间隔内,滚动窗口都会允许访问。这就导致一个致命问题:如果高峰请求在两个滚动窗口之间发生,可能把系统过载,从而失去限流的作用。 - 滑动时间窗口(sliding time window)
滑动窗口算法是把固定时间长度进行划分并且随着时间间隔移动,移动方式为开始时间点变为时间列表中的第2个时间点,结束时间点增加一个时间点,不断重复,可以巧妙的避开计数器的临界点的问题,不会发生滚动窗口不能限流问题。前面使用断路保护就是应用的滑动时间窗口。
6.2 限流编码配置
- cloud-provider-payment8001工程中,调整PayCircuitController,新增接口:
//=========Resilience4j ratelimit 的例子
@GetMapping(value = "/pay/ratelimit/{id}")
public String myRatelimit(@PathVariable("id") Integer id) {
return "Hello, myRatelimit欢迎到来 inputId: "+id+" \t " + IdUtil.simpleUUID();
}
- PayFeignApi中创建调用接口:
/**
* Resilience4j Ratelimit 的例子
*/
@GetMapping(value = "/pay/ratelimit/{id}")
public String myRatelimit(@PathVariable("id") Integer id);
- 在cloud-consumer-feign-order80工程中,添加maven依赖:
<!--resilience4j-ratelimiter-->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-ratelimiter</artifactId>
</dependency>
- 在cloud-consumer-feign-order80工程中,创建application-ratelimiter.yml:
####resilience4j ratelimiter 限流的例子
resilience4j:
ratelimiter:
configs:
default:
limit-for-period: 2 #在一次刷新周期内,允许执行的最大请求数
limit-refresh-period:
seconds: 1 # 限流器每隔limitRefreshPeriod刷新一次,将允许处理的最大请求数量重置为limitForPeriod
timeout-duration:
seconds: 1 # 线程等待权限的默认等待时间
instances:
cloud-payment-service:
baseConfig: default
- 修改application.yml, 激活ratelimiter配置
spring:
profiles:
active: ratelimiter
- 测试