Java 中使用 Failsafe 实现容错

在本文中,我们将探索Failsafe库,并了解如何将其合并到我们的代码中,以使其对故障情况更具弹性。

什么是容错?
无论我们将应用程序构建得多么好,总会有可能出错的地方。通常,这些都是我们无法控制的——例如,调用不可用的远程服务。因此,我们必须构建我们的应用程序来容忍这些故障并为我们的用户提供最佳体验。

我们可以通过多种不同的方式对这些失败做出反应,具体取决于我们正在做什么以及出了什么问题。例如,如果我们正在调用一个我们知道间歇性中断的远程服务,我们可以重试并希望调用能够正常进行。或者我们可以尝试调用提供相同功能的不同服务。

还有一些方法可以构建我们的代码来避免这些情况。例如,限制对同一远程服务的并发调用数量将减少其负载。

依赖关系
在使用 Failsafe 之前,我们需要在构建中包含最新版本,在撰写本文时为3.3.2 。

如果我们使用 Maven,我们可以将其包含在pom.xml中:

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

或者,如果我们使用 Gradle,我们可以将其包含在build.gradle中:

i

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

此时,我们已准备好开始在我们的应用程序中使用它。

4. 使用故障保护执行操作
故障安全与策略的概念一起工作。每个策略都会确定它是否认为该操作失败以及它将如何对此做出反应。

1.确定失败
默认情况下,如果策略抛出任何Exception,则策略将认为操作失败。但是,我们可以将策略配置为仅处理我们感兴趣的一组确切的异常,可以通过类型或通过提供检查它们的 lambda 来实现:

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

我们还可以将它们配置为将我们的操作的特定结果视为失败,无论是作为精确值还是通过提供 lambda 来为我们检查:

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

默认情况下,策略始终将所有异常视为失败。如果我们添加对异常的处理,这将取代该行为,但添加对特定结果的处理将是对策略的异常处理的补充。此外,我们所有的句柄检查都是附加的——我们可以添加任意数量的检查,如果任何检查通过,策略将认为该操作失败。

2.制定政策
一旦我们制定了政策,我们就可以根据它们构建一个执行器。这是我们执行功能并获取结果的方法——无论是我们行动的实际结果还是通过我们的政策修改的结果。我们可以通过将所有策略传递到Failsafe.with()来做到这一点,或者我们可以使用compose()方法来扩展它:

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

我们可以按照任何顺序添加所需数量的策略。策略始终按照添加的顺序执行,每个策略都包含下一个策略。所以,上面的内容将是:


其中每一个都会对其所包装的策略或操作的异常或返回值做出适当的反应。这使我们能够根据需要采取行动。例如,上面的内容在所有重试中应用超时。我们可以交换它,将超时单独应用于每次尝试的重试。

3.执行动作
一旦我们制定了策略,Failsafe 就会向我们返回一个FailsafeExecutor实例。然后,这个实例有一组方法,我们可以使用它们来执行我们的操作,具体取决于我们想要执行的内容以及我们希望它如何返回。

执行操作的最直接方法是T get<T>(CheckedSupplier<T>)和void run(CheckedRunnable)。CheckedSupplier和CheckedRunnable都是函数式接口,这意味着如果需要,我们可以使用 lambda 或方法引用来调用这些方法。

它们之间的区别在于get()将返回操作的结果,而run()将返回void – 并且操作也必须返回void:

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

此外,我们有各种方法可以异步运行我们的操作,为我们的结果返回一个CompletableFuture 。但是,这些不属于本文的讨论范围。

故障安全策略
现在我们知道如何构建FailsafeExecutor来执行我们的操作,我们需要构建使用它的策略。 Failsafe 提供了多种标准策略。每个都使用构建器模式来使构建它们变得更容易。

1.后备政策
我们可以使用的最直接的策略是Fallback。此政策将使我们能够在连锁操作失败时提供新的结果。

使用它的最简单方法是简单地返回一个静态值:

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

在这种情况下,如果操作因任何原因失败,我们的策略将返回固定值“0”。

此外,我们可以使用CheckedRunnable或CheckedSupplier来生成替代值。根据我们的需求,这可能像在返回固定值之前写出日志消息一样简单,也可能像运行完全不同的执行路径一样复杂:

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));

2.重试策略
回退策略允许我们在操作失败时给出替代结果。与此相反,重试策略允许我们简单地再次尝试原始操作。

如果没有配置,此策略将调用该操作最多 3 次,并在成功时返回结果,或者在从未成功时抛出FailsafeException :

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

这已经非常有用,因为这意味着如果我们偶尔执行错误的操作,我们可以在放弃之前重试几次。

但是,我们可以进一步配置此行为。我们可以做的第一件事是使用withMaxAttempts()调用调整重试次数:

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

现在,这将执行该操作最多五次,而不是默认值。

我们还可以将其配置为在每次尝试之间等待固定的时间。这在短暂故障(例如网络故障)无法立即自行修复的情况下非常有用:

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 次故障后延迟 2,000 毫秒,逐渐增加干预故障的延迟。

3.超时策略
回退和重试策略可以帮助我们从操作中获得成功的结果,而超时策略则恰恰相反。如果我们调用的操作花费的时间比我们想要的时间长,我们可以使用它来强制失败。如果我们需要在某个操作花费太长时间的情况下失败,这可能是无价的。

当我们构建超时时,我们需要提供目标持续时间,在此之后操作将失败:

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

默认情况下,这将运行操作直至完成,如果所需时间超过我们提供的持续时间,则失败。

或者,我们可以将其配置为在达到超时时中断操作,而不是运行完整。当我们需要快速响应而不是仅仅因为速度太慢而失败时,这非常有用:

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

我们也可以有效地组合超时策略和重试策略。如果我们在重试之外设置超时,则超时时间将分布在所有重试中:

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);

这将尝试该操作五次,如果每次尝试花费的时间超过 500 毫秒,则每次尝试都会被取消。

4.舱壁政策
到目前为止,我们看到的所有策略都是关于控制应用程序对故障的反应方式。然而,我们也可以使用一些政策来首先减少失败的可能性。

隔离策略的存在是为了限制执行操作的并发次数。这可以减少外部服务的负载,因此有助于减少它们失败的机会。

当我们构造Bulkhead时,我们需要配置它支持的最大并发执行数:

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

默认情况下,当舱壁已满时,任何操作都会立即失败。

我们还可以将舱壁配置为在新操作进入时等待,如果容量可用,那么它将执行等待任务:

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

一旦容量可用,任务将按照执行顺序通过舱壁。一旦等待时间到期,任何必须等待超过此配置的等待时间的任务都将失败。然而,它们后面的其他任务可能会成功执行。

5.速率限制器策略
与舱壁类似,速率限制器有助于限制可能发生的操作的执行次数。然而,与仅跟踪当前正在执行的操作数量的隔板不同,速率限制器限制给定时间段内的操作数量。

故障安全为我们提供了两个可以使用的速率限制器——突发和平滑。

突发速率限制器使用固定时间窗口,并允许在此窗口中执行最大数量:

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 秒执行一次,而不是在一秒窗口内允许 100 次执行。任何比这更快的执行都会达到我们的等待时间,否则就会失败。

6.断路器政策
与大多数其他策略不同,我们可以使用断路器,因此如果操作被认为已经失败,我们的应用程序可能会快速失败。例如,如果我们正在调用远程服务并且知道它没有响应,那么尝试就没有意义 – 我们可能会立即失败,而无需先花费时间和资源。

断路器在三态系统中工作。默认状态为“关闭”,这意味着将尝试所有操作,就好像断路器不存在一样。然而,如果足够多的这些操作失败,断路器将转为“打开”。

Open 状态意味着不尝试任何操作,并且所有调用都将立即失败。在进入半开状态之前,断路器将保持这种状态一段时间。

半开放状态意味着尝试执行操作,但我们有不同的失败阈值来确定是转到“关闭”还是“打开”。

例如:

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

如果最后 10 个请求中有 7 次失败,此设置将从“关闭”变为“打开”;如果最后 10 个请求中有 4 次成功,则该设置将从“打开”变为“半开”;如果最后 10 个请求中有 4 个成功,则该设置将从“半开”变为“关闭”;或者返回到如果最近 5 个请求中有 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();

这样做可以让我们更快地调整加载。如果我们的负载非常低,我们可能根本不想检查故障,但是如果我们的负载非常高,那么发生故障的可能性就会增加,因此我们只想在负载超过阈值时才做出反应。