本系列代码地址:https://github.com/JoJoTec/spring-cloud-parent
在前面一节,我们实现了 FeignClient 粘合 resilience4j 的 Retry 实现重试。细心的读者可能会问,为何在这里的实现,不把断路器和线程限流一起加上呢:
@Bean public FeignDecorators.Builder defaultBuilder( Environment environment, RetryRegistry retryRegistry ) { //获取微服务名称 String name = environment.getProperty("feign.client.name"); Retry retry = null; try { retry = retryRegistry.retry(name, name); } catch (ConfigurationNotFoundException e) { retry = retryRegistry.retry(name); } //覆盖其中的异常判断,只针对 feign.RetryableException 进行重试,所有需要重试的异常我们都在 DefaultErrorDecoder 以及 Resilience4jFeignClient 中封装成了 RetryableException retry = Retry.of(name, RetryConfig.from(retry.getRetryConfig()).retryOnException(throwable -> { return throwable instanceof feign.RetryableException; }).build()); return FeignDecorators.builder().withRetry( retry ); }主要原因是,这里增加断路器以及线程隔离,其粒度是微服务级别的,这样的坏处是:
微服务中只要有一个实例一直异常,整个微服务就会被断路
微服务只要有一个方法一直异常,整个微服务就会被断路
微服务的某个实例比较慢,其他实例正常,但是轮询的负载均衡模式导致线程池被这个实例的请求堵满。由于这一个慢实例,倒是整个微服务的请求都被拖慢
回顾我们想要实现的微服务重试、断路、线程隔离 请求重试来看几个场景:
1.在线发布服务的时候,或者某个服务出现问题下线的时候,旧服务实例已经在注册中心下线并且实例已经关闭,但是其他微服务本地有服务实例缓存或者正在使用这个服务实例进行调用,这时候一般会因为无法建立 TCP 连接而抛出一个 java.io.IOException,不同框架使用的是这个异常的不同子异常,但是提示信息一般有 connect time out 或者 no route to host。这时候如果重试,并且重试的实例不是这个实例而是正常的实例,就能调用成功。如下图所示:
2.当调用一个微服务返回了非 2XX 的响应码:
a) 4XX:在发布接口更新的时候,可能调用方和被调用方都需要发布。假设新的接口参数发生变化,没有兼容老的调用的时候,就会有异常,一般是参数错误,即返回 4XX 的响应码。例如新的调用方调用老的被调用方。针对这种情况,重试可以解决。但是为了保险,我们对于这种请求已经发出的,只重试 GET 方法(即查询方法,或者明确标注可以重试的非 GET 方法),对于非 GET 请求我们不重试。如下图所示:
b) 5XX:当某个实例发生异常的时候,例如连不上数据库,JVM Stop-the-world 等等,就会有 5XX 的异常。针对这种情况,重试也可以解决。同样为了保险,我们对于这种请求已经发出的,只重试 GET 方法(即查询方法,或者明确标注可以重试的非 GET 方法),对于非 GET 请求我们不重试。如下图所示:
3.断路器打开的异常:后面我们会知道,我们的断路器是针对微服务某个实例某个方法级别的,如果抛出了断路器打开的异常,请求其实并没有发出去,我们可以直接重试。
4.限流异常:后面我们会知道,我们给调用每个微服务实例都做了单独的线程池隔离,如果线程池满了拒绝请求,会抛出限流异常,针对这种异常也需要直接重试。
这些场景在线上在线发布更新的时候,以及流量突然到来导致某些实例出现问题的时候,还是很常见的。如果没有重试,用户会经常看到异常页面,影响用户体验。所以这些场景下的重试还是很必要的。对于重试,我们使用 resilience4j 作为我们整个框架实现重试机制的核心。
微服务实例级别的线程隔离再看下面一个场景: