退避策略

特别说明:封面图片来源 https://www.zcool.com.cn/work/ZNTQ3NzE5OTI=.html PS:未授权…若作者看到后不愿意授权可联系下架

前言

系统之间的交互大多是通过 RPC 的方式,交互的协议包括 HTTP、Dubbo、gRpc 等,无论什么协议,在接口之间的调用时会存在一种异常情况,那就是超时。导致接口超时的原因大多是网络抖动,或者服务提供方能力受限,无法处理大量请求,比如 IO 网络消耗,数据库消耗,磁盘等。

在遇到接口超时问题时,我们可能会考虑重试接口调用,通过重试策略来减低接口超时错误,那么每次重试的时间间隔是多少呢?有什么规律呢?这就涉及退避策略。

定义

退避是指怎么去做下一次的重试,通俗简单的理解就是等待多长时间,常见的退避策略有以下两种方式。

  • 固定时间间隔重试:一个接口调用失败了,不是立马返回失败,而是 hold 住线程,每隔一定时长重新调用接口,最多调用一定次数,只要其中一次成功了就直接返回。如果达到最大调用上限次数都没成功,接口返回失败。

  • 指数时间间隔重试:重试的时间间隔不在是按照固定时间,而是按照一定的增加方式增加,比如第一次 1s,第二次 2s,第三次 4s 秒,依次按照某种规律增加。使用此类重试机制的如 RocketMQ 的消息重试。

需要注意的是,退避策略和重试策略需要区分开来。重试策略,主要是用来判断调用接口异常时是否需要重试。退避策略着重的是在重试过程中怎么去做下一次的重试。

实现方案

Spring BackOff

清楚了退避策略,那么在代码中我们如何实现呢?这里可以参考 Spring 的 BackOff ,代码比较简单,具体的代码路径为 org.springframework.util.backoff.BackOff 该类为接口,具体实现有固定时间间隔重试 FixedBackOff 和指数时间间隔重试 ExponentialBackOff 。下面介绍一下如何使用。

通过查看 BackOff类里面注释,可以知道使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
FixedBackOff backOff = new FixedBackOff();
BackOffExecution exec = backOff.start();

// In the operation recovery/retry loop:
long waitInterval = exec.nextBackOff();
if (waitInterval == BackOffExecution.STOP) {
// do not retry operation
} else {
// sleep, e.g. Thread.sleep(waitInterval)
// retry operation
}

在具体使用时,我们需要在使用重试的代码中进行循环,直到重试策略停止。

FixedBackOff

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FixedBackOff fixedBackOff = new FixedBackOff();
fixedBackOff.setInterval(2_000); // 重试时间间隔, 单位毫秒
fixedBackOff.setMaxAttempts(2); // 最大重试次数
BackOffExecution backOffExecution = fixedBackOff.start();

while (true) {
long value = backOffExecution.nextBackOff();
if (value == BackOffExecution.STOP) {
break;
} else {
Thread.sleep(value);
// 业务逻辑
System.err.println(value);
}
}

ExponentialBackOff

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
long initialInterval = 100;           // 初始间隔
double multiplier = 2.0; // 递增倍数
long maxInterval = 5 * 1000L; // 最大间隔
long maxElapsedTime = 5 * 1000L; // 累计最大的时间间隔

ExponentialBackOff backOff = new ExponentialBackOff(initialInterval, multiplier);
backOff.setMaxInterval(maxInterval);
backOff.setMaxElapsedTime(maxElapsedTime);

BackOffExecution backOffExecution = backOff.start();

while (true) {
long value = backOffExecution.nextBackOff();
if (value == BackOffExecution.STOP) {
break;
} else {
System.err.println(value);
Thread.sleep(value);
}
}

如果我们需要自定义退避策略,只需要实现 BackOffBackOffExecution接口即可。

Spring Retry

Spring BackOff 提供的退避策略很清晰,如果我们在代码中去这样做,实在过于繁琐,遵从设计模式的开闭原则,我们应该避免直接修改核心逻辑,而是通过其他方式来进行代码控制。Spring 就为我们提供了相关工具,Spring Retry,通过 AOP 的方式来对一个方法进行重试 。在 maven 中,该包是这样描述的

Spring Retry provides an abstraction around retrying failed operations, with an emphasis on declarative control of the process and policy-based behaviour that is easy to extend and customize. For instance, you can configure a plain POJO operation to retry if it fails, based on the type of exception, and with a fixed or exponential backoff.

具体的使用和原理可以参考这篇文章 Spring 中的重试机制,简单、实用!,核心原理实现就是通过 AOP 。
在 Spring Retry 中,重试策略更为丰富。

  • SimpleRetryPolicy
    默认最多重试3次
  • TimeoutRetryPolicy
    默认在1秒内失败都会重试
  • ExpressionRetryPolicy
    符合表达式就会重试
  • CircuitBreakerRetryPolicy
    增加了熔断的机制,如果不在熔断状态,则允许重试
  • CompositeRetryPolicy
    可以组合多个重试策略
  • NeverRetryPolicy
    从不重试
  • AlwaysRetryPolicy
    总是重试

退避策略也进行了丰富。

  • FixedBackOffPolicy
    默认固定延迟1秒后执行下一次重试
  • ExponentialBackOffPolicy
    指数递增延迟执行重试,默认初始0.1秒,系数是2,那么下次延迟0.2秒,再下次就是延迟0.4秒,如此类推,最大30秒。
  • ExponentialRandomBackOffPolicy
    在上面那个策略上增加随机性
  • UniformRandomBackOffPolicy
    这个跟上面的区别就是,上面的延迟会不停递增,这个只会在固定的区间随机
  • StatelessBackOffPolicy
    这个说明是无状态的,所谓无状态就是对上次的退避无感知,从它下面的子类也能看出来

在使用时我们通过注解的方式进行重试和退避策略的设置,如果项目没有使用 Spring 框架,我们应该怎么使用呢?我们可以通过 RetryTemplate 进行重试和退避策略的构造,在 execute 方法入参中实现 RetryCallback 接口即可。

1
2
3
4
5
6
7
RetryTemplate retryTemplate = RetryTemplate.builder()
.infiniteRetry()
.retryOn(Exception.class)
.uniformRandomBackoff(1000, 3000)
.build();

retryTemplate.execute(new HelloServiceRetryCallbackImpl());

Retry

除了 Spring 提供的重试框架,resilience4j 也提供了重试框架,resilience4j 功能比较丰富,有下面几个模块功能。

  • resilience4j-circuitbreaker: Circuit breaking
  • resilience4j-ratelimiter: Rate limiting
  • resilience4j-bulkhead: Bulkheading
  • resilience4j-retry: Automatic retrying (sync and async)
  • resilience4j-cache: Result caching
  • resilience4j-timelimiter: Timeout handling

这里需要使用 resilience4j-retry 的能力。具体使用需要通过编码的方式,没有像 Spring Retry 那样提供注解。示例如下,更多方法使用,自行阅读 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RetryConfig config = RetryConfig.custom()
.maxAttempts(2)
.waitDuration(Duration.ofMillis(3000))
//.retryOnResult(response -> response.getStatus() == 500)
.retryOnException(e -> e instanceof RuntimeException)
.retryExceptions(IOException.class, TimeoutException.class)
.ignoreExceptions(IllegalArgumentException.class)
.build();

RetryRegistry retryRegistry = RetryRegistry.of(config);

Retry retry = retryRegistry.retry("retry");

TestService testService = new TestService();

Runnable runnable = Retry.decorateRunnable(retry, testService::test);
runnable.run();

总结

程序的稳定性要想做好不是一件简单的事情,网络因素影响是常事,遇到网络抖动,网络拥堵,程序需要自适应调整,配置重试策略,结合实际情况选择合适的退避策略,尽可能的保证程序的可用性。

参考

  1. 接口调用失败的退避策略
  2. Spring retry使用和采坑记录
  3. Spring 中的重试机制,简单、实用!
  4. Resilience4j-轻量级熔断框架
  5. resilience4j 官网