熟悉的重试场景
我们在日常系统开发中,经常会遇到使用Http或者RPC调用跨系统应用的场景。由于是跨系统间调用,不可避免地会遇到网络问题或者服务方限流等原因导致的异常,这时我们就需要对失败的调用进行重试,这会引入了一系列的问题:
哪些异常需要重试?
应该重试多少次?
重试的时间间隔是多少?
每次重试时间的累加如何设定?
超时时间是多少?
场景模拟
我们使用代码来模拟实际场景,MockService模拟一个远程调用,使用Random随机数模拟返回的请求结果。为了让重试行为更加明显,这里设置了返回随机数[0, 9],只有返回0才是请求成功,剩余情况都是超时,也就是十分之一的调用成功率。
@Slf4j
public class MockService {
// 模拟远程服务调用
public static Response call() {
Random rand = new Random();
int result = rand.nextInt(10);
if(result == 0) { // 成功
return new Response(200, "处理成功");
} else {
try {
Thread.sleep(1 * 1000);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
throw new RuntimeException("处理超过1s,超时");
}
}
}
@AllArgsConstructor
@Data
public class Response {
private int code; // 状态码
private String msg; // 返回结果
}
Java原生的解决方案
public Response commonRetry() {
int retryTimes = 1;
while(retryTimes <= 10) {
try {
log.info("第{}次调用", retryTimes);
Response response = MockService.call();
if(response.getCode() == 200) {
return response;
}
Thread.sleep(1000); // 失败,等待下次调用
retryTimes++;
} catch (Exception e) {
log.error(e.getMessage(), e);
try {
Thread.sleep(1000);
} catch (Exception e2) {
log.error(e2.getMessage(), e2);
}
retryTimes++;
}
}
throw new RuntimeException("重试失败");
}
一般我们可以通过try/catch的方式来设置重试的行为,retryTimes代表重试的次数,每次调用时查看结果的返回码,如果是200则返回结果,如果不是200或者服务抛异常则等待1秒钟,然后重试直到达到最大的重试次数。
可以看到代码比较繁琐,可读性较差。另外,重试策略和业务处理的代码耦合,如果再考虑不同异常的处理方式和重试间隔时间的累加,代码会更加复杂。
Guava的retrying工具
Guava提供了专门的重试工具来帮我们进行解耦,它对重试逻辑进行了抽象,提供了多种重试策略,而且扩展起来非常方便,可以监控每次充实的结果和行为,提升远程调用方代码的简洁性与实用性。
使用前,我们需要引入guava-retrying包
<dependency>
<groupId>com.github.rholder</groupId>
<artifactId>guava-retrying</artifactId>
<version>2.0.0</version>
</dependency>
使用方式相当简单,首先声明Retryer对象,通过链式调用配置重试策略,再通过回调函数实现代码逻辑,
public Response graceRetry() {
Retryer<Response> retryer = RetryerBuilder.<Response>newBuilder()
.retryIfException() // 当发生异常时重试
.retryIfResult(response -> response.getCode() != 200) // 当返回码不为200时重试
.withWaitStrategy(WaitStrategies.fibonacciWait(1000, 10, TimeUnit.SECONDS)) // 等待策略:使用斐波拉契数列递增等待
.withStopStrategy(StopStrategies.stopAfterAttempt(10)) // 重试达到10次时退出
.build();
try {
return retryer.call(new Callable<Response>() {
@Override
public Response call() throws Exception {
log.info("重试调用");
return MockService.call();
}
});
} catch (Exception e) {
log.error(e.getMessage(), e);
}
throw new RuntimeException("重试失败");
}
日志结果如下:
19:20:01.862 [main] INFO RetryTest - 重试调用
19:20:03.868 [main] INFO RetryTest - 重试调用
19:20:05.874 [main] INFO RetryTest - 重试调用
19:20:08.882 [main] INFO RetryTest - 重试调用
19:20:12.892 [main] INFO RetryTest - 重试调用
19:20:18.897 [main] INFO RetryTest - 重试调用
19:20:27.900 [main] INFO RetryTest - 重试调用
19:20:38.903 [main] INFO RetryTest - 重试调用
19:20:49.909 [main] INFO RetryTest - 重试调用
19:21:00.919 [main] INFO RetryTest - 重试调用
19:21:00.921 [main] INFO RetryTest - Response(code=200, msg=处理成功)
可以看出,每次重试的时长根据斐波拉契数列递增,直到递增到每次10秒后就不再递增,并且实际调用在10次内成功。
重试的实现逻辑全部通过配置策略来实现,实现了代码的解耦,可读性显著提升。
下面是主要用到的重试与停止策略:
- retryIfException() 表示抛出Exception异常及其子异常时重试
- retryIfResult(response) 可通过返回的response对象判断哪些返回码需要重试
- withWaitStrategy(WaitStrategies) 配置等待策略,常见的有:
- WaitStrategies.fixedWait() 固定等待n的时间
- WaitStrategies.randomWait() 等待随机时间后重试,可配置等待上限和等待下限
- WaitStragegies.incrementingWait() 按照等差数列增加等待时间
- WaitStragegies.exponentialWait() 按照指数级别增长等待时间
- WaitStragegies.fibonacciWait() 按照斐波拉契数列增加等待时间
- withStopStrategy(StopStrategies) 配置停止重试的策略,常见的有:
- StopStrategies.neverStop() 一直重试,直到返回成功为止
- StopStrategies.stopAfterAttempt() 重试多少次停止
- StopStrategies.stopAfterDelay() 一直重试直到成功或者超过设置的时长为止
- withRetryListener() 注册一个回调函数,当重试时记录重试失败的次数或者日志
小小收获
可以看出,和自己实现的代码相比,使用Guava的retrying小工具实现的重试代码简洁很多,重用性相当高,代码API也设计得相当优雅。
我们在日常的代码编写中,也应该多学习开源项目的设计思路和实现,体会高手是如何对代码进行抽象与解耦,对自己的代码水平也是很好的提高与锻炼。
written by Ryan.Ou