Java 项目中使用 Resilience4j 框架实现隔断机制/断路器


到目前为止,在本系列中,我们已经了解了 Resilience4j 及其 Retry, RateLimiter, TimeLimiter, 和 Bulkhead 模块。在本文中,我们将探索 CircuitBreaker 模块。我们将了解何时以及如何使用它,并查看一些示例。

代码示例

本文附有 GitHub 上的工作代码示例。

什么是 Resilience4j?

请参阅上一篇文章中的描述,快速了解 Resilience4j 的一般工作原理

什么是断路器?

断路器的思想是,如果我们知道调用可能会失败或超时,则阻止对远程服务的调用。我们这样做是为了不会在我们的服务和远程服务中不必要地浪费关键资源。这样的退出也给了远程服务一些时间来恢复。

我们怎么知道一个调用可能会失败? 通过跟踪对远程服务发出的先前请求的结果。例如,如果前 10 次调用中有 8 次导致失败或超时,则下一次调用也可能会失败。

断路器通过包装对远程服务的调用来跟踪响应。在正常运行期间,当远程服务成功响应时,我们说断路器处于“闭合”状态。当处于关闭状态时,断路器正常将请求传递给远程服务。

当远程服务返回错误或超时时,断路器会增加一个内部计数器。如果错误计数超过配置的阈值,断路器将切换到“断开”状态。当处于断开状态时,断路器立即向调用者返回错误,甚至无需尝试远程调用。

经过一段配置的时间后,断路器从断开状态切换到“半开”状态。在这种状态下,它允许一些请求传递到远程服务以检查它是否仍然不可用或缓慢。 如果错误率或慢呼叫率高于配置的阈值,则切换回断开状态。但是,如果错误率或慢呼叫率低于配置的阈值,则切换到关闭状态以恢复正常操作。

断路器的类型

断路器可以基于计数或基于时间。如果最后 N 次调用失败或缓慢,则基于计数的断路器将状态从关闭切换为断开。如果最后 N 秒的响应失败或缓慢,则基于时间的断路器将切换到断开状态。在这两个断路器中,我们还可以指定失败或慢速调用的阈值。

例如,如果最近 25 次调用中有 70% 失败或需要 2 秒以上才能完成,我们可以配置一个基于计数的断路器来“断开电路”。同样,如果过去 30 秒内 80% 的调用失败或耗时超过 5 秒,我们可以告诉基于时间的断路器断开电路。

Resilience4j 的 CircuitBreaker 概念

resilience4j-circuitbreaker 的工作原理与其他 Resilience4j 模块类似。我们提供想要作为函数构造执行的代码——一个进行远程调用的 lambda 表达式或一个从远程服务中检索到的某个值的 Supplier,等等——并且断路器用代码修饰它 如果需要,跟踪响应并切换状态。

Resilience4j 同时支持基于计数和基于时间的断路器。

我们使用 slidingWindowType() 配置指定断路器的类型。此配置可以采用两个值之一 –
SlidingWindowType.COUNT_BASED
SlidingWindowType.TIME_BASED

failureRateThreshold()slowCallRateThreshold() 以百分比形式配置失败率阈值和慢速调用率。

slowCallDurationThreshold() 以秒为单位配置调用被认为慢的时间。

我们可以指定一个 minimumNumberOfCalls(),在断路器可以计算错误率或慢速调用率之前需要它。

如前所述,断路器在一定时间后从断开状态切换到半断开状态,以检查远程服务的情况。waitDurationInOpenState() 指定断路器在切换到半开状态之前应等待的时间。

permittedNumberOfCallsInHalfOpenState() 配置在半开状态下允许的调用次数,
maxWaitDurationInHalfOpenState() 确定断路器在切换回开状态之前可以保持在半开状态的时间。

此配置的默认值 0 意味着断路器将无限等待,直到所有
permittedNumberOfCallsInHalfOpenState() 完成。

默认情况下,断路器将任何异常视为失败。但是我们可以对此进行调整,以使用 recordExceptions() 配置指定应视为失败的异常列表和使用 ignoreExceptions() 配置忽略的异常列表。

如果我们在确定异常应该被视为失败还是忽略时想要更精细的控制,我们可以提供 Predicate<Throwable> 作为 recordException()ignoreException() 配置。

当断路器拒绝处于断开状态的呼叫时,它会抛出 CallNotPermittedException。我们可以使用 writablestacktraceEnabled() 配置控制 CallNotPermittedException 的堆栈跟踪中的信息量。

使用 Resilience4j CircuitBreaker模块

让我们看看如何使用
resilience4j-circuitbreaker 模块中可用的各种功能。

我们将使用与本系列前几篇文章相同的示例。假设我们正在为一家航空公司建立一个网站,以允许其客户搜索和预订航班。我们的服务与 FlightSearchService 类封装的远程服务对话。

使用 Resilience4j 断路器时,CircuitBreakerRegistryCircuitBreakerConfigCircuitBreaker 是我们使用的主要抽象。

CircuitBreakerRegistry 是用于创建和管理 CircuitBreaker 对象的工厂。

CircuitBreakerConfig 封装了上一节中的所有配置。每个 CircuitBreaker 对象都与一个 CircuitBreakerConfig 相关联。

第一步是创建一个 CircuitBreakerConfig

CircuitBreakerConfig config = CircuitBreakerConfig.ofDefaults();

这将创建一个具有以下默认值的 CircuitBreakerConfig:

配置 默认值
slidingWindowType COUNT_BASED
failureRateThreshold 50%
slowCallRateThreshold 100%
slowCallDurationThreshold 60s
minimumNumberOfCalls 100
permittedNumberOfCallsInHalfOpenState 10
maxWaitDurationInHalfOpenState 0s

基于计数的断路器

假设我们希望断路器在最近 10 次调用中有 70% 失败时断开:

CircuitBreakerConfig config = CircuitBreakerConfig
  .custom()
  .slidingWindowType(SlidingWindowType.COUNT_BASED)
  .slidingWindowSize(10)
  .failureRateThreshold(70.0f)
  .build();

然后我们用这个配置创建一个 CircuitBreaker

CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
CircuitBreaker circuitBreaker = registry.circuitBreaker("flightSearchService");

现在让我们表达我们的代码以作为 Supplier 运行航班搜索并使用 circuitbreaker 装饰它:

Supplier<List<Flight>> flightsSupplier =
  () -> service.searchFlights(request);
Supplier<List<Flight>> decoratedFlightsSupplier =
  circuitBreaker.decorateSupplier(flightsSupplier);

最后,让我们调用几次修饰操作来了解断路器的工作原理。我们可以使用 CompletableFuture 来模拟来自用户的并发航班搜索请求:

for (int i=0; i<20; i++) {
  try {
    System.out.println(decoratedFlightsSupplier.get());
  }
  catch (...) {
    // Exception handling
  }
}

输出显示前几次飞行搜索成功,然后是 7 次飞行搜索失败。此时,断路器断开并为后续调用抛出 CallNotPermittedException

Searching for flights; current time = 12:01:12 884
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... ]
Searching for flights; current time = 12:01:12 954
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... ]
Searching for flights; current time = 12:01:12 957
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... ]
Searching for flights; current time = 12:01:12 958
io.reflectoring.resilience4j.circuitbreaker.exceptions.FlightServiceException: Error occurred during flight search
... stack trace omitted ...
io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'flightSearchService' is OPEN and does not permit further calls
... other lines omitted ...
io.reflectoring.resilience4j.circuitbreaker.Examples.countBasedSlidingWindow_FailedCalls(Examples.java:56)
  at io.reflectoring.resilience4j.circuitbreaker.Examples.main(Examples.java:229)

现在,假设我们希望断路器在最后 10 个调用中有 70% 需要 2 秒或更长时间才能完成:

CircuitBreakerConfig config = CircuitBreakerConfig
  .custom()
  .slidingWindowType(SlidingWindowType.COUNT_BASED)
  .slidingWindowSize(10)
  .slowCallRateThreshold(70.0f)
  .slowCallDurationThreshold(Duration.ofSeconds(2))
  .build();

示例输出中的时间戳显示请求始终需要 2 秒才能完成。在 7 次缓慢响应后,断路器断开并且不允许进一步调用:

Searching for flights; current time = 12:26:27 901
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... ]
Searching for flights; current time = 12:26:29 953
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... ]
Searching for flights; current time = 12:26:31 957
Flight search successful
... other lines omitted ...
Searching for flights; current time = 12:26:43 966
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... ]
io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'flightSearchService' is OPEN and does not permit further calls
... stack trace omitted ...
        at io.reflectoring.resilience4j.circuitbreaker.Examples.main(Examples.java:231)
io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'flightSearchService' is OPEN and does not permit further calls
... stack trace omitted ...
        at io.reflectoring.resilience4j.circuitbreaker.Examples.main(Examples.java:231)

通常我们会配置一个具有故障率和慢速调用率阈值的断路器:

CircuitBreakerConfig config = CircuitBreakerConfig
  .custom()
  .slidingWindowType(SlidingWindowType.COUNT_BASED)
  .slidingWindowSize(10)
  .failureRateThreshold(70.0f)
  .slowCallRateThreshold(70.0f)
  .slowCallDurationThreshold(Duration.ofSeconds(2))
  .build();

基于时间的断路器

假设我们希望断路器在过去 10 秒内 70% 的请求失败时断开:

CircuitBreakerConfig config = CircuitBreakerConfig
  .custom()
  .slidingWindowType(SlidingWindowType.COUNT_BASED)
  .slidingWindowSize(10)
  .failureRateThreshold(70.0f)
  .slowCallRateThreshold(70.0f)
  .slowCallDurationThreshold(Duration.ofSeconds(2))
  .build();

我们创建了 CircuitBreaker,将航班搜索调用表示为 Supplier<List<Flight>> 并使用 CircuitBreaker 对其进行装饰,就像我们在上一节中所做的那样。

以下是多次调用修饰操作后的示例输出:

Start time: 18:51:01 552
Searching for flights; current time = 18:51:01 582
Flight search successful
[Flight{flightNumber='XY 765', ... }]
... other lines omitted ...
Searching for flights; current time = 18:51:01 631
io.reflectoring.resilience4j.circuitbreaker.exceptions.FlightServiceException: Error occurred during flight search
... stack trace omitted ...
Searching for flights; current time = 18:51:01 632
io.reflectoring.resilience4j.circuitbreaker.exceptions.FlightServiceException: Error occurred during flight search
... stack trace omitted ...
Searching for flights; current time = 18:51:01 633
... other lines omitted ...
io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'flightSearchService' is OPEN and does not permit further calls
... other lines omitted ...

前 3 个请求成功,接下来的 7 个请求失败。此时断路器断开,后续请求因抛出 CallNotPermittedException 而失败。

现在,假设我们希望断路器在过去 10 秒内 70% 的调用需要 1 秒或更长时间才能完成:

CircuitBreakerConfig config = CircuitBreakerConfig
  .custom()
  .slidingWindowType(SlidingWindowType.TIME_BASED)
  .minimumNumberOfCalls(10)
  .slidingWindowSize(10)
  .slowCallRateThreshold(70.0f)
  .slowCallDurationThreshold(Duration.ofSeconds(1))
  .build();

示例输出中的时间戳显示请求始终需要 1 秒才能完成。在 10 个请求(minimumNumberOfCalls)之后,当断路器确定 70% 的先前请求花费了 1 秒或更长时间时,它断开电路:

Start time: 19:06:37 957
Searching for flights; current time = 19:06:37 979
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
Searching for flights; current time = 19:06:39 066
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
Searching for flights; current time = 19:06:40 070
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
Searching for flights; current time = 19:06:41 070
... other lines omitted ...
io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'flightSearchService' is OPEN and does not permit further calls
... stack trace omitted ...

通常我们会配置一个具有故障率和慢速调用率阈值的基于时间的断路器:

指定断开状态下的等待时间

假设我们希望断路器处于断开状态时等待 10 秒,然后转换到半断开状态并让一些请求传递到远程服务:

CircuitBreakerConfig config = CircuitBreakerConfig
  .custom()
  .slidingWindowType(SlidingWindowType.TIME_BASED)
  .slidingWindowSize(10)
  .minimumNumberOfCalls(10)
  .failureRateThreshold(70.0f)
  .slowCallRateThreshold(70.0f)
  .slowCallDurationThreshold(Duration.ofSeconds(2))
  .build();

示例输出中的时间戳显示断路器最初转换为断开状态,在接下来的 10 秒内阻止一些调用,然后更改为半断开状态。后来,在半开状态时一致的成功响应导致它再次切换到关闭状态:

Searching for flights; current time = 20:55:58 735
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
Searching for flights; current time = 20:55:59 812
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
Searching for flights; current time = 20:56:00 816
... other lines omitted ...
io.reflectoring.resilience4j.circuitbreaker.exceptions.FlightServiceException: Flight search failed
    at
... stack trace omitted ...
2020-12-13T20:56:03.850115+05:30: CircuitBreaker 'flightSearchService' changed state from CLOSED to OPEN
2020-12-13T20:56:04.851700+05:30: CircuitBreaker 'flightSearchService' recorded a call which was not permitted.
2020-12-13T20:56:05.852220+05:30: CircuitBreaker 'flightSearchService' recorded a call which was not permitted.
2020-12-13T20:56:06.855338+05:30: CircuitBreaker 'flightSearchService' recorded a call which was not permitted.
... other similar lines omitted ...
2020-12-13T20:56:12.862362+05:30: CircuitBreaker 'flightSearchService' recorded a call which was not permitted.
2020-12-13T20:56:13.865436+05:30: CircuitBreaker 'flightSearchService' changed state from OPEN to HALF_OPEN
Searching for flights; current time = 20:56:13 865
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
... other similar lines omitted ...
2020-12-13T20:56:16.877230+05:30: CircuitBreaker 'flightSearchService' changed state from HALF_OPEN to CLOSED
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
Searching for flights; current time = 20:56:17 879
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
... other similar lines omitted ...

指定回退方法

使用断路器时的常见模式是指定在电路断开时要调用的回退方法。回退方法可以为不允许的远程调用提供一些默认值或行为

我们可以使用 Decorators 实用程序类进行设置。Decorators 是来自 resilience4j-all 模块的构建器,具有 withCircuitBreaker()withRetry()withRateLimiter() 等方法,可帮助将多个 Resilience4j 装饰器应用于 SupplierFunction 等。

当断路器断开并抛出 CallNotPermittedException 时,我们将使用它的 withFallback() 方法从本地缓存返回航班搜索结果:

Supplier<List<Flight>> flightsSupplier = () -> service.searchFlights(request);
Supplier<List<Flight>> decorated = Decorators
  .ofSupplier(flightsSupplier)
  .withCircuitBreaker(circuitBreaker)
  .withFallback(Arrays.asList(CallNotPermittedException.class),
                e -> this.getFlightSearchResultsFromCache(request))
  .decorate();

以下示例输出显示了断路器断开后从缓存中返回的搜索结果:

Searching for flights; current time = 22:08:29 735
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
Searching for flights; current time = 22:08:29 854
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
Searching for flights; current time = 22:08:29 855
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
Searching for flights; current time = 22:08:29 855
2020-12-13T22:08:29.856277+05:30: CircuitBreaker 'flightSearchService' recorded an error: 'io.reflectoring.resilience4j.circuitbreaker.exceptions.FlightServiceException: Error occurred during flight search'. Elapsed time: 0 ms
Searching for flights; current time = 22:08:29 912
... other lines omitted ...
2020-12-13T22:08:29.926691+05:30: CircuitBreaker 'flightSearchService' changed state from CLOSED to OPEN
Returning flight search results from cache
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
Returning flight search results from cache
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... }]
... other lines omitted ...

减少 Stacktrace 中的信息

每当断路器断开时,它就会抛出 CallNotPermittedException

io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'flightSearchService' is OPEN and does not permit further calls
    at io.github.resilience4j.circuitbreaker.CallNotPermittedException.createCallNotPermittedException(CallNotPermittedException.java:48)
... other lines in stack trace omitted ...
at io.reflectoring.resilience4j.circuitbreaker.Examples.timeBasedSlidingWindow_SlowCalls(Examples.java:169)
    at io.reflectoring.resilience4j.circuitbreaker.Examples.main(Examples.java:263)

除了第一行,堆栈跟踪中的其他行没有增加太多价值。如果 CallNotPermittedException 发生多次,这些堆栈跟踪行将在我们的日志文件中重复。

我们可以通过将 writablestacktraceEnabled() 配置设置为 false 来减少堆栈跟踪中生成的信息量:

CircuitBreakerConfig config = CircuitBreakerConfig
  .custom()
  .slidingWindowType(SlidingWindowType.COUNT_BASED)
  .slidingWindowSize(10)
  .failureRateThreshold(70.0f)
  .writablestacktraceEnabled(false)
  .build();

现在,当 CallNotPermittedException 发生时,堆栈跟踪中只存在一行:

Searching for flights; current time = 20:29:24 476
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... ]
Searching for flights; current time = 20:29:24 540
Flight search successful
[Flight{flightNumber='XY 765', flightDate='12/31/2020', from='NYC', to='LAX'}, ... ]
... other lines omitted ...
io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'flightSearchService' is OPEN and does not permit further calls
io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'flightSearchService' is OPEN and does not permit further calls
...

其他有用的方法

Retry 模块类似,CircuitBreaker 也有像 ignoreExceptions()recordExceptions() 等方法,让我们可以指定 CircuitBreaker 在跟踪调用结果时应该忽略和考虑哪些异常。

例如,我们可能不想忽略来自远程飞行服务的 SeatsUnavailableException – 在这种情况下,我们真的不想断开电路。

与我们见过的其他 Resilience4j 模块类似,CircuitBreaker 还提供了额外的方法,如 decorateCheckedSupplier()decorateCompletionStage()decorateRunnable()decorateConsumer() 等,因此我们可以在 Supplier 之外的其他结构中提供我们的代码。

断路器事件

CircuitBreaker 有一个 EventPublisher 可以生成以下类型的事件:

  • CircuitBreakerOnSuccessEvent,
  • CircuitBreakerOnErrorEvent,
  • CircuitBreakerOnStateTransitionEvent,
  • CircuitBreakerOnResetEvent,
  • CircuitBreakerOnIgnoredErrorEvent,
  • CircuitBreakerOnCallNotPermittedEvent,
  • CircuitBreakerOnFailureRateExceededEvent 以及
  • CircuitBreakerOnSlowCallRateExceededEvent.

我们可以监听这些事件并记录它们,例如:

circuitBreaker.getEventPublisher()
  .onCallNotPermitted(e -> System.out.println(e.toString()));
circuitBreaker.getEventPublisher()
  .onError(e -> System.out.println(e.toString()));
circuitBreaker.getEventPublisher()
  .onFailureRateExceeded(e -> System.out.println(e.toString()));
circuitBreaker.getEventPublisher().onStateTransition(e -> System.out.println(e.toString()));

以下是示例的日志输出:

2020-12-13T22:25:52.972943+05:30: CircuitBreaker 'flightSearchService' recorded an error: 'io.reflectoring.resilience4j.circuitbreaker.exceptions.FlightServiceException: Error occurred during flight search'. Elapsed time: 0 ms
Searching for flights; current time = 22:25:52 973
... other lines omitted ...
2020-12-13T22:25:52.974448+05:30: CircuitBreaker 'flightSearchService' exceeded failure rate threshold. Current failure rate: 70.0
2020-12-13T22:25:52.984300+05:30: CircuitBreaker 'flightSearchService' changed state from CLOSED to OPEN
2020-12-13T22:25:52.985057+05:30: CircuitBreaker 'flightSearchService' recorded a call which was not permitted.
... other lines omitted ...

CircuitBreaker指标

CircuitBreake 暴露了许多指标,这些是一些重要的条目:

  • 成功、失败或忽略的调用总数 (resilience4j.circuitbreaker.calls)
  • 断路器状态 (resilience4j.circuitbreaker.state)
  • 断路器故障率 (resilience4j.circuitbreaker.failure.rate)
  • 未被允许的调用总数 (resilience4.circuitbreaker.not.permitted.calls)
  • 断路器的缓慢调用 (resilience4j.circuitbreaker.slow.call.rate)

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

MeterRegistry meterRegistry = new SimpleMeterRegistry();
TaggedCircuitBreakerMetrics.ofCircuitBreakerRegistry(registry)
  .bindTo(meterRegistry);

运行几次断路器修饰操作后,我们显示捕获的指标。这是一些示例输出:

The number of slow failed calls which were slower than a certain threshold - resilience4j.circuitbreaker.slow.calls: 0.0
The states of the circuit breaker - resilience4j.circuitbreaker.state: 0.0, state: metrics_only
Total number of not permitted calls - resilience4j.circuitbreakernot.permitted.calls: 0.0
The slow call of the circuit breaker - resilience4j.circuitbreaker.slow.call.rate: -1.0
The states of the circuit breaker - resilience4j.circuitbreaker.state: 0.0, state: half_open
Total number of successful calls - resilience4j.circuitbreaker.calls: 0.0, kind: successful
The failure rate of the circuit breaker - resilience4j.circuitbreaker.failure.rate: -1.0

在实际应用中,我们会定期将数据导出到监控系统并在仪表板上进行分析。

结论

在本文中,我们学习了如何使用 Resilience4j 的 Circuitbreaker 模块在远程服务返回错误时暂停向其发出请求。我们了解了为什么这很重要,还看到了一些有关如何配置它的实际示例。

您可以使用 GitHub 上的代码来演示一个完整的应用程序。


本文译自:Implementing a Circuit Breaker with Resilience4j – Reflectoringhttps://reflectoring.io/circuitbreaker-with-resilience4j/)

Java 项目中使用 Resilience4j 框架实现故障隔离

Java 项目中使用 Resilience4j 框架实现故障隔离

到目前为止,在本系列中,我们已经了解了 Resilience4j 及其 Retry, RateLimiterTimeLimiter 模块。在本文中,我们将探讨 Bulkhead 模块。我们将了解它解决了什么问题,何时以及如何使用它,并查看一些示例。

代码示例

本文附有 GitHub 上的工作代码示例。

什么是 Resilience4j?

请参阅上一篇文章中的描述,快速了解 Resilience4j 的一般工作原理

什么是故障隔离?

几年前,我们遇到了一个生产问题,其中一台服务器停止响应健康检查,负载均衡器将服务器从池中取出。

就在我们开始调查这个问题的时候,还有第二个警报——另一台服务器已经停止响应健康检查,也被从池中取出。

几分钟后,每台服务器都停止响应健康探测,我们的服务完全关闭。

我们使用 Redis 为应用程序支持的几个功能缓存一些数据。正如我们后来发现的那样,Redis 集群同时出现了一些问题,它已停止接受新连接。我们使用 Jedis 库连接到 Redis,该库的默认行为是无限期地阻塞调用线程,直到建立连接。

我们的服务托管在 Tomcat 上,它的默认请求处理线程池大小为 200 个线程。因此,通过连接到 Redis 的代码路径的每个请求最终都会无限期地阻塞线程。

几分钟之内,集群中的所有 2000 个线程都无限期地阻塞了——甚至没有空闲线程来响应负载均衡器的健康检查。

该服务本身支持多项功能,并非所有功能都需要访问 Redis 缓存。但是当这一方面出现问题时,它最终影响了整个服务。

这正是故障隔离要解决的问题——它可以防止某个服务区域的问题影响整个服务。

虽然我们的服务发生的事情是一个极端的例子,但我们可以看到缓慢的上游依赖如何影响调用服务的不相关区域。

如果我们在每个服务器实例上对 Redis 设置了 20 个并发请求的限制,那么当 Redis 连接问题发生时,只有这些线程会受到影响。剩余的请求处理线程可以继续为其他请求提供服务。

故障隔离背后的想法是对我们对远程服务进行的并发调用数量设置限制。我们将对不同远程服务的调用视为不同的、隔离的池,并对可以同时进行的调用数量设置限制。

术语舱壁本身来自它在船舶中的使用,其中船舶的底部被分成彼此分开的部分。如果有裂缝,并且水开始流入,则只有该部分会充满水。这可以防止整艘船沉没。

Resilience4j 隔板概念

resilience4j-bulkhead 的工作原理类似于其他 Resilience4j 模块。我们为它提供了我们想要作为函数构造执行的代码——一个进行远程调用的 lambda 表达式或一个从远程服务中检索到的某个值的 Supplier,等等——并且隔板用代码装饰它以控制并发调用数。

Resilience4j 提供两种类型的隔板 – SemaphoreBulkhead ThreadPoolBulkhead

SemaphoreBulkhead 内部使用
java.util.concurrent.Semaphore 来控制并发调用的数量并在当前线程上执行我们的代码。

ThreadPoolBulkhead 使用线程池中的一个线程来执行我们的代码。它内部使用
java.util.concurrent.ArrayBlockingQueue
java.util.concurrent.ThreadPoolExecutor 来控制并发调用的数量。

SemaphoreBulkhead

让我们看看与信号量隔板相关的配置及其含义。

maxConcurrentCalls 确定我们可以对远程服务进行的最大并发调用数。我们可以将此值视为初始化信号量的许可数。

任何尝试超过此限制调用远程服务的线程都可以立即获得 BulkheadFullException 或等待一段时间以等待另一个线程释放许可。这由 maxWaitDuration 值决定。

当有多个线程在等待许可时,fairCallHandlingEnabled 配置确定等待的线程是否以先进先出的顺序获取许可。

最后, writableStackTraceEnabled 配置让我们可以在 BulkheadFullException 发生时减少堆栈跟踪中的信息量。这很有用,因为如果没有它,当异常多次发生时,我们的日志可能会充满许多类似的信息。通常在读取日志时,只知道发生了 BulkheadFullException 就足够了。

ThreadPoolBulkhead

coreThreadPoolSizemaxThreadPoolSizekeepAliveDurationqueueCapacity 是与 ThreadPoolBulkhead 相关的主要配置。ThreadPoolBulkhead 内部使用这些配置来构造一个 ThreadPoolExecutor

internalThreadPoolExecutor 使用可用的空闲线程之一执行传入的任务。 如果没有线程可以自由执行传入的任务,则该任务将排队等待线程可用时稍后执行。如果已达到 queueCapacity,则远程调用将被拒绝并返回 BulkheadFullException

ThreadPoolBulkhead 也有 writableStackTraceEnabled 配置来控制 BulkheadFullException 的堆栈跟踪中的信息量。

使用 Resilience4j 隔板模块

让我们看看如何使用 resilience4j-bulkhead 模块中可用的各种功能。

我们将使用与本系列前几篇文章相同的示例。假设我们正在为一家航空公司建立一个网站,以允许其客户搜索和预订航班。我们的服务与 FlightSearchService 类封装的远程服务对话。

SemaphoreBulkhead

使用基于信号量的隔板时,BulkheadRegistryBulkheadConfigBulkhead 是我们使用的主要抽象。

BulkheadRegistry 是一个用于创建和管理 Bulkhead 对象的工厂。

BulkheadConfig 封装了 maxConcurrentCallsmaxWaitDurationwritableStackTraceEnabledfairCallHandlingEnabled 配置。每个 Bulkhead 对象都与一个 BulkheadConfig 相关联。

第一步是创建一个 BulkheadConfig

BulkheadConfig config = BulkheadConfig.ofDefaults();

这将创建一个 BulkheadConfig,其默认值为 maxConcurrentCalls(25)、maxWaitDuration(0s)、writableStackTraceEnabled(true) 和 fairCallHandlingEnabled(true)。

假设我们希望将并发调用的数量限制为 2,并且我们愿意等待 2 秒让线程获得许可:

BulkheadConfig config = BulkheadConfig.custom()
  .maxConcurrentCalls(2)
  .maxWaitDuration(Duration.ofSeconds(2))
  .build();

然后我们创建一个 Bulkhead

BulkheadRegistry registry = BulkheadRegistry.of(config);

Bulkhead bulkhead = registry.bulkhead("flightSearchService");

现在让我们表达我们的代码以作为 Supplier 运行航班搜索并使用 bulkhead 装饰它:

BulkheadRegistry registry = BulkheadRegistry.of(config);
Bulkhead bulkhead = registry.bulkhead("flightSearchService");

最后,让我们调用几次装饰操作来了解隔板的工作原理。我们可以使用 CompletableFuture 来模拟来自用户的并发航班搜索请求:

for (int i=0; i<4; i++) {
  CompletableFuture
    .supplyAsync(decoratedFlightsSupplier)
    .thenAccept(flights -> System.out.println("Received results"));
}

输出中的时间戳和线程名称显示,在 4 个并发请求中,前两个请求立即通过:

Searching for flights; current time = 11:42:13 187; current thread = ForkJoinPool.commonPool-worker-3
Searching for flights; current time = 11:42:13 187; current thread = ForkJoinPool.commonPool-worker-5
Flight search successful at 11:42:13 226
Flight search successful at 11:42:13 226
Received results
Received results
Searching for flights; current time = 11:42:14 239; current thread = ForkJoinPool.commonPool-worker-9
Searching for flights; current time = 11:42:14 239; current thread = ForkJoinPool.commonPool-worker-7
Flight search successful at 11:42:14 239
Flight search successful at 11:42:14 239
Received results
Received results

第三个和第四个请求仅在 1 秒后就能够获得许可,在之前的请求完成之后。

如果线程无法在我们指定的 2s maxWaitDuration 内获得许可,则会抛出 BulkheadFullException

Caused by: io.github.resilience4j.bulkhead.BulkheadFullException: Bulkhead 'flightSearchService' is full and does not permit further calls
    at io.github.resilience4j.bulkhead.BulkheadFullException.createBulkheadFullException(BulkheadFullException.java:49)
    at io.github.resilience4j.bulkhead.internal.SemaphoreBulkhead.acquirePermission(SemaphoreBulkhead.java:164)
    at io.github.resilience4j.bulkhead.Bulkhead.lambda$decorateSupplier$5(Bulkhead.java:194)
    at java.base/java.util.concurrent.CompletableFuture$AsyncSupply.run(CompletableFuture.java:1700)
    ... 6 more

除了第一行,堆栈跟踪中的其他行没有增加太多价值。如果 BulkheadFullException 发生多次,这些堆栈跟踪行将在我们的日志文件中重复。

我们可以通过将 writableStackTraceEnabled 配置设置为 false 来减少堆栈跟踪中生成的信息量:

BulkheadConfig config = BulkheadConfig.custom()
    .maxConcurrentCalls(2)
    .maxWaitDuration(Duration.ofSeconds(1))
    .writableStackTraceEnabled(false)
.build();

现在,当 BulkheadFullException 发生时,堆栈跟踪中只存在一行:

Searching for flights; current time = 12:27:58 658; current thread = ForkJoinPool.commonPool-worker-3
Searching for flights; current time = 12:27:58 658; current thread = ForkJoinPool.commonPool-worker-5
io.github.resilience4j.bulkhead.BulkheadFullException: Bulkhead 'flightSearchService' is full and does not permit further calls
Flight search successful at 12:27:58 699
Flight search successful at 12:27:58 699
Received results
Received results

与我们见过的其他 Resilience4j 模块类似,Bulkhead 还提供了额外的方法,如 decorateCheckedSupplier()decorateCompletionStage()decorateRunnable()decorateConsumer() 等,因此我们可以在 Supplier 供应商之外的其他结构中提供我们的代码。

ThreadPoolBulkhead

当使用基于线程池的隔板时,
ThreadPoolBulkheadRegistryThreadPoolBulkheadConfigThreadPoolBulkhead 是我们使用的主要抽象。

ThreadPoolBulkheadRegistry 是用于创建和管理 ThreadPoolBulkhead 对象的工厂。

ThreadPoolBulkheadConfig 封装了 coreThreadPoolSizemaxThreadPoolSizekeepAliveDurationqueueCapacity 配置。每个 ThreadPoolBulkhead 对象都与一个 ThreadPoolBulkheadConfig 相关联。

第一步是创建一个 ThreadPoolBulkheadConfig

ThreadPoolBulkheadConfig config =
  ThreadPoolBulkheadConfig.ofDefaults();

这将创建一个 ThreadPoolBulkheadConfig,其默认值为 coreThreadPoolSize(可用处理器数量 -1)、maxThreadPoolSize(可用处理器最大数量)、keepAliveDuration(20ms)和 queueCapacity(100)。

假设我们要将并发调用的数量限制为 2:

ThreadPoolBulkheadConfig config = ThreadPoolBulkheadConfig.custom()
  .maxThreadPoolSize(2)
  .coreThreadPoolSize(1)
  .queueCapacity(1)
  .build();

然后我们创建一个 ThreadPoolBulkhead

ThreadPoolBulkheadRegistry registry = ThreadPoolBulkheadRegistry.of(config);
ThreadPoolBulkhead bulkhead = registry.bulkhead("flightSearchService");

现在让我们表达我们的代码以作为 Supplier 运行航班搜索并使用 bulkhead 装饰它:

Supplier<List<Flight>> flightsSupplier =
  () -> service.searchFlightsTakingOneSecond(request);
Supplier<CompletionStage<List<Flight>>> decoratedFlightsSupplier =
  ThreadPoolBulkhead.decorateSupplier(bulkhead, flightsSupplier);

与返回一个 Supplier<List<Flight>>
SemaphoreBulkhead.decorateSupplier() 不同,
ThreadPoolBulkhead.decorateSupplier() 返回一个 Supplier<CompletionStage<List<Flight>>。这是因为 ThreadPoolBulkHead 不会在当前线程上同步执行代码。

最后,让我们调用几次装饰操作来了解隔板的工作原理:

for (int i=0; i<3; i++) {
  decoratedFlightsSupplier
    .get()
    .whenComplete((r,t) -> {
      if (r != null) {
        System.out.println("Received results");
      }
      if (t != null) {
        t.printStackTrace();
      }
    });
}

输出中的时间戳和线程名称显示,虽然前两个请求立即执行,但第三个请求已排队,稍后由释放的线程之一执行:

Searching for flights; current time = 16:15:00 097; current thread = bulkhead-flightSearchService-1
Searching for flights; current time = 16:15:00 097; current thread = bulkhead-flightSearchService-2
Flight search successful at 16:15:00 136
Flight search successful at 16:15:00 135
Received results
Received results
Searching for flights; current time = 16:15:01 151; current thread = bulkhead-flightSearchService-2
Flight search successful at 16:15:01 151
Received results

如果队列中没有空闲线程和容量,则抛出 BulkheadFullException

Exception in thread "main" io.github.resilience4j.bulkhead.BulkheadFullException: Bulkhead 'flightSearchService' is full and does not permit further calls
 at io.github.resilience4j.bulkhead.BulkheadFullException.createBulkheadFullException(BulkheadFullException.java:64)
 at io.github.resilience4j.bulkhead.internal.FixedThreadPoolBulkhead.submit(FixedThreadPoolBulkhead.java:157)
... other lines omitted ...

我们可以使用 writableStackTraceEnabled 配置来减少堆栈跟踪中生成的信息量:

ThreadPoolBulkheadConfig config = ThreadPoolBulkheadConfig.custom()
  .maxThreadPoolSize(2)
  .coreThreadPoolSize(1)
  .queueCapacity(1)
  .writableStackTraceEnabled(false)
  .build();

现在,当 BulkheadFullException 发生时,堆栈跟踪中只存在一行:

Searching for flights; current time = 12:27:58 658; current thread = ForkJoinPool.commonPool-worker-3
Searching for flights; current time = 12:27:58 658; current thread = ForkJoinPool.commonPool-worker-5
io.github.resilience4j.bulkhead.BulkheadFullException: Bulkhead 'flightSearchService' is full and does not permit further calls
Flight search successful at 12:27:58 699
Flight search successful at 12:27:58 699
Received results
Received results

上下文传播

有时我们将数据存储在 ThreadLocal 变量中并在代码的不同区域中读取它。我们这样做是为了避免在方法链之间显式地将数据作为参数传递,尤其是当该值与我们正在实现的核心业务逻辑没有直接关系时。

例如,我们可能希望将当前用户 ID 或事务 ID 或某个请求跟踪 ID 记录到每个日志语句中,以便更轻松地搜索日志。对于此类场景,使用 ThreadLocal 是一种有用的技术。

使用 ThreadPoolBulkhead 时,由于我们的代码不在当前线程上执行,因此我们存储在 ThreadLocal 变量中的数据在其他线程中将不可用。

让我们看一个例子来理解这个问题。首先我们定义一个 RequestTrackingIdHolder 类,一个围绕 ThreadLocal 的包装类:

class RequestTrackingIdHolder {
  static ThreadLocal<String> threadLocal = new ThreadLocal<>();

  static String getRequestTrackingId() {
    return threadLocal.get();
  }

  static void setRequestTrackingId(String id) {
    if (threadLocal.get() != null) {
      threadLocal.set(null);
      threadLocal.remove();
    }
    threadLocal.set(id);
  }

  static void clear() {
    threadLocal.set(null);
    threadLocal.remove();
  }
}

静态方法可以轻松设置和获取存储在 ThreadLocal 上的值。我们接下来在调用隔板装饰的航班搜索操作之前设置一个请求跟踪 ID:

for (int i=0; i<2; i++) {
  String trackingId = UUID.randomUUID().toString();
  System.out.println("Setting trackingId " + trackingId + " on parent, main thread before calling flight search");
  RequestTrackingIdHolder.setRequestTrackingId(trackingId);
  decoratedFlightsSupplier
    .get()
    .whenComplete((r,t) -> {
        // other lines omitted
    });
}

示例输出显示此值在隔板管理的线程中不可用:

Setting trackingId 98ff99df-466a-47f7-88f7-5e31fc8fcb6b on parent, main thread before calling flight search
Setting trackingId 6b98d73c-a590-4a20-b19d-c85fea783caf on parent, main thread before calling flight search
Searching for flights; current time = 19:53:53 799; current thread = bulkhead-flightSearchService-1; Request Tracking Id = null
Flight search successful at 19:53:53 824
Received results
Searching for flights; current time = 19:53:54 836; current thread = bulkhead-flightSearchService-1; Request Tracking Id = null
Flight search successful at 19:53:54 836
Received results

为了解决这个问题,ThreadPoolBulkhead 提供了一个 ContextPropagatorContextPropagator 是一种用于跨线程边界检索、复制和清理值的抽象。它定义了一个接口,其中包含从当前线程 (retrieve()) 获取值、将其复制到新的执行线程 (copy()) 并最终在执行线程 (clear()) 上进行清理的方法。

让我们实现一个
RequestTrackingIdPropagator

class RequestTrackingIdPropagator implements ContextPropagator {
  @Override
  public Supplier<Optional> retrieve() {
    System.out.println("Getting request tracking id from thread: " + Thread.currentThread().getName());
    return () -> Optional.of(RequestTrackingIdHolder.getRequestTrackingId());
  }

  @Override
  Consumer<Optional> copy() {
    return optional -> {
      System.out.println("Setting request tracking id " + optional.get() + " on thread: " + Thread.currentThread().getName());
      optional.ifPresent(s -> RequestTrackingIdHolder.setRequestTrackingId(s.toString()));
    };
  }

  @Override
  Consumer<Optional> clear() {
    return optional -> {
      System.out.println("Clearing request tracking id on thread: " + Thread.currentThread().getName());
      optional.ifPresent(s -> RequestTrackingIdHolder.clear());
    };
  }
}

我们通过在 ThreadPoolBulkheadConfig 上的设置来为 ThreadPoolBulkhead 提供 ContextPropagator

class RequestTrackingIdPropagator implements ContextPropagator {
  @Override
  public Supplier<Optional> retrieve() {
    System.out.println("Getting request tracking id from thread: " + Thread.currentThread().getName());
    return () -> Optional.of(RequestTrackingIdHolder.getRequestTrackingId());
  }

  @Override
  Consumer<Optional> copy() {
    return optional -> {
      System.out.println("Setting request tracking id " + optional.get() + " on thread: " + Thread.currentThread().getName());
      optional.ifPresent(s -> RequestTrackingIdHolder.setRequestTrackingId(s.toString()));
    };
  }

  @Override
  Consumer<Optional> clear() {
    return optional -> {
      System.out.println("Clearing request tracking id on thread: " + Thread.currentThread().getName());
      optional.ifPresent(s -> RequestTrackingIdHolder.clear());
    };
  }
}

现在,示例输出显示请求跟踪 ID 在隔板管理的线程中可用:

Setting trackingId 71d44cb8-dab6-4222-8945-e7fd023528ba on parent, main thread before calling flight search
Getting request tracking id from thread: main
Setting trackingId 5f9dd084-f2cb-4a20-804b-038828abc161 on parent, main thread before calling flight search
Getting request tracking id from thread: main
Setting request tracking id 71d44cb8-dab6-4222-8945-e7fd023528ba on thread: bulkhead-flightSearchService-1
Searching for flights; current time = 20:07:56 508; current thread = bulkhead-flightSearchService-1; Request Tracking Id = 71d44cb8-dab6-4222-8945-e7fd023528ba
Flight search successful at 20:07:56 538
Clearing request tracking id on thread: bulkhead-flightSearchService-1
Received results
Setting request tracking id 5f9dd084-f2cb-4a20-804b-038828abc161 on thread: bulkhead-flightSearchService-1
Searching for flights; current time = 20:07:57 542; current thread = bulkhead-flightSearchService-1; Request Tracking Id = 5f9dd084-f2cb-4a20-804b-038828abc161
Flight search successful at 20:07:57 542
Clearing request tracking id on thread: bulkhead-flightSearchService-1
Received results

Bulkhead事件

Bulkhead 和 ThreadPoolBulkhead 都有一个 EventPublisher 来生成以下类型的事件:

  • BulkheadOnCallPermittedEvent
  • BulkheadOnCallRejectedEvent 和
  • BulkheadOnCallFinishedEvent

我们可以监听这些事件并记录它们,例如:

Bulkhead bulkhead = registry.bulkhead("flightSearchService");
bulkhead.getEventPublisher().onCallPermitted(e -> System.out.println(e.toString()));
bulkhead.getEventPublisher().onCallFinished(e -> System.out.println(e.toString()));
bulkhead.getEventPublisher().onCallRejected(e -> System.out.println(e.toString()));

示例输出显示了记录的内容:

2020-08-26T12:27:39.790435: Bulkhead 'flightSearch' permitted a call.
... other lines omitted ...
2020-08-26T12:27:40.290987: Bulkhead 'flightSearch' rejected a call.
... other lines omitted ...
2020-08-26T12:27:41.094866: Bulkhead 'flightSearch' has finished a call.

Bulkhead 指标

SemaphoreBulkhead

Bulkhead 暴露了两个指标:

  • 可用权限的最大数量(resilience4j.bulkhead.max.allowed.concurrent.calls),和
  • 允许的并发调用数(resilience4j.bulkhead.available.concurrent.calls)。

bulkhead.available 指标与我们在 BulkheadConfig 上配置的 maxConcurrentCalls 相同。

首先,我们像前面一样创建 BulkheadConfigBulkheadRegistryBulkhead。然后,我们创建一个 MeterRegistry 并将 BulkheadRegistry 绑定到它:

MeterRegistry meterRegistry = new SimpleMeterRegistry();
TaggedBulkheadMetrics.ofBulkheadRegistry(registry)
  .bindTo(meterRegistry);

运行几次隔板装饰操作后,我们显示捕获的指标:

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

这是一些示例输出:

The maximum number of available permissions - resilience4j.bulkhead.max.allowed.concurrent.calls: 8.0
The number of available permissions - resilience4j.bulkhead.available.concurrent.calls: 3.0

ThreadPoolBulkhead

ThreadPoolBulkhead 暴露五个指标:

  • 队列的当前长度(resilience4j.bulkhead.queue.depth),
  • 当前线程池的大小(resilience4j.bulkhead.thread.pool.size),
  • 线程池的核心和最大容量(resilience4j.bulkhead.core.thread.pool.sizeresilience4j.bulkhead.max.thread.pool.size),以及
  • 队列的容量(resilience4j.bulkhead.queue.capacity)。

首先,我们像前面一样创建 ThreadPoolBulkheadConfig
ThreadPoolBulkheadRegistryThreadPoolBulkhead。然后,我们创建一个 MeterRegistry 并将
ThreadPoolBulkheadRegistry 绑定到它:

MeterRegistry meterRegistry = new SimpleMeterRegistry();
TaggedThreadPoolBulkheadMetrics.ofThreadPoolBulkheadRegistry(registry).bindTo(meterRegistry);

运行几次隔板装饰操作后,我们将显示捕获的指标:

The queue capacity - resilience4j.bulkhead.queue.capacity: 5.0
The queue depth - resilience4j.bulkhead.queue.depth: 1.0
The thread pool size - resilience4j.bulkhead.thread.pool.size: 5.0
The maximum thread pool size - resilience4j.bulkhead.max.thread.pool.size: 5.0
The core thread pool size - resilience4j.bulkhead.core.thread.pool.size: 3.0

在实际应用中,我们会定期将数据导出到监控系统并在仪表板上进行分析。

实施隔板时的陷阱和良好实践

使隔板成为单例

对给定远程服务的所有调用都应通过同一个 Bulkhead 实例。对于给定的远程服务,Bulkhead 必须是单例。

如果我们不强制执行此操作,我们代码库的某些区域可能会绕过 Bulkhead 直接调用远程服务。为了防止这种情况,远程服务的实际调用应该在一个核心、内部层和其他区域应该使用内部层暴露的隔板装饰器。

我们如何确保未来的新开发人员理解这一意图? 查看 Tom 的文章,该文章展示了解决此类问题的一种方法,即通过组织包结构来明确此类意图。此外,它还展示了如何通过在 ArchUnit 测试中编码意图来强制执行此操作。

与其他 Resilience4j 模块结合

将隔板与一个或多个其他 Resilience4j 模块(如重试和速率限制器)结合使用会更有效。例如,如果有 BulkheadFullException,我们可能希望在一些延迟后重试。

结论

在本文中,我们学习了如何使用 Resilience4j 的 Bulkhead 模块对我们对远程服务进行的并发调用设置限制。我们了解了为什么这很重要,还看到了一些有关如何配置它的实际示例。

您可以使用 GitHub 上的代码演示一个完整的应用程序。


本文译自: Implementing Bulkhead with Resilience4j – Reflectoring

Java 项目中使用 Resilience4j 框架实现异步超时处理

到目前为止,在本系列中,我们已经了解了 Resilience4j 及其 RetryRateLimiter 模块。在本文中,我们将通过 TimeLimiter 继续探索 Resilience4j。我们将了解它解决了什么问题,何时以及如何使用它,并查看一些示例。

代码示例

本文附有 GitHub 上的工作代码示例。

什么是 Resilience4j?

请参阅上一篇文章中的描述,快速了解 Resilience4j 的一般工作原理

什么是限时?

对我们愿意等待操作完成的时间设置限制称为时间限制。如果操作没有在我们指定的时间内完成,我们希望通过超时错误收到通知。

有时,这也称为“设定最后期限”。

我们这样做的一个主要原因是确保我们不会让用户或客户无限期地等待。不提供任何反馈的缓慢服务可能会让用户感到沮丧。

我们对操作设置时间限制的另一个原因是确保我们不会无限期地占用服务器资源。我们在使用 Spring 的 @Transactional 注解时指定的 timeout 值就是一个例子——在这种情况下,我们不想长时间占用数据库资源。

什么时候使用 Resilience4j TimeLimiter?

Resilience4j 的 TimeLimiter 可用于设置使用 CompleteableFutures 实现的异步操作的时间限制(超时)。

Java 8 中引入的 CompletableFuture 类使异步、非阻塞编程变得更容易。可以在不同的线程上执行慢速方法,释放当前线程来处理其他任务。 我们可以提供一个当 slowMethod() 返回时执行的回调:

int slowMethod() {
  // time-consuming computation or remote operation
return 42;
}

CompletableFuture.supplyAsync(this::slowMethod)
.thenAccept(System.out::println);

这里的 slowMethod() 可以是一些计算或远程操作。通常,我们希望在进行这样的异步调用时设置时间限制。我们不想无限期地等待 slowMethod() 返回。例如,如果 slowMethod() 花费的时间超过一秒,我们可能想要返回先前计算的、缓存的值,甚至可能会出错。

在 Java 8 的 CompletableFuture 中,没有简单的方法来设置异步操作的时间限制。CompletableFuture 实现了 Future 接口,Future 有一个重载的 get() 方法来指定我们可以等待多长时间:

CompletableFuture<Integer> completableFuture = CompletableFuture
  .supplyAsync(this::slowMethod);
Integer result = completableFuture.get(3000, TimeUnit.MILLISECONDS);
System.out.println(result);

但是这里有一个问题—— get() 方法是一个阻塞调用。所以它首先违背了使用 CompletableFuture 的目的,即释放当前线程。

这是 Resilience4j 的 TimeLimiter 解决的问题——它让我们在异步操作上设置时间限制,同时保留在 Java 8 中使用 CompletableFuture 时非阻塞的好处。

CompletableFuture 的这种限制已在 Java 9 中得到解决。我们可以在 Java 9 及更高版本中使用 CompletableFuture 上的 orTimeout()completeOnTimeout() 等方法直接设置时间限制。然而,凭借 Resilience4J指标事件,与普通的 Java 9 解决方案相比,它仍然提供了附加值。

Resilience4j TimeLimiter 概念

TimeLimiter支持 FutureCompletableFuture。但是将它与 Future 一起使用相当于 Future.get(long timeout, TimeUnit unit)。因此,我们将在本文的其余部分关注 CompletableFuture

与其他 Resilience4j 模块一样,TimeLimiter 的工作方式是使用所需的功能装饰我们的代码 – 如果在这种情况下操作未在指定的 timeoutDuration 内完成,则返回 TimeoutException

我们为 TimeLimiter 提供 timeoutDurationScheduledExecutorService 和异步操作本身,表示为 CompletionStageSupplier。它返回一个 CompletionStage 的装饰 Supplier

在内部,它使用调度器来调度一个超时任务——通过抛出一个 TimeoutException 来完成 CompletableFuture 的任务。如果操作先完成,TimeLimiter 取消内部超时任务。

除了 timeoutDuration 之外,还有另一个与 TimeLimiter 关联的配置 cancelRunningFuture。此配置仅适用于 Future 而不适用于 CompletableFuture。当超时发生时,它会在抛出 TimeoutException 之前取消正在运行的 Future

使用 Resilience4j TimeLimiter 模块

TimeLimiterRegistryTimeLimiterConfigTimeLimiterresilience4j-timelimiter 的主要抽象。

TimeLimiterRegistry 是用于创建和管理 TimeLimiter 对象的工厂。

TimeLimiterConfig 封装了 timeoutDurationcancelRunningFuture 配置。每个 TimeLimiter 对象都与一个 TimeLimiterConfig 相关联。

TimeLimiter 提供辅助方法来为 FutureCompletableFuture Suppliers 创建或执行装饰器。

让我们看看如何使用 TimeLimiter 模块中可用的各种功能。我们将使用与本系列前几篇文章相同的示例。假设我们正在为一家航空公司建立一个网站,以允许其客户搜索和预订航班。我们的服务与 FlightSearchService 类封装的远程服务对话。

第一步是创建一个 TimeLimiterConfig

TimeLimiterConfig config = TimeLimiterConfig.ofDefaults();

这将创建一个 TimeLimiterConfig,其默认值为 timeoutDuration (1000ms) 和 cancelRunningFuture (true)。

假设我们想将超时值设置为 2s 而不是默认值:

TimeLimiterConfig config = TimeLimiterConfig.custom()
  .timeoutDuration(Duration.ofSeconds(2))
  .build();

然后我们创建一个 TimeLimiter

TimeLimiterRegistry registry = TimeLimiterRegistry.of(config);

TimeLimiter limiter = registry.timeLimiter("flightSearch");

我们想要异步调用
FlightSearchService.searchFlights(),它返回一个 List<Flight>。让我们将其表示为 Supplier<CompletionStage<List<Flight>>>

Supplier<List<Flight>> flightSupplier = () -> service.searchFlights(request);
Supplier<CompletionStage<List<Flight>>> origCompletionStageSupplier =
() -> CompletableFuture.supplyAsync(flightSupplier);

然后我们可以使用 TimeLimiter 装饰 Supplier

ScheduledExecutorService scheduler =
  Executors.newSingleThreadScheduledExecutor();
Supplier<CompletionStage<List<Flight>>> decoratedCompletionStageSupplier =  
  limiter.decorateCompletionStage(scheduler, origCompletionStageSupplier);

最后,让我们调用装饰的异步操作:

decoratedCompletionStageSupplier.get().whenComplete((result, ex) -> {
  if (ex != null) {
    System.out.println(ex.getMessage());
  }
  if (result != null) {
    System.out.println(result);
  }
});

以下是成功飞行搜索的示例输出,其耗时少于我们指定的 2 秒 timeoutDuration

Searching for flights; current time = 19:25:09 783; current thread = ForkJoinPool.commonPool-worker-3

Flight search successful

[Flight{flightNumber='XY 765', flightDate='08/30/2020', from='NYC', to='LAX'}, Flight{flightNumber='XY 746', flightDate='08/30/2020', from='NYC', to='LAX'}] on thread ForkJoinPool.commonPool-worker-3

这是超时的航班搜索的示例输出:

Exception java.util.concurrent.TimeoutException: TimeLimiter 'flightSearch' recorded a timeout exception on thread pool-1-thread-1 at 19:38:16 963

Searching for flights; current time = 19:38:18 448; current thread = ForkJoinPool.commonPool-worker-3

Flight search successful at 19:38:18 461

上面的时间戳和线程名称表明,即使异步操作稍后在另一个线程上完成,调用线程也会收到 TimeoutException。

如果我们想创建一个装饰器并在代码库的不同位置重用它,我们将使用decorateCompletionStage()。如果我们想创建它并立即执行 Supplier<CompletionStage>,我们可以使用 executeCompletionStage() 实例方法代替:

CompletionStage<List<Flight>> decoratedCompletionStage =  
  limiter.executeCompletionStage(scheduler, origCompletionStageSupplier);

TimeLimiter 事件

TimeLimiter 有一个 EventPublisher,它生成 TimeLimiterOnSuccessEventTimeLimiterOnErrorEventTimeLimiterOnTimeoutEvent 类型的事件。我们可以监听这些事件并记录它们,例如:

TimeLimiter limiter = registry.timeLimiter("flightSearch");

limiter.getEventPublisher().onSuccess(e -> System.out.println(e.toString()));

limiter.getEventPublisher().onError(e -> System.out.println(e.toString()));

limiter.getEventPublisher().onTimeout(e -> System.out.println(e.toString()));

示例输出显示了记录的内容:

2020-08-07T11:31:48.181944: TimeLimiter 'flightSearch' recorded a successful call.

... other lines omitted ...

2020-08-07T11:31:48.582263: TimeLimiter 'flightSearch' recorded a timeout exception.

TimeLimiter 指标

TimeLimiter 跟踪成功、失败和超时的调用次数。

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

MeterRegistry meterRegistry = new SimpleMeterRegistry();
TaggedTimeLimiterMetrics.ofTimeLimiterRegistry(registry)
  .bindTo(meterRegistry);

运行几次限时操作后,我们显示捕获的指标:

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

这是一些示例输出:

The number of timed out calls - resilience4j.timelimiter.calls(timeout): 6.0

The number of successful calls - resilience4j.timelimiter.calls(successful): 4.0

The number of failed calls - resilience4j.timelimiter.calls(failed): 0.0

在实际应用中,我们会定期将数据导出到监控系统并在仪表板上进行分析。

实施时间限制时的陷阱和良好实践

通常,我们处理两种操作 – 查询(或读取)和命令(或写入)。对查询进行时间限制是安全的,因为我们知道它们不会改变系统的状态。我们看到的 searchFlights() 操作是查询操作的一个例子。

命令通常会改变系统的状态。bookFlights() 操作将是命令的一个示例。在对命令进行时间限制时,我们必须记住,当我们超时时,该命令很可能仍在运行。例如,bookFlights() 调用上的 TimeoutException 并不一定意味着命令失败。

在这种情况下,我们需要管理用户体验——也许在超时时,我们可以通知用户操作花费的时间比我们预期的要长。然后我们可以查询上游以检查操作的状态并稍后通知用户。

结论

在本文中,我们学习了如何使用 Resilience4j 的 TimeLimiter 模块为异步、非阻塞操作设置时间限制。我们通过一些实际示例了解了何时使用它以及如何配置它。

您可以使用 GitHub 上的代码演示一个完整的应用程序来说明这些想法。


本文译自:
https://reflectoring.io/time-limiting-with-resilience4j/

Java 项目中使用 Resilience4j 框架实现客户端 API 调用的限速/节流机制


在本系列的上一篇文章中,我们了解了 Resilience4j 以及如何使用其 Retry 模块。现在让我们了解 RateLimiter – 它是什么,何时以及如何使用它,以及在实施速率限制(或者也称为“节流”)时要注意什么。

代码示例

本文附有GitHub 上的工作代码示例。

什么是 Resilience4j?

请参阅上一篇文章中的描述,快速了解 Resilience4j 的一般工作原理

什么是限速?

我们可以从两个角度来看待速率限制——作为服务提供者和作为服务消费者。

服务端限速

作为服务提供商,我们实施速率限制以保护我们的资源免受过载和拒绝服务 (DoS) 攻击

为了满足我们与所有消费者的服务水平协议 (SLA),我们希望确保一个导致流量激增的消费者不会影响我们对他人的服务质量。

我们通过设置在给定时间单位内允许消费者发出多少请求的限制来做到这一点。我们通过适当的响应拒绝任何超出限制的请求,例如 HTTP 状态 429(请求过多)。这称为服务器端速率限制。

速率限制以每秒请求数 (rps)、每分钟请求数 (rpm) 或类似形式指定。某些服务在不同的持续时间(例如 50 rpm 且不超过 2500 rph)和一天中的不同时间(例如,白天 100 rps 和晚上 150 rps)有多个速率限制。该限制可能适用于单个用户(由用户 ID、IP 地址、API 访问密钥等标识)或多租户应用程序中的租户。

客户端限速

作为服务的消费者,我们希望确保我们不会使服务提供者过载。此外,我们不想招致意外的成本——无论是金钱上的还是服务质量方面的。

如果我们消费的服务是有弹性的,就会发生这种情况。服务提供商可能不会限制我们的请求,而是会因额外负载而向我们收取额外费用。有些甚至在短时间内禁止行为不端的客户。消费者为防止此类问题而实施的速率限制称为客户端速率限制。

何时使用 RateLimiter?

resilience4j-ratelimiter 用于客户端速率限制。

服务器端速率限制需要诸如缓存和多个服务器实例之间的协调之类的东西,这是 resilience4j 不支持的。对于服务器端的速率限制,有 API 网关和 API 过滤器,例如 Kong API GatewayRepose API Filter。Resilience4j 的 RateLimiter 模块并不打算取代它们。

Resilience4j RateLimiter 概念

想要调用远程服务的线程首先向 RateLimiter 请求许可。如果 RateLimiter 允许,则线程继续。 否则,RateLimiter 会停放线程或将其置于等待状态。

RateLimiter 定期创建新权限。当权限可用时,线程会收到通知,然后可以继续。

一段时间内允许的调用次数称为 limitForPeriod。RateLimiter 刷新权限的频率由 limitRefreshPeriod 指定。timeoutDuration 指定线程可以等待多长时间获取权限。如果在等待时间结束时没有可用的权限,RateLimiter 将抛出 RequestNotPermitted 运行时异常。

使用Resilience4j RateLimiter 模块

RateLimiterRegistryRateLimiterConfigRateLimiterresilience4j-ratelimiter 的主要抽象。

RateLimiterRegistry 是一个用于创建和管理 RateLimiter 对象的工厂。

RateLimiterConfig 封装了 limitForPeriodlimitRefreshPeriodtimeoutDuration 配置。每个 RateLimiter 对象都与一个 RateLimiterConfig 相关联。

RateLimiter 提供辅助方法来为包含远程调用的函数式接口或 lambda 表达式创建装饰器。

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

基本示例

第一步是创建一个 RateLimiterConfig

RateLimiterConfig config = RateLimiterConfig.ofDefaults();

这将创建一个 RateLimiterConfig,其默认值为 limitForPeriod (50)、limitRefreshPeriod(500ns) 和 timeoutDuration (5s)。

假设我们与航空公司服务的合同规定我们可以以 1 rps 调用他们的搜索 API。然后我们将像这样创建 RateLimiterConfig

RateLimiterConfig config = RateLimiterConfig.custom()
  .limitForPeriod(1)
  .limitRefreshPeriod(Duration.ofSeconds(1))
  .timeoutDuration(Duration.ofSeconds(1))
  .build();

如果线程无法在指定的 1 秒 timeoutDuration 内获取权限,则会出错。

然后我们创建一个 RateLimiter 并装饰 searchFlights() 调用:

RateLimiterRegistry registry = RateLimiterRegistry.of(config);
RateLimiter limiter = registry.rateLimiter("flightSearchService");
// FlightSearchService and SearchRequest creation omitted
Supplier<List<Flight>> flightsSupplier =
  RateLimiter.decorateSupplier(limiter,
    () -> service.searchFlights(request));

最后,我们多次使用装饰过的 Supplier<List<Flight>>

for (int i=0; i<3; i++) {
  System.out.println(flightsSupplier.get());
}

示例输出中的时间戳显示每秒发出一个请求:

Searching for flights; current time = 15:29:40 786
...
[Flight{flightNumber='XY 765', ... }, ... ]
Searching for flights; current time = 15:29:41 791
...
[Flight{flightNumber='XY 765', ... }, ... ]

如果超出限制,我们会收到 RequestNotPermitted 异常:

Exception in thread "main" io.github.resilience4j.ratelimiter.RequestNotPermitted: RateLimiter 'flightSearchService' does not permit further calls at io.github.resilience4j.ratelimiter.RequestNotPermitted.createRequestNotPermitted(RequestNotPermitted.java:43)

 at io.github.resilience4j.ratelimiter.RateLimiter.waitForPermission(RateLimiter.java:580)

... other lines omitted ...

装饰方法抛出已检异常

假设我们正在调用
FlightSearchService.searchFlightsThrowingException() ,它可以抛出一个已检 Exception。那么我们就不能使用
RateLimiter.decorateSupplier()。我们将使用
RateLimiter.decorateCheckedSupplier() 代替:

CheckedFunction0<List<Flight>> flights =
  RateLimiter.decorateCheckedSupplier(limiter,
    () -> service.searchFlightsThrowingException(request));

try {
  System.out.println(flights.apply());
} catch (...) {
  // exception handling
}

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

如果我们不想使用 SuppliersRateLimiter 提供了更多的辅助装饰器方法,如 decorateFunction()decorateCheckedFunction()decorateRunnable()decorateCallable() 等,以与其他语言结构一起使用。decorateChecked* 方法用于装饰抛出已检查异常的方法。

应用多个速率限制

假设航空公司的航班搜索有多个速率限制:2 rps 和 40 rpm。 我们可以通过创建多个 RateLimiters 在客户端应用多个限制:

RateLimiterConfig rpsConfig = RateLimiterConfig.custom().
  limitForPeriod(2).
  limitRefreshPeriod(Duration.ofSeconds(1)).
  timeoutDuration(Duration.ofMillis(2000)).build();

RateLimiterConfig rpmConfig = RateLimiterConfig.custom().
  limitForPeriod(40).
  limitRefreshPeriod(Duration.ofMinutes(1)).
  timeoutDuration(Duration.ofMillis(2000)).build();

RateLimiterRegistry registry = RateLimiterRegistry.of(rpsConfig);
RateLimiter rpsLimiter =
  registry.rateLimiter("flightSearchService_rps", rpsConfig);
RateLimiter rpmLimiter =
  registry.rateLimiter("flightSearchService_rpm", rpmConfig);  
然后我们使用两个 RateLimiters 装饰 searchFlights() 方法:

Supplier<List<Flight>> rpsLimitedSupplier =
  RateLimiter.decorateSupplier(rpsLimiter,
    () -> service.searchFlights(request));

Supplier<List<Flight>> flightsSupplier
  = RateLimiter.decorateSupplier(rpmLimiter, rpsLimitedSupplier);

示例输出显示每秒发出 2 个请求,并且限制为 40 个请求:

Searching for flights; current time = 15:13:21 246
...
Searching for flights; current time = 15:13:21 249
...
Searching for flights; current time = 15:13:22 212
...
Searching for flights; current time = 15:13:40 215
...
Exception in thread "main" io.github.resilience4j.ratelimiter.RequestNotPermitted:
RateLimiter 'flightSearchService_rpm' does not permit further calls
at io.github.resilience4j.ratelimiter.RequestNotPermitted.createRequestNotPermitted(RequestNotPermitted.java:43)
at io.github.resilience4j.ratelimiter.RateLimiter.waitForPermission(RateLimiter.java:580)

在运行时更改限制

如果需要,我们可以在运行时更改 limitForPeriodtimeoutDuration 的值:

limiter.changeLimitForPeriod(2);
limiter.changeTimeoutDuration(Duration.ofSeconds(2));

例如,如果我们的速率限制根据一天中的时间而变化,则此功能很有用 – 我们可以有一个计划线程来更改这些值。新值不会影响当前正在等待权限的线程。

RateLimiter和 Retry一起使用

假设我们想在收到 RequestNotPermitted 异常时重试,因为它是一个暂时性错误。我们会像往常一样创建 RateLimiterRetry 对象。然后我们装饰一个 Supplier 的供应商并用 Retry 包装它:

Supplier<List<Flight>> rateLimitedFlightsSupplier =
  RateLimiter.decorateSupplier(rateLimiter,
    () -> service.searchFlights(request));

Supplier<List<Flight>> retryingFlightsSupplier =
  Retry.decorateSupplier(retry, rateLimitedFlightsSupplier);

示例输出显示为 RequestNotPermitted 异常重试请求:

Searching for flights; current time = 15:29:39 847
Flight search successful
[Flight{flightNumber='XY 765', ... }, ... ]
Searching for flights; current time = 17:10:09 218
...
[Flight{flightNumber='XY 765', flightDate='07/31/2020', from='NYC', to='LAX'}, ...]
2020-07-27T17:10:09.484: Retry 'rateLimitedFlightSearch', waiting PT1S until attempt '1'. Last attempt failed with exception 'io.github.resilience4j.ratelimiter.RequestNotPermitted: RateLimiter 'flightSearchService' does not permit further calls'.
Searching for flights; current time = 17:10:10 492
...
2020-07-27T17:10:10.494: Retry 'rateLimitedFlightSearch' recorded a successful retry attempt...
[Flight{flightNumber='XY 765', flightDate='07/31/2020', from='NYC', to='LAX'}, ...]

我们创建装饰器的顺序很重要。如果我们将 RetryRateLimiter 包装在一起,它将不起作用。

RateLimiter 事件

RateLimiter 有一个 EventPublisher,它在调用远程操作时生成 RateLimiterOnSuccessEventRateLimiterOnFailureEvent 类型的事件,以指示获取权限是否成功。我们可以监听这些事件并记录它们,例如:

RateLimiter limiter = registry.rateLimiter("flightSearchService");
limiter.getEventPublisher().onSuccess(e -> System.out.println(e.toString()));
limiter.getEventPublisher().onFailure(e -> System.out.println(e.toString()));

日志输出示例如下:

RateLimiterEvent{type=SUCCESSFUL_ACQUIRE, rateLimiterName='flightSearchService', creationTime=2020-07-21T19:14:33.127+05:30}
... other lines omitted ...
RateLimiterEvent{type=FAILED_ACQUIRE, rateLimiterName='flightSearchService', creationTime=2020-07-21T19:14:33.186+05:30}

RateLimiter 指标

假设在实施客户端节流后,我们发现 API 的响应时间增加了。这是可能的 – 正如我们所见,如果在线程调用远程操作时权限不可用,RateLimiter 会将线程置于等待状态。

如果我们的请求处理线程经常等待获得许可,则可能意味着我们的 limitForPeriod 太低。也许我们需要与我们的服务提供商合作并首先获得额外的配额。

监控 RateLimiter 指标可帮助我们识别此类容量问题,并确保我们在 RateLimiterConfig 上设置的值运行良好。

RateLimiter 跟踪两个指标:可用权限的数量(
resilience4j.ratelimiter.available.permissions)和等待权限的线程数量(
resilience4j.ratelimiter.waiting.threads)。

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

MeterRegistry meterRegistry = new SimpleMeterRegistry();
TaggedRateLimiterMetrics.ofRateLimiterRegistry(registry)
  .bindTo(meterRegistry);

运行几次限速操作后,我们显示捕获的指标:

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

这是一些示例输出:

The number of available permissions - resilience4j.ratelimiter.available.permissions: -6.0
The number of waiting threads - resilience4j.ratelimiter.waiting_threads: 7.0

resilience4j.ratelimiter.available.permissions 的负值显示为请求线程保留的权限数。在实际应用中,我们会定期将数据导出到监控系统,并在仪表板上进行分析。

实施客户端速率限制时的陷阱和良好实践

使速率限制器成为单例

对给定远程服务的所有调用都应通过相同的 RateLimiter 实例。对于给定的远程服务,RateLimiter 必须是单例。

如果我们不强制执行此操作,我们代码库的某些区域可能会绕过 RateLimiter 直接调用远程服务。为了防止这种情况,对远程服务的实际调用应该在核心、内部层和其他区域应该使用内部层暴露的限速装饰器。

我们如何确保未来的新开发人员理解这一意图?查看 Tom 的文章,其中揭示了一种解决此类问题的方法,即通过组织包结构来明确此类意图。此外,它还展示了如何通过在 ArchUnit 测试中编码意图来强制执行此操作。

为多个服务器实例配置速率限制器

为配置找出正确的值可能很棘手。如果我们在集群中运行多个服务实例,limitForPeriod 的值必须考虑到这一点

例如,如果上游服务的速率限制为 100 rps,而我们的服务有 4 个实例,那么我们将配置 25 rps 作为每个实例的限制。

然而,这假设我们每个实例上的负载大致相同。 如果情况并非如此,或者如果我们的服务本身具有弹性并且实例数量可能会有所不同,那么 Resilience4j 的 RateLimiter 可能不适合

在这种情况下,我们需要一个速率限制器,将其数据保存在分布式缓存中,而不是像 Resilience4j RateLimiter 那样保存在内存中。但这会影响我们服务的响应时间。另一种选择是实现某种自适应速率限制。尽管 Resilience4j 可能会支持它,但尚不清楚何时可用。

选择正确的超时时间

对于 timeoutDuration 配置值,我们应该牢记 API 的预期响应时间

如果我们将 timeoutDuration 设置得太高,响应时间和吞吐量就会受到影响。如果它太低,我们的错误率可能会增加。

由于此处可能涉及一些反复试验,因此一个好的做法是将我们在 RateLimiterConfig 中使用的值(如 timeoutDurationlimitForPeriodlimitRefreshPeriod)作为我们服务之外的配置进行维护。然后我们可以在不更改代码的情况下更改它们。

调优客户端和服务器端速率限制器

实现客户端速率限制并不能保证我们永远不会受到上游服务的速率限制

假设我们有来自上游服务的 2 rps 的限制,并且我们将 limitForPeriod 配置为 2,将 limitRefreshPeriod 配置为 1s。如果我们在第二秒的最后几毫秒发出两个请求,在此之前没有其他调用,RateLimiter 将允许它们。如果我们在下一秒的前几毫秒内再进行两次调用,RateLimiter 也会允许它们,因为有两个新权限可用。但是上游服务可能会拒绝这两个请求,因为服务器通常会实现基于滑动窗口的速率限制。

为了保证我们永远不会从上游服务中获得超过速率,我们需要将客户端中的固定窗口配置为短于服务中的滑动窗口。因此,如果我们在前面的示例中将 limitForPeriod 配置为 1 并将 limitRefreshPeriod 配置为 500ms,我们就不会出现超出速率限制的错误。但是,第一个请求之后的所有三个请求都会等待,从而增加响应时间并降低吞吐量。

结论

在本文中,我们学习了如何使用 Resilience4j 的 RateLimiter 模块来实现客户端速率限制。 我们通过实际示例研究了配置它的不同方法。我们学习了一些在实施速率限制时要记住的良好做法和注意事项。

您可以使用 GitHub 上的代码演示一个完整的应用程序来说明这些想法。


本文译自: Implementing Rate Limiting with Resilience4j – Reflectoring

使用 Resilience4j 框架实现重试机制


在本文中,我们将从快速介绍 Resilience4j 开始,然后深入探讨其 Retry 模块。我们将了解何时、如何使用它,以及它提供的功能。在此过程中,我们还将学习实现重试时的一些良好实践。

代码示例

本文在 GitHu 上附有工作代码示例。

什么是 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<List<Flight>> flightSearchSupplier =
  () -> searchService.searchFlights(request); // ----> 4

Supplier<List<Flight>> 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 重试模块

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

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

简单重试

在简单重试中,如果在远程调用期间抛出 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<List<Flight>> flightSearchSupplier =
  () -> service.searchFlights(request);

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

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

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

我们将航班搜索调用表示为 lambda 表达式 – List<Flight>SupplierRetry.decorateSupplier() 方法使用重试功能装饰此 Supplier。最后,我们在装饰过的 Supplier 上调用 get() 方法来进行远程调用。

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

List<Flight> 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<List<Flight>> flightSearchSupplier =
  () -> service.searchFlightsThrowingException(request);

我们可能会尝试在 lambda 表达式中处理 Exception 并返回 Collections.emptyList(),但这看起来不太好。更重要的是,由于我们自己捕获 Exception,重试不再起作用:

ExceptionSupplier<List<Flight>> 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<List<Flight>> 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,它表示一个没有参数的函数。请注意对 CheckedFunction0 对象的 apply() 调用以调用远程操作。

如果我们不想使用 SuppliersRetry 提供了更多的辅助装饰器方法,如 decorateFunction()decorateCheckedFunction()decorateRunnable()decorateCallable() 等,以与其他语言结构一起使用。decorate*decorateChecked* 版本之间的区别在于,decorate* 版本在 RuntimeExceptions 上重试,而 decorateChecked* 版本在 Exception 上重试。

有条件重试

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

Predicate-based条件重试

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

让我们看看如何创建 RetryConfig

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

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

Exception-based条件重试

假设我们有一个通用异常
FlightServiceBaseException,当在与航空公司的航班服务交互期间发生任何意外时会抛出该异常。作为一般策略,我们希望在抛出此异常时重试。但是我们不想重试 SeatsUnavailableException 的一个子类 – 如果航班上没有可用座位,重试将无济于事。我们可以通过像这样创建 RetryConfig 来做到这一点:

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

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

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

Predicate<Throwable> 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();

与 predicate-based (基于谓词)的条件重试一样,谓词内的检查可以根据需要复杂化。

退避策略

到目前为止,我们的示例有固定的重试等待时间。通常我们希望在每次尝试后增加等待时间——这是为了让远程服务有足够的时间在当前过载的情况下进行恢复。我们可以使用 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<Flight> 被传递给 thenAccept(),它只是打印它。

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

ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();

Supplier<CompletionStage<List<Flight>>> 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()));

类似地,RetryRegistry 也有一个 EventPublisher,它在 Retry 对象被添加或从注册表中删除时发布事件。

重试指标

Retry 维护计数器以跟踪操作的次数

  1. 第一次尝试成功
  2. 重试后成功
  3. 没有重试就失败了
  4. 重试后仍失败

每次执行装饰器时,它都会更新这些计数器。

为什么要捕获指标?

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

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

如何捕获指标?

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

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

MeterRegistry meterRegistry = new SimpleMeterRegistry();

TaggedRetryMetrics.ofRetryRegistry(retryRegistry).bindTo(meterRegistry);

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

Consumer<Meter> 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 服务总线为客户端库提供内置重试工具。 它们允许应用程序设置重试策略来控制重试行为。

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

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

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

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

结论

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

您可以使用 GitHub 上的代码尝试一个完整的应用程序来演示这些想法。


本文译自: Implementing Retry with Resilience4j – Reflectoring