1. Hystrix 简介
hystrix 是 springcloud 中的一个服务熔断组件,熔断即当服务出现故障之后,可以想电闸一样电流负载过大之后通过 “跳闸” 来切断电源,保证电路不会损坏。
而 hystrix 熔断器就是参考了这样的机制,来保证服务出现故障之后,就将其进行熔断、降级等操作来保护整个系统不会因为一个无法的问题出现服务雪崩效应。
2. 服务雪崩
服务雪崩:在微服务架构系统中,处理某个请求需要多个服务共同来完成时,多个服务之间会进行 链式调用,如果某个环节的服务宕机无法及时做出响应时,那么调用链中的所有服务等待响应结果的线程都要等待请求超时后才才能将线程回收,这样就大大增加了服务中线程全部被占用,无法及时将线程回收的问题,最终导致整个服务瘫痪;
3. Hystrix 核心概念
在 Hystrix
中有一些比较重要的概念:
-
服务降级(fallback):客户端在调用服务端的接口时,如果此接口处理时间较常,如果同一时间内有非常多的请求调用此接口,就会导致服务器繁忙,服务降级就是为了不让客户端等待,立刻返回一个友好提示;
-
当 程序运行异常、请求超时、服务熔断触发服务降级、线程池/信号量打满也会导致 这些情况都会触发服务降级;
-
-
服务熔断(break):服务熔断就相对于是电闸中的保险丝,当功率消耗异常时,保险丝就会导致电闸跳闸关闭,由此来保护电路的安全,服务熔断其实也是相同的作用,只不过针对的对象是服务,当被调用的服务达到最大可访问量时,直接拒绝其其他请求对该服务继续进行调用,然后使用服务降级的方法直接返回一个友好提示,避免调用服务的线程等待无法及时收回;
-
服务限流(flowlimit):顾名思义,就是对访问服务端的请求进行限制,例如秒杀等高并发的操作,进行大量的请求在某一刻同时访问服务,会对请求进行排队,依次访问服务;
4. 熔断器(断路器)原理
“ 断路器 ” 本身是一种开关装置,当某个无法单元发生故障之后,通过断路器的故障监控(类似熔断保险丝),向调用方法返回一个服务预期的、可以处理的预备响应(Fallback),而不是长时间的等待响应的返回或抛出调用方法无法处理的异常,这样就保证了服务调用方法的线程不会被长时间、不必要的占用无法及时的回收线程,从而避免了故障在分布式系统中蔓延,乃至引起整个系统的 雪崩;
断路器的本身是一个开关,将其打开后就无法再访问服务了,这其中使用了一个状态来进行标识,这个状态标识也就是所谓的开关了,如果状态为关闭状态,说明将要访问的服务是正常状态,可以正常进行访问,如果这个状态为打开状态,那么就说明将要访问的服务可能存在问题,就会直接执行熔断后的备选处理方法对访问进行响应;
这个断路器的状态初始初始状态是关闭的,那么如果要知道某个服务的状态,那么第一次请求其实还是会真正的访问到服务,如果这次的访问呈现异常状态,那么断路器的状态就会修改为打开状态,当然也不一定是第一次访问呈现异常就直接将状态切换为打开状态,阻止之后的流量进入该服务,可能会根据多次请求的异常概率来进行判断,超过某个值之后就会修改状态;
如果断路器的状态被打开之后,它并不是会一直是打开的状态,如上图所示,还有第三种状态为 “半开”,当断路器的开关打开之后,就会拦截所有访问服务的请求,执行备选方法,默认打开五秒后状态就会自动变更为半开状态;
在半开状态下,断路器还是会对请求进行拦截,但并不会拦截所有的请求了,他可能会让少量的流量进入,根据这些进入的流量呈现的状态来判断无法是否恢复正常,如果状态正常,那么 半开的状态就会修改为关闭,如果结果仍然是异常,那么重新修改为打开状态,经过默认的五秒之后再次变成打开状态,之后循环这个操作;
如果想要在请求发起之后,在当前服务中去拦截请求是一个很复杂的操作,那么就可以使用 AOP 的方式将拦截操作织入到请求发起之后,从而模拟出拦截请求的效果;
上图就是 Hystrix
断路器的基本工作原理,手写断路器可用通过 aop
来拦截消费者发起的远程调用服务,并对该服务的隔离器状态进行判断,每个服务都有一个专属的隔离器对象,记录的当前服务的状态、失败次数等信息;
模拟 Hystrix
断路器实现,大致需要如下这些步骤:
-
创建一个
spring boot
应用; -
引入
spring-boot-starter-web
依赖,让应用可以发起 web 请求进行模拟服务远程调用; -
引入
spring-boot-starter-aop
依赖,让应用可以通过aop
动态代理对远程调用请求进行拦截; -
创建自定义注解,用于标识哪些方法会发起远程调用请求,只要发起会发起远程服务调用就有一定的可能出现服务雪崩的现象,所以一般来所有对服务的远程调用都需要进行拦截,验证服务的状态,而是
hystrix
中则是采取的包扫描的方式,将所有feign
规范接口中的方法都进行了拦截,因为feign
接口中的所有都是要发起远程的调用; -
在
aspect
中定义一个通知方法,根据注解对指定方法进行增强,这里就是标注了自定义注解的方法,这些方法都要发起远程调用; -
定义一个枚举类,通过枚举类型标识断路器的三个状态(关闭、打开、半开);
-
定义断路器模型对象,该对象中存储服务的状态、调用失败次数等信息,每个服务对应一个不同的断路器对象,存储本服务的信息;
-
hystrix
是通过断路器实现对服务状态的校验,这也是hystrix
中的核心逻辑,当拦截到远程调用服务的请求之后,就会进入断路器中,根据Url
中包含服务名称,获取到该服务的状态信息,每个服务都对应一个断路器对象,该对象中出了服务的状态信息等; -
在这里断路器其实就是
aspect
切面中拦截请求的通知方法,手写基础拦截器的核心功能都定义在这个通知方法中;
1. 2. 3. 创建应用、导入依赖:
<dependencies>
<!-- spring-boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- aop -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
4. 定义自定义注解:
-
该主机的作用就是用于标识哪些方法会进行服务远程调用,让
aop
的通知方法可以精确的指定切点;
package com.biscuit.anno;
import java.lang.annotation.*;
/**
* 断路器切面注解,标注注解的方法将会被拦截
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface MyBiscuit {
}
5. 定义切面以及通知方法:
-
这里的通知采用
@Around
环绕通知,这也可以控制目标方法的执行时机,并且可以根据状态觉得是否要执行目标方法,也就是决定远程调用请求是否允许发起;
/**
* 使用注解选择切入点比较灵活,只需要在想要的方法上标注直接即可,使用 execution 表达式不灵活
* 这个方法的作用就相当于请求的拦截器
* 在方法中进行断路器状态的判断,决定是否真正发起服务远程调用
* @param joinPoint
* @return
*/
@Around(value = "@annotation(com.biscuit.anno.MyBiscuit)")
public Object biscuitAround(ProceedingJoinPoint joinPoint) {
}
}
6. 定义断路器状态枚举类:
package com.biscuit.model;
/**
* 断路器开关状态
*/
public enum BiscuitStatus {
CLOSE,
OPEN,
HALT_OPEN
}
7. 定义断路器数据模型对象:
-
该对象中有以下这些属性,用于存储服务的信息:
-
Integer WINDOW_TIME
:窗口时间,当断路器状态修改为打开时,等待多长时间变为半开状态,也是一个时间周期; -
Integer MAX_FAIL_COUNT
:一个窗口时间周期内,允许远程调用一个服务最大的失败次数; -
BiscuitStatus status = BiscuitStatus.CLOSE;
:当前服务的断路器打开状态,默认为关闭; -
AtomicInteger currentFailCount
:一个窗口时间周期内远程调用失败的次数,使用这个对象存储可以保证线程安全,其底层通过 CAS 自旋锁进行实习; -
ThreadPoolExecutor poolExecutor
:线程池,当断路器的状态修改打开状态时,需要开启一个定时任务,在一定时间之后将状态修改为半开状态,如果不使用线程池,那么就频繁的创建线程造成不必要的性能损耗,同时调用失败的次数也需要定期进行重置,也需要开启一个线程进行这个操作,所以这里使用线程池可以降低性能的损耗;
-
-
定义了
addFailCount()
方法,自增一次调用失败次数,并进行判断,如果超出定义的最大允许阈值,那么就将状态修改为打开,并且从线程池中取出一个线程,开启定时任务,在一定时间后将状态进行修改; -
定义了
{}
方法,当对象创建时就执行代码块,在代码快中开启了一个线程应用定期清空调用失败的次数,并且是死循环;
/**
* @author biscuit
* @create 2023年05月25日 15:34
* 断路器模型
* 每个服务都有单独的一个断路器对象,彼此互不影响
*/
@Data
public class Biscuit {
/**
* 窗口时间,状态切换为打开状态后,等待多长时间切换为半开状态
*/
public static final Integer WINDOW_TIME = 20;
/**
* 最大失败此时,调用三次失败后就将断路器打开
*/
public static final Integer MAX_FAIL_COUNT = 3;
/**
* 断路器的状态,默认为 close 关闭状态
*/
private BiscuitStatus status = BiscuitStatus.CLOSE;
/**
* 当前断路器以及失败了几次
* AtomicInteger 对象可以保证线程安全(CAS 自旋锁实现)
*/
private AtomicInteger currentFailCount = new AtomicInteger(0);
/**
* 线程锁
*/
private Object lock = new Object();
/**
* 线程池
*/
private ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
4,
8,
30,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(2000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
{
// 定期将失败次数清零,系统可容忍服务在一定时间内失败一定的次数,而如果在两个时间或多个周期中的次数相加超出阈值
// 断路器依然打开,那么显然是不合理的,所以需要定期重置失败的次数,只要在一个周期内失败次数不超出阈值
poolExecutor.execute(() -> {
// 定期重置调用失败的次数
while (true) {
try {
TimeUnit.SECONDS.sleep(WINDOW_TIME);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 当断路器为关闭状态时才不断的重置失败次数
if (this.getStatus().equals(BiscuitStatus.CLOSE)) {
// 失败次数清零
this.currentFailCount.set(0);
} else {
// 当状态为半开或打开时,让线程陷入等待,减少资源的浪费
synchronized (lock) {
try {
lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
});
}
/**
* 记录调用失败次数
* @return 返回 ture 标识失败次数不达到阈值,返回 false 表示失败次数超出阈值
*/
public boolean addFailCount() {
// 调用自带的 ++1 自增方法,遍返回 +1 后的值
int current = this.getCurrentFailCount().incrementAndGet();
// 判断已记录的次数是否超过最大的可容忍的失败次数
if (current > MAX_FAIL_COUNT) {
// 将断路器修改状态为打开状态
this.setStatus(BiscuitStatus.OPEN);
// 发起一个定时任务,等待多长时间之后,将断路器的打开状态修改为半开状态,这里采用开启一个新的线程,等待指定的时间后在运行
// 通过这种方式实现一个伪定时任务的实现,这样就不会影响到主线程继续执行了
// new Thread(() -> {}).start(); 这种方式每次都创建一个线程,比较浪费性能,这里采用线程池存储线程,随用随取
poolExecutor.execute(() -> {
try {
TimeUnit.SECONDS.sleep(WINDOW_TIME);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 修改断路器状态为半开并重置已经记录的失败次数
this.setStatus(BiscuitStatus.HALT_OPEN);
this.currentFailCount.set(0);
});
return false;
}
return true;
}
}
8. 断路器核心逻辑:
-
定义在
aspect
切面的一个通知方法中,因为是模拟所以不需要考虑太多的问题,只将核心的逻辑定义;
/**
* @author biscuit
* @create 2023年05月25日 15:24
*/
@Component
@Aspect
public class BiscuitAspect {
// public static final String POINT_CUT = "execution (* com.biscuit.controller.BiscuitController.doRPC(..))";
// 因为一个消费者可以去调用多个提供者,所以每个提供者都有自己的断路器,记录当前提供者的调用信息以及断路器状态
public static Map<String, Biscuit> biscuitMap = new HashMap<>();
private Random random = new Random();
static {
// 假设是当前只有一个断路器,是 order-service 的服务,真实情况应该是扫描注解,将所有所有需要被远程调用的服务都创建一个对应的断路器
// 也就是说,完整的情况是为每一个 Feign 接口都创建一个对应的断路器,记录被调用服务的相关信息
biscuitMap.put("order-service",new Biscuit());
}
/**
* 使用注解选择切入点比较灵活,只需要在想要的方法上标注直接即可,使用 execution 表达式不灵活
* 这个方法的作用就相当于请求的拦截器
* 在方法中进行断路器状态的判断,决定是否真正发起服务远程调用
* @param joinPoint
* @return
*/
@Around(value = "@annotation(com.biscuit.anno.MyBiscuit)")
public Object biscuitAround(ProceedingJoinPoint joinPoint) {
Object result = null;
// 这里就假设调用的 order-service 服务
Biscuit biscuit = biscuitMap.get("order-service");
// 判断该服务的断路器状态,根据状态进行不同的处理
BiscuitStatus status = biscuit.getStatus();
switch (status) {
case CLOSE:
// close 断路器关闭状态,未打开,正常进行服务远程调用(执行目标方法)
try {
result = joinPoint.proceed();
return result;
} catch (Throwable e) {
// 当目标方法执行异常时,也就说明调用的服务可能存在异常,对可容忍的次数 +1,当达到阈值时将断路器打开
biscuit.addFailCount();
return "断路器已打开,执行备选方案";
}
case OPEN:
// open 断路器打开状态,已打开,不能进行正常操作,直接调用备选方案对请求做出响应
// 并且重置定时任务将状态修改为半开的状态,也就是重新进行倒计时
return "断路器已打开,执行备选方案";
case HALT_OPEN:
// 可以放行少许流量去调用服务,这里采百分之 20 的概率,通过随机数实现
int ran = random.nextInt(5); // 0-4 的随机数
if (ran == 3) {
// 取其中一个,那么就是百分之20的概率
try {
// 尝试调用
result = joinPoint.proceed();
// 如果调用成功,那么将断路器状态从半开修改为关闭
biscuit.setStatus(BiscuitStatus.CLOSE);
// 同时向定期重置失败次数的线程重新开启运行(唤醒线程)
biscuit.getLock().notify();
return result;
} catch (Throwable e) {
biscuit.addFailCount();
return "断路器已打开,执行备选方案";
}
}
default:
return "断路器已打开,执行备选方案";
}
}
}
最后,启动应用发起远程调用请求测试:
-
这里远程调用的目标服务是没有开启的,所以他一直会是错误的状态;
-
调用
http://localhost:8080/doRPC
进行测试,调用该方法就会被aop
通知所拦截,因为添加了@Biscuit
注解;
成功拦截到 “远程调用请求”;
初次访问时,断路器默认是关闭状态,所以会正常的发起远程调用的请求;
远程调用异常,记录错误次数,并返回一个可以处理的响应信息;