使用 Resilience4j 实现重试

在本文中,我们将首先简要介绍 Resilience4j,然后深入研究其重试模块。我们将了解何时以及如何使用它,以及它提供哪些功能.

什么是 Resilience4j?

应用程序通过网络通信时,许多事情都可能出错。由于连接中断、网络故障、上游服务不可用等原因,操作可能会超时或失败。应用程序可能会相互过载、无响应,甚至崩溃。

Resilience4j 是一个 Java 库,可帮助我们构建具有弹性和容错能力的应用程序。它提供了一个用于编写代码以防止和处理此类问题的框架。

Resilience4j 是针对 Java 8 及更高版本编写的,适用于功能接口、lambda 表达式和方法引用等构造。

Resilience4j 模块

让我们快速了解一下这些模块及其用途:

模块 目的
Retry 自动重试失败的远程操作
RateLimiter 限制在一定时间内调用远程操作的次数
TimeLimiter 设置调用远程操作的时间限制
Circuit Breaker 当远程操作持续失败时快速失败或执行默认操作
Bulkhead 限制并发远程操作的数量
Cache 存储昂贵的远程操作的结果

使用方式

虽然每个模块都有其抽象,但一般的使用模式如下:

  1. 创建 Resilience4j 配置对象
  2. 为此类配置创建一个 Registry 对象
  3. 从注册表创建或获取 Resilience4j 对象
  4. 将远程操作编码为 lambda 表达式、函数接口或通常的 Java 方法
  5. 使用提供的辅助方法之一,为步骤 4 中的代码创建装饰器或包装器
  6. 调用装饰器方法调用远程操作

步骤 1-5 通常在应用程序启动时执行一次。让我们看看重试模块的这些步骤:

RetryConfig config = RetryConfig.ofDefaults(); // ----> 1
RetryRegistry registry = RetryRegistry.of(config); // ----> 2
Retry retry = registry.retry("flightSearchService", config); // ----> 3

FlightSearchService searchService = new FlightSearchService();
SearchRequest request = new SearchRequest("NYC", "LAX", "07/21/2020");
Supplier> flightSearchSupplier = 
  () -> searchService.searchFlights(request); // ----> 4

Supplier> retryingFlightSearch = 
  Retry.decorateSupplier(retry, flightSearchSupplier); // ----> 5

System.out.println(retryingFlightSearch.get()); // ----> 6

何时使用重试?

远程操作可以是通过网络发出的任何请求。通常,它是以下之一: 

  1. 向 REST 端点发送 HTTP 请求
  2. 调用远程过程 (RPC) 或 Web 服务
  3. 从数据存储(SQL/NoSQL 数据库、对象存储等)读取和写入数据
  4. 向消息代理(RabbitMQ/ActiveMQ/Kafka 等)发送消息和从消息代理接收消息

当远程操作失败时,我们有两个选择 - 立即向客户端返回错误,或重试该操作。如果重试成功,这对客户端来说很棒 - 他们甚至不必知道这是一个临时问题。

选择哪个选项取决于错误类型(暂时或永久)、操作(幂等或非幂等)、客户端(人员或应用程序)和用例。

暂时性错误是暂时的,通常情况下,如果重试,操作很可能会成功。例如,请求被上游服务限制、连接中断或由于某些服务暂时不可用而导致超时。

硬件故障或 REST API 的 404(未找到)响应是永久性错误的示例,重试不会有帮助。

如果我们想要应用重试,则操作必须是幂等的。假设远程服务收到并处理了我们的请求,但在发送响应时出现问题。在这种情况下,当我们重试时,我们不希望服务将该请求视为新请求或返回意外错误(想想银行的汇款)。
重试会增加 API 的响应时间。如果客户端是另一个应用程序(如 cron 作业或守护进程),这可能不是问题。但是,如果是人,有时最好是响应迅速、快速失败并给出反馈,而不是让对方在我们不断重试时等待。

对于某些关键用例,可靠性可能比响应时间更重要,即使客户端是人,我们也可能需要实施重试。银行转账或旅行社预订航班和酒店就是很好的例子 - 对于此类用例,用户期望的是可靠性,而不是即时响应。我们可以立即通知用户我们已接受他们的请求,并在请求完成后通知他们,从而做出响应。


使用 Resilience4j 重试模块


RetryRegistry、RetryConfig和是resilience4j-retryRetry中的主要抽象。是用于创建和管理对象的工厂。封装了诸如应尝试重试多少次、两次尝试之间等待多长时间等配置。每个对象都与一个相关联。提供辅助方法来为包含远程调用的功能接口或 lambda 表达式创建装饰器。RetryRegistryRetryRetryConfigRetryRetryConfigRetry


让我们看看如何使用重试模块中提供的各种功能。假设我们正在为一家航空公司建立一个网站,让其客户可以搜索和预订航班。我们的服务与类封装的远程服务进行对话FlightSearchService。

简单重试
在简单重试中,如果在远程调用期间抛出 a,则重试该操作RuntimeException。我们可以配置尝试次数、尝试之间等待的时间等:

RetryConfig config = RetryConfig.custom()
  .maxAttempts(3)
  .waitDuration(Duration.of(2, SECONDS))
  .build();

// Registry, Retry creation omitted

FlightSearchService service = new FlightSearchService();
SearchRequest request = new SearchRequest("NYC", "LAX", "07/31/2020");
Supplier> flightSearchSupplier = 
  () -> service.searchFlights(request);

Supplier> retryingFlightSearch = 
  Retry.decorateSupplier(retry, flightSearchSupplier);

System.out.println(retryingFlightSearch.get());

 我们创建了一个RetryConfig指定我们最多重试 3 次并每次尝试之间等待 2 秒的方法。如果我们改用该RetryConfig.ofDefaults()方法,则将使用默认值 3 次尝试和 500 毫秒的等待时间。

我们将航班搜索调用表示为 lambda 表达式 -Supplier的List。该Retry.decorateSupplier()方法Supplier使用重试功能对其进行修饰。最后,我们get()在修饰的 上调用该方法Supplier进行远程调用。

如果我们想创建一个装饰器并在代码库的不同位置重复使用它,我们会使用它decorateSupplier()。如果我们想创建它并立即执行它,我们可以改用executeSupplier()实例方法:

List flights = retry.executeSupplier(
  () -> service.searchFlights(request));

 以下示例输出显示第一次请求失败,第二次尝试时成功:

Searching for flights; current time = 20:51:34 975
Operation failed
Searching for flights; current time = 20:51:36 985
Flight search successful
[Flight{flightNumber='XY 765', flightDate='07/31/2020', from='NYC', to='LAX'}, ...]

发生已检查异常时重试
现在,假设我们想要重试已检查和未检查的异常。假设我们正在调用FlightSearchService.searchFlightsThrowingException()可以抛出已检查的异常Exception。由于Supplier不能抛出已检查的异常,我们将在以下行收到编译器错误:

Supplier> flightSearchSupplier = 
  () -> service.searchFlightsThrowingException(request);
Exception我们可能会尝试在 lambda 表达式中处理并返回Collections.emptyList(),但这看起来不太好。但更重要的是,由于我们正在Exception自我捕捉,重试不再起作用:

Supplier> flightSearchSupplier = () -> {
  try {      
    return service.searchFlightsThrowingException(request);
  } catch (Exception e) {
    // don't do this, this breaks the retry!
  }
  return Collections.emptyList();
};

 那么,当我们想要重试远程调用可能抛出的所有异常时,我们应该怎么做?我们可以使用Retry.decorateCheckedSupplier()(或executeCheckedSupplier()实例方法)代替Retry.decorateSupplier():

CheckedFunction0> retryingFlightSearch = 
  Retry.decorateCheckedSupplier(retry, 
    () -> service.searchFlightsThrowingException(request));

try {
  System.out.println(retryingFlightSearch.apply());
} catch (...) {
  // handle exception that can occur after retries are exhausted
}

Retry.decorateCheckedSupplier()返回一个CheckedFunction0表示没有参数的函数。注意对对象的调用apply()以CheckedFunction0调用远程操作。

如果我们不想使用Suppliers ,Retry则提供更多辅助装饰器方法,如decorateFunction()、decorateCheckedFunction()、等,以便与其他语言结构配合使用。decorateRunnable()和版本之间的decorateCallable()区别在于版本在s 上重试,而版本在 上重试。decorate*decorateChecked*decorate*RuntimeExceptiondecorateChecked*Exception

有条件重试
上面的简单重试示例展示了在调用远程服务时,当我们得到RuntimeException或检查到Exception时如何重试。在实际应用中,我们可能不想对所有异常都进行重试。例如,如果我们得到一个AuthenticationFailedException重试相同的请求将无济于事。当我们进行 HTTP 调用时,我们可能想要检查 HTTP 响应状态代码或在响应中查找特定的应用程序错误代码来决定是否应该重试。让我们看看如何实现这种有条件的重试。

基于谓词的条件重试
假设航空公司的航班服务定期初始化其数据库中的航班数据。对于给定日期的航班数据,此内部操作需要几秒钟。如果我们在初始化过程中调用当天的航班搜索,该服务将返回特定错误代码 FS-167。航班搜索文档称这是一个临时错误,可以在几秒钟后重试该操作。

让我们看看如何创建RetryConfig:

RetryConfig config = RetryConfig.custom()
  .maxAttempts(3)
  .waitDuration(Duration.of(3, SECONDS))
  .retryOnResult(searchResponse -> searchResponse
    .getErrorCode()
    .equals("FS-167"))
  .build();

我们使用该retryOnResult()方法并传递一个Predicate执行此检查的函数。其中的逻辑Predicate可以任意复杂 - 它可以是对一组错误代码的检查,也可以是一些自定义逻辑来决定是否应重试搜索。

基于异常的条件重试
假设我们有一个一般异常FlightServiceBaseException,当与航空公司的航班服务交互期间发生任何意外情况时,就会抛出该异常。作为一般策略,我们希望在抛出此异常时重试。但是有一个子类SeatsUnavailableException我们不想重试 - 如果航班上没有空位,重试将无济于事。我们可以通过创建RetryConfig如下代码来实现:
 

RetryConfig config = RetryConfig.custom()
  .maxAttempts(3)
  .waitDuration(Duration.of(3, SECONDS))
  .retryExceptions(FlightServiceBaseException.class)
  .ignoreExceptions(SeatsUnavailableException.class)
  .build();

在 中retryExceptions()我们指定一个异常列表。Resilience4j 将重试与此列表中的异常匹配或继承的任何异常。我们将要忽略且不重试的异常放入 中ignoreExceptions()。如​​果代码在运行时抛出其他异常,例如IOException,它也不会被重试。

假设即使对于给定的异常,我们也不想在所有情况下都重试。也许我们只想在异常具有特定错误代码或异常消息中的特定文本时重试。retryOnException在这种情况下,我们可以使用该方法:

Predicate rateLimitPredicate = rle -> 
  (rle instanceof  RateLimitExceededException) &&
  "RL-101".equals(((RateLimitExceededException) rle).getErrorCode());

RetryConfig config = RetryConfig.custom()
  .maxAttempts(3)
  .waitDuration(Duration.of(1, SECONDS))
  .retryOnException(rateLimitPredicate)
  build();

与基于谓词的条件重试一样,谓词内的检查可以根据需要而变得复杂。

退避策略
到目前为止,我们的示例都具有固定的重试等待时间。通常,我们希望在每次尝试后增加等待时间 - 这是为了在远程服务当前过载的情况下为其提供足够的时间进行恢复。我们可以使用 来实现这一点IntervalFunction。

IntervalFunction是一个功能接口——它将Function尝试次数作为参数并以毫秒为单位返回等待时间。

随机间隔
这里我们指定尝试之间的随机等待时间:

RetryConfig config = RetryConfig.custom()
  .maxAttempts(4)
  .intervalFunction(IntervalFunction.ofRandomized(2000))
  .build();

有IntervalFunction.ofRandomized()一个randomizationFactor与之关联的。我们可以将其设置为 的第二个参数ofRandomized()。如果未设置,则采用默认值 0.5。这randomizationFactor决定了随机值的范围。因此,对于上面的默认值 0.5,生成的等待时间将在 1000 毫秒(2000 - 2000 * 0.5)和 3000 毫秒(2000 + 2000 * 0.5)之间。
示例输出显示了此行为:

Searching for flights; current time = 20:27:08 729
Operation failed
Searching for flights; current time = 20:27:10 643
Operation failed
Searching for flights; current time = 20:27:13 204
Operation failed
Searching for flights; current time = 20:27:15 236
Flight search successful
[Flight{flightNumber='XY 765', flightDate='07/31/2020', from='NYC', to='LAX'},...]

指数区间
对于指数退避,我们指定两个值 - 初始等待时间和乘数。在此方法中,由于乘数的存在,尝试之间的等待时间会呈指数增加。例如,如果我们指定初始等待时间为 1 秒,乘数为 2,则重试将在 1 秒、2 秒、4 秒、8 秒、16 秒等之后完成。当客户端是后台作业或守护进程时,建议使用此方法。

以下是我们如何创建RetryConfig指数退避算法:

RetryConfig config = RetryConfig.custom()
  .maxAttempts(6)
  .intervalFunction(IntervalFunction.ofExponentialBackoff(1000, 2))
  .build();

下面的示例输出显示了这种行为:

Searching for flights; current time = 20:37:02 684
Operation failed
Searching for flights; current time = 20:37:03 727
Operation failed
Searching for flights; current time = 20:37:05 731
Operation failed
Searching for flights; current time = 20:37:09 731
Operation failed
Searching for flights; current time = 20:37:17 731

IntervalFunction还提供了一种exponentialRandomBackoff()结合上述两种方法的方法。我们还可以提供自定义实现IntervalFunction。

重试异步操作
到目前为止我们看到的示例都是同步调用。让我们看看如何重试异步操作。假设我们正在像这样异步搜索航班:


CompletableFuture.supplyAsync(() -> service.searchFlights(request))
  .thenAccept(System.out::println);

调用searchFlight()发生在不同的线程上,当它返回时,返回的内容List被传递给thenAccept()它并打印出来。

executeCompletionStage()我们可以使用对象上的方法对上述异步操作进行重试Retry。此方法采用两个参数 -ScheduledExecutorService将在其上安排重试的参数和Supplier将被修饰的参数。它修饰并执行CompletionStage,然后返回一个CompletionStage,我们可以像以前一样调用它thenAccept:
 

ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();

Supplier>> completionStageSupplier = 
  () -> CompletableFuture.supplyAsync(() -> service.searchFlights(request));

retry.executeCompletionStage(scheduler, completionStageSupplier)
.thenAccept(System.out::println);

在实际应用中,我们将使用共享线程池(Executors.newScheduledThreadPool())来调度重试,而不是这里显示的单线程调度执行器。

重试事件
在所有这些示例中,装饰器都是黑盒 - 我们不知道尝试何时失败,并且框架代码正在尝试重试。假设对于给定的请求,我们想要记录一些详细信息,例如尝试次数或下次尝试之前的等待时间。我们可以使用在不同执行点发布的重试事件来实现这一点。Retry有一个具有诸如、等EventPublisher方法的onRetry()onSuccess()

我们可以通过实现这些监听器方法来收集和记录详细信息:

Retry.EventPublisher publisher = retry.getEventPublisher();
publisher.onRetry(event -> System.out.println(event.toString()));
publisher.onSuccess(event -> System.out.println(event.toString()));

重试指标
Retry维护计数器来跟踪操作的次数

第一次尝试就成功了
重试后成功
失败,无需重试
重试后仍失败
每次执行装饰器时,它都会更新这些计数器。
 

为什么要捕获指标?

捕获并定期分析指标可以让我们深入了解上游服务的行为。它还可以帮助识别瓶颈和其他潜在问题。

例如,如果我们发现某个操作通常在第一次尝试时就会失败,我们可以调查其原因。如果我们发现我们的请求受到限制或在建立连接时发生超时,则可能表明远程服务需要额外的资源或容量。

如何捕获指标?
Resilience4j 使用 Micrometer 发布指标。Micrometer 为 Prometheus、Azure Monitor、New Relic 等监控系统提供了仪表客户端的外观。因此,我们可以将指标发布到这些系统中的任何一个,或者在它们之间切换,而无需更改我们的代码。

首先,我们像往常一样创建RetryConfig和RetryRegistry和Retry。然后,我们创建一个MeterRegistry并将绑定RetryRegistry到它:

MeterRegistry meterRegistry = new SimpleMeterRegistry();
TaggedRetryMetrics.ofRetryRegistry(retryRegistry).bindTo(meterRegistry);

 运行可重试操作几次后,我们显示捕获的指标:

Consumer meterConsumer = meter -> {
    String desc = meter.getId().getDescription();
    String metricName = meter.getId().getTag("kind");
    Double metricValue = StreamSupport.stream(meter.measure().spliterator(), false)
      .filter(m -> m.getStatistic().name().equals("COUNT"))
      .findFirst()
      .map(m -> m.getValue())
      .orElse(0.0);
    System.out.println(desc + " - " + metricName + ": " + metricValue);
};
meterRegistry.forEachMeter(meterConsumer);

以下是一些示例输出:

The number of successful calls without a retry attempt - successful_without_retry: 4.0
The number of failed calls without a retry attempt - failed_without_retry: 0.0
The number of failed calls after a retry attempt - failed_with_retry: 0.0
The number of successful calls after a retry attempt - successful_with_retry: 6.0

重试时的陷阱和良好做法
服务通常会提供具有内置重试机制的客户端库或 SDK。对于云服务尤其如此。例如,Azure CosmosDB 和 Azure Service Bus 为客户端库提供了内置重试功能。它们允许应用程序设置重试策略来控制重试行为。

在这种情况下,最好使用内置重试,而不是编写自己的代码。如果我们确实需要编写自己的重试策略,则应禁用内置的默认重试策略 - 否则,它可能会导致嵌套重试,即应用程序的每次尝试都会导致客户端库的多次尝试。

一些云服务会记录暂时性错误代码。例如,Azure SQL 提供了数据库客户端需要重试的错误代码列表。在决定为特定操作添加重试之前,最好检查服务提供商是否有这样的列表。

另一个好的做法是将我们使用的值(RetryConfig如最大尝试次数、等待时间、可重试的错误代码和异常)作为服务之外的配置进行维护。如果我们发现新的瞬态错误,或者我们需要调整尝试间隔,我们可以在不构建和重新部署服务的情况下进行更改。

通常,重试时,Thread.sleep()框架代码中的某个地方可能会发生某种情况。同步重试的情况就是如此,每次重试之间会有等待时间。如果我们的代码在 Web 应用程序的上下文中运行,这Thread很可能是 Web 服务器的请求处理线程。因此,如果我们重试次数过多,则会降低应用程序的吞吐量。

结论

在本文中,我们了解了什么是 Resilience4j,以及如何使用它的重试模块使我们的应用程序能够应对临时错误。我们研究了配置重试的不同方法,以及一些在各种方法之间做出决定的示例。我们了解了实施重试时应遵循的一些良好做法,以及收集和分析重试指标的重要性。

你可能感兴趣的:(Spring,spring,cloud,spring,boot,spring)