2. 什么是容错机制?

无论我们如何精心构建应用,总会有出错的可能。很多时候,这些错误超出了我们的控制范围 —— 比如调用的远程服务突然不可用。因此,我们必须构建能够容忍这些故障的应用程序,为用户提供最佳体验。

我们可以通过多种方式应对这些故障,具体取决于执行的操作和故障类型。例如,当我们调用一个已知存在间歇性故障的远程服务时,可以重试调用,期望能成功。或者,我们可以尝试调用另一个提供相同功能的服务。

还有一些代码结构设计可以避免这些情况。例如,限制对同一远程服务的并发调用数量,可以减轻其负载压力。

3. 依赖配置

在使用Failsafe之前,我们需要在构建文件中添加最新版本,本文写作时为3.3.2

如果使用Maven,在pom.xml中添加:

<dependency>
    <groupId>dev.failsafe</groupId>
    <artifactId>failsafe</artifactId>
    <version>3.3.2</version>
</dependency>

如果使用Gradle,在build.gradle中添加:

implementation("dev.failsafe:failsafe:3.3.2")

现在我们就可以在应用中使用Failsafe了。

4. 使用Failsafe执行操作

Failsafe基于策略(policy)概念工作。每个策略会判断操作是否失败,并定义如何响应失败。

4.1. 失败判定

默认情况下,策略将抛出任何Exception的操作视为失败。但我们可以配置策略只处理特定类型的异常,通过异常类型或lambda表达式检查:

policy
  .handle(IOException.class)
  .handleIf(e -> e instanceof IOException)

还可以配置策略将特定返回结果视为失败,通过具体值或lambda表达式检查:

policy
  .handleResult(null)
  .handleResultIf(result -> result < 0)

默认策略会将所有异常视为失败。如果添加了异常处理,会替换默认行为;而添加结果处理会作为异常处理的补充。所有检查条件是叠加的 —— 只要任一条件满足,策略就判定操作失败。

4.2. 组合策略

定义好策略后,可以构建执行器(executor)。这是执行功能并获取结果的手段 —— 可能是操作的实际结果,也可能是策略修改后的结果。 可以通过*Failsafe.with()传入所有策略,或使用compose()*方法扩展:

Failsafe.with(defaultFallback, npeFallback, ioFallback)
  .compose(timeout)
  .compose(retry);

可按需添加任意数量的策略,策略按添加顺序执行,每个策略包裹下一个策略。上述代码的执行顺序如下:

fail safe policy stack

每个策略会根据被包裹策略或操作的异常/返回值做出适当响应。例如,上述代码将超时策略应用于所有重试尝试。我们也可以调整顺序,使超时策略单独应用于每次重试。

4.3. 执行操作

组合策略后,Failsafe返回一个FailsafeExecutor实例。该实例提供多种方法执行操作,具体取决于要执行的内容和期望的返回方式。

最直接的执行方法是T get(CheckedSupplier)void run(CheckedRunnable)CheckedSupplierCheckedRunnable都是函数式接口,因此可以使用lambda或方法引用调用。

区别在于:get()返回操作结果,而run()返回void —— 且操作本身也必须返回void

Failsafe.with(policy).run(this::runSomething);
var result = Failsafe.with(policy).get(this::doSomething);

此外,还有多种异步执行方法,返回CompletableFuture。但本文不涉及异步内容。

5. Failsafe策略详解

现在我们了解了如何构建FailsafeExecutor执行操作,接下来需要构建具体策略。Failsafe提供了多种标准策略,均采用建造者模式简化构建过程。

5.1. 降级策略

最简单的策略是Fallback(降级)。当链式操作失败时,该策略允许提供替代结果。

最简单的用法是返回静态值:

Fallback<Integer> policy = Fallback.builder(0).build();

此策略在操作因任何原因失败时返回固定值"0"。

也可以使用CheckedRunnableCheckedSupplier生成替代值。根据需求,可能只是记录日志后返回固定值,也可能执行完全不同的逻辑:

Fallback<Result> backupService = Fallback.of(this::callBackupService)
  .build();

Result result = Failsafe.with(backupService)
  .get(this::callPrimaryService);

这里会先执行*callPrimaryService()。失败时自动执行callBackupService()*尝试获取结果。

最后,可以使用*Fallback.ofException()*在失败时抛出特定异常。这能将所有配置的失败原因统一为单个预期异常:

Fallback<Result> throwOnFailure = Fallback.ofException(e -> new OperationFailedException(e));

5.2. 重试策略

Fallback提供替代结果不同,Retry策略允许简单重试原始操作。

无配置时,该策略最多调用操作3次,成功则返回结果,否则抛出FailsafeException

RetryPolicy<Object> retryPolicy = RetryPolicy.builder().build();

这已很有用 —— 对偶发性故障的操作,可以在放弃前重试几次。

但我们可以进一步配置行为。首先使用*withMaxAttempts()*调整重试次数:

RetryPolicy<Object> retryPolicy = RetryPolicy.builder()
  .withMaxAttempts(5)
  .build();

现在最多执行5次操作(默认3次)。

还可以配置每次重试间的固定延迟。这对短暂性故障(如网络抖动)很有用:

RetryPolicy<Object> retryPolicy = RetryPolicy.builder()
  .withDelay(Duration.ofMillis(250))
  .build();

更复杂的变体是*withBackoff()*,配置递增延迟:

RetryPolicy<Object> retryPolicy = RetryPolicy.builder()
  .withMaxAttempts(20)
  .withBackoff(Duration.ofMillis(100), Duration.ofMillis(2000))
  .build();

首次失败后延迟100毫秒,第20次失败后延迟2000毫秒,中间失败延迟逐渐增加。

5.3. 超时策略

FallbackRetry帮助获取成功结果不同,Timeout策略相反 —— 当操作耗时过长时强制失败。 当需要因操作过慢而失败时,这非常有用。

构建Timeout时需指定目标持续时间,超过该时间操作将失败:

Timeout<Object> timeout = Timeout.builder(Duration.ofMillis(100)).build();

默认情况下,操作会执行完成,然后检查是否超时。

也可配置在超时时中断操作,而非等待完成:

Timeout<Object> timeout = Timeout.builder(Duration.ofMillis(100))
  .withInterrupt()
  .build();

Timeout策略可与Retry策略组合使用。如果超时策略包裹重试策略,超时时间将覆盖所有重试:

Timeout<Object> timeoutPolicy = Timeout.builder(Duration.ofSeconds(10))
  .withInterrupt()
  .build();
RetryPolicy<Object> retryPolicy = RetryPolicy.builder()
  .withMaxAttempts(20)
  .withBackoff(Duration.ofMillis(100), Duration.ofMillis(2000))
  .build();

Failsafe.with(timeoutPolicy, retryPolicy).get(this::perform);

这会最多尝试20次操作,每次尝试间延迟递增,但总执行时间超过10秒则放弃。

反之,如果重试策略包裹超时策略,每次单独尝试都有超时限制:

Timeout<Object> timeoutPolicy = Timeout.builder(Duration.ofMillis(500))
  .withInterrupt()
  .build();
RetryPolicy<Object> retryPolicy = RetryPolicy.builder()
  .withMaxAttempts(5)
  .build();

Failsafe.with(retryPolicy, timeoutPolicy).get(this::perform);

这会尝试5次操作,每次超过500毫秒则取消。

5.4. 隔离策略

目前看到的策略都是控制应用如何响应故障。但有些策略可以从源头减少故障发生概率

隔离策略(Bulkhead)用于限制操作的并发执行次数。这能减轻外部服务负载,从而降低其故障概率。

构建Bulkhead时需配置最大并发执行数:

Bulkhead<Object> bulkhead = Bulkhead.builder(10).build();

默认情况下,当隔离舱已满时,新操作会立即失败。

也可配置隔离舱让新操作等待,有空闲容量时执行等待的任务:

Bulkhead<Object> bulkhead = Bulkhead.builder(10)
  .withMaxWaitTime(Duration.ofMillis(1000))
  .build();

任务按执行顺序通过隔离舱,一旦有空闲容量立即执行。等待时间超过配置值的任务会失败,但后续任务可能成功执行。

5.5. 限流策略

与隔离策略类似,限流策略(Rate Limiter)也限制操作执行次数。但隔离策略只跟踪当前执行的操作数,而限流策略限制单位时间内的操作数。

Failsafe提供两种限流器:突发型(bursty)和平滑型(smooth)。

突发型限流器使用固定时间窗口,允许窗口内最多执行次数:

RateLimiter<Object> rateLimiter = RateLimiter.burstyBuilder(100, Duration.ofSeconds(1))
  .withMaxWaitTime(Duration.ofMillis(200))
  .build();

此配置允许每秒执行100次操作。配置了等待时间,操作会阻塞直到执行或失败。称为"突发型"是因为计数在窗口结束时重置,允许操作突然重新开始执行。

特别是配置等待时间后,所有阻塞的操作在限流窗口结束时都会突然执行。

平滑型限流器则将执行分散到整个时间窗口:

RateLimiter<Object> rateLimiter = RateLimiter.smoothBuilder(100, Duration.ofSeconds(1))
  .withMaxWaitTime(Duration.ofMillis(200))
  .build();

配置类似,但执行会被平滑分布。即不是1秒内允许100次执行,而是每1/100秒允许1次执行。超过此速率的操作会触发等待或失败。

5.6. 熔断策略

与其他策略不同,熔断器(Circuit Breaker)让应用在操作被认为已失败时快速失败。 例如,调用远程服务时若已知其无响应,则无需尝试 —— 可立即失败,避免浪费时间和资源。

熔断器采用三态系统。默认状态为关闭(Closed),所有操作正常执行。但当足够多的操作失败时,熔断器转为开启(Open)状态。

开启状态不执行任何操作,所有调用立即失败。熔断器保持此状态一段时间后转为半开(Half-Open)状态。

半开状态会尝试执行操作,但使用不同的失败阈值决定转为关闭还是开启状态。

例如:

CircuitBreaker<Object> circuitBreaker = CircuitBreaker.builder()
  .withFailureThreshold(7, 10)
  .withDelay(Duration.ofMillis(500))
  .withSuccessThreshold(4, 5)
  .build();

此配置在最近10次请求中7次失败时转为开启状态;500毫秒后转为半开状态;最近5次请求中4次成功时转为关闭状态,2次失败则转回开启状态。

也可配置基于时间的失败阈值。例如,30秒内5次失败则开启熔断:

CircuitBreaker<Object> circuitBreaker = CircuitBreaker.builder()
  .withFailureThreshold(5, Duration.ofSeconds(30))
  .build();

还可配置为请求百分比而非固定次数。例如,5分钟内至少100次请求中失败率达20%则开启熔断:

CircuitBreaker<Object> circuitBreaker = CircuitBreaker.builder()
  .withFailureRateThreshold(20, 100, Duration.ofMinutes(5))
  .build();

这能更快适应负载变化。低负载时可能无需检查故障,高负载时故障概率增加,只在超过阈值时才触发熔断。

6. 总结

本文全面介绍了Failsafe库。该库功能远不止于此,不妨亲自尝试探索更多可能性!


原始标题:Fault Tolerance in Java Using Failsafe | Baeldung