关于spring-cloud:SpringCloud升级之路20200x版31-FeignClient-实现断路器以及线程隔离限流的思路

11次阅读

共计 7078 个字符,预计需要花费 18 分钟才能阅读完成。

本系列代码地址:https://github.com/JoJoTec/sp…

在后面一节,咱们实现了 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 作为咱们整个框架实现重试机制的外围

微服务实例级别的线程隔离

再看上面一个场景:

微服务 A 通过同一个线程池调用微服务 B 的所有实例。如果有一个实例有问题,阻塞了申请,或者是响应十分慢。那么长此以往,这个线程池会被发送到这个异样实例的申请而占满,然而实际上微服务 B 是有失常工作的实例的。

为了避免这种状况,也为了限度调用每个微服务实例的并发(也就是限流),咱们 应用不同线程池调用不同的微服务的不同实例 。这个也是 通过 resilience4j 实现 的。

微服务实例办法粒度的断路器

如果一个实例在一段时间内压力过大导致申请慢,或者实例正在敞开,以及实例有问题导致申请响应大多是 500,那么即便咱们有重试机制,如果很多申请都是依照申请到有问题的实例 -> 失败 -> 重试其余实例,这样效率也是很低的。这就须要应用 断路器

在理论利用中咱们发现,大部分异常情况下,是某个微服务的某些实例的某些接口有异样,而这些问题实例上的其余接口往往是可用的。所以咱们的断路器 不能间接将这个实例整个断路,更不能将整个微服务断路 。所以,咱们应用 resilience4j 实现的是 微服务实例办法级别 的断路器(即不同微服务,不同实例的不同办法是不同的断路器)

应用 resilience4j 的断路器和线程限流器

上面咱们先来看下断路器的相干配置,来了解下 resilience4j 断路器的原理:

CircuitBreakerConfig.java

// 判断一个异样是否记录为断路器失败,默认所有异样都是失败,这个相当于黑名单
private Predicate<Throwable> recordExceptionPredicate = throwable -> true;
// 判断一个返回对象是否记录为断路器失败,默认只有失常返回对象就不认为是失败
private transient Predicate<Object> recordResultPredicate = (Object object) -> false;
// 判断一个异样是否能够不认为是断路器失败,默认所有异样都是失败,这个相当于白名单
private Predicate<Throwable> ignoreExceptionPredicate = throwable -> false;
// 获取以后工夫函数
private Function<Clock, Long> currentTimestampFunction = clock -> System.nanoTime();
// 以后工夫的单位
private TimeUnit timestampUnit = TimeUnit.NANOSECONDS;
// 异样名单,指定一个 Exception 的 list,所有这个汇合中的异样或者这些异样的子类,在调用的时候被抛出,都会被记录为失败。其余异样不会被认为是失败,或者在 ignoreExceptions 中配置的异样也不会被认为是失败。默认是所有异样都认为是失败。private Class<? extends Throwable>[] recordExceptions = new Class[0];
// 异样白名单,在这个名单中的所有异样及其子类,都不会认为是申请失败,就算在 recordExceptions 中配置了这些异样也没用。默认白名单为空。private Class<? extends Throwable>[] ignoreExceptions = new Class[0];
// 失败申请百分比,超过这个比例,`CircuitBreaker` 就会变成 `OPEN` 状态,默认为 50%
private float failureRateThreshold = 50;
// 当 `CircuitBreaker` 处于 `HALF_OPEN` 状态的时候,容许通过的申请数量
private int permittedNumberOfCallsInHalfOpenState = 10;
// 滑动窗口大小,如果配置 `COUNT_BASED` 默认值 100 就代表是最近 100 个申请,如果配置 `TIME_BASED` 默认值 100 就代表是最近 100s 的申请。private int slidingWindowSize = 100;
// 滑动窗口类型,`COUNT_BASED` 代表是基于计数的滑动窗口,`TIME_BASED` 代表是基于计时的滑动窗口
private SlidingWindowType slidingWindowType = SlidingWindowType.COUNT_BASED;
// 最小申请个数。只有在滑动窗口内,申请个数达到这个个数,才会触发 `CircuitBreaker` 对于是否关上断路器的判断。private int minimumNumberOfCalls = 100;
// 对应 RuntimeException 的 writableStackTrace 属性,即生成异样的时候,是否缓存异样堆栈
// 断路器相干的异样都是继承 RuntimeException,这里对立指定这些异样的 writableStackTrace
// 设置为 false,异样会没有异样堆栈,然而会晋升性能
private boolean writableStackTraceEnabled = true;
// 如果设置为 `true` 代表是否主动从 `OPEN` 状态变成 `HALF_OPEN`,即便没有申请过去。private boolean automaticTransitionFromOpenToHalfOpenEnabled = false;
// 在断路器 OPEN 状态等待时间函数,默认是固定 60s,在期待与工夫后,会退出 OPEN 状态
private IntervalFunction waitIntervalFunctionInOpenState = IntervalFunction.of(Duration.ofSeconds(60));
// 当返回某些对象或者异样时,间接将状态转化为另一状态,默认是没有配置任何状态转换机制
private Function<Either<Object, Throwable>, TransitionCheckResult> transitionOnResult = any -> TransitionCheckResult.noTransition();
// 当慢调用达到这个百分比的时候,`CircuitBreaker` 就会变成 `OPEN` 状态
// 默认状况下,慢调用不会导致 `CircuitBreaker` 就会变成 `OPEN` 状态,因为默认配置是百分之 100
private float slowCallRateThreshold = 100;
// 慢调用工夫,当一个调用慢于这个工夫时,会被记录为慢调用
private Duration slowCallDurationThreshold = Duration.ofSeconds(60);
//`CircuitBreaker` 放弃 `HALF_OPEN` 的工夫。默认为 0,即放弃 `HALF_OPEN` 状态,直到 minimumNumberOfCalls 胜利或失败为止。private Duration maxWaitDurationInHalfOpenState = Duration.ofSeconds(0);

而后是线程隔离的相干配置:

ThreadPoolBulkheadConfig.java

// 以下五个参数对应 Java 线程池的配置,咱们这里就不再赘述了
private int maxThreadPoolSize = Runtime.getRuntime().availableProcessors();
private int coreThreadPoolSize = Runtime.getRuntime().availableProcessors();
private int queueCapacity = 100;
private Duration keepAliveDuration = Duration.ofMillis(20);
private RejectedExecutionHandler rejectedExecutionHandler = new ThreadPoolExecutor.AbortPolicy();
// 对应 RuntimeException 的 writableStackTrace 属性,即生成异样的时候,是否缓存异样堆栈
// 限流器相干的异样都是继承 RuntimeException,这里对立指定这些异样的 writableStackTrace
// 设置为 false,异样会没有异样堆栈,然而会晋升性能
private boolean writableStackTraceEnabled = true;
//Java 很多 Context 传递都基于 ThreadLocal,然而这里相当于切换线程了,某些工作须要维持上下文,能够通过实现 ContextPropagator 退出这里即可
private List<ContextPropagator> contextPropagators = new ArrayList<>();

在增加了上一节所说的 resilience4j-spring-cloud2 依赖之后,咱们能够这样配置断路器和线程隔离:

resilience4j.circuitbreaker:
  configs:
    default:
      registerHealthIndicator: true
      slidingWindowSize: 10
      minimumNumberOfCalls: 5
      slidingWindowType: TIME_BASED
      permittedNumberOfCallsInHalfOpenState: 3
      automaticTransitionFromOpenToHalfOpenEnabled: true
      waitDurationInOpenState: 2s
      failureRateThreshold: 30
      eventConsumerBufferSize: 10
      recordExceptions:
        - java.lang.Exception
resilience4j.thread-pool-bulkhead:
  configs:
    default:
      maxThreadPoolSize: 50
      coreThreadPoolSize: 10
      queueCapacity: 1000

如何实现微服务实例办法粒度的断路器

咱们要实现的是每个微服务的每个实例的每个办法都是不同的断路器,咱们须要拿到:

  • 微服务名
  • 实例 ID,或者能惟一标识一个实例的字符串
  • 办法名:能够是 URL 门路,或者是办法全限定名。

咱们这里 办法名采纳的是办法全限定名称,而不是 URL 门路,因为有些 FeignClient 将参数放在了门路下面,例如应用 @PathVriable,如果参数是相似于用户 ID 这样的,那么一个用户就会有一个独立的断路器,这不是咱们冀望的。所以采纳办法全限定名躲避这个问题。

那么在哪里能力获取到这些呢?回顾下 FeignClient 的外围流程,咱们发现 须要在理论调用的时候,负载均衡器调用实现之后,能力获取到实例 ID。也就是在 org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClient 调用实现之后。所以,咱们在这里植入咱们的断路器代码实现断路器。

另外就是配置粒度,能够每个 FeignClient 独自配置即可,不必到办法这一级别。举个例子如下:

resilience4j.circuitbreaker:
  configs:
    default:
      slidingWindowSize: 10
    feign-client-1:
      slidingWindowSize: 100

上面这段代码,contextId 即 feign-client-1 这种,不同的微服务实例办法 serviceInstanceMethodId 不同。如果 contextId 对应的配置没找到,就会抛出 ConfigurationNotFoundException,这时候咱们就读取并应用 default 配置。

try {circuitBreaker = circuitBreakerRegistry.circuitBreaker(serviceInstanceMethodId, contextId);
} catch (ConfigurationNotFoundException e) {circuitBreaker = circuitBreakerRegistry.circuitBreaker(serviceInstanceMethodId);
}

如何实现微服务实例线程限流器

对于线程隔离限流器,咱们只须要微服务名和实例 ID,同时这些线程池只做调用,所以其实和断路器一样,能够放在 org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClient 调用实现之后,植入线程限流器相干代码实现。

微信搜寻“我的编程喵”关注公众号,每日一刷,轻松晋升技术,斩获各种 offer

正文完
 0