面試官:說出 Java 中的 7 種重試機制

隨著互聯(lián)網的發(fā)展項目中的業(yè)務功能越來越復雜,有一些基礎服務我們不可避免的會去調用一些第三方的接口或者公司內其他項目中提供的服務,但是遠程服務的健壯性和網絡穩(wěn)定性都是不可控因素。
在測試階段可能沒有什么異常情況,但上線后可能會出現(xiàn)調用的接口因為內部錯誤或者網絡波動而出錯或返回系統(tǒng)異常,因此我們必須考慮加上重試機制
重試機制 可以提高系統(tǒng)的健壯性,并且減少因網絡波動依賴服務臨時不可用帶來的影響,讓系統(tǒng)能更穩(wěn)定的運行
1. 手動重試
手動重試:使用 while 語句進行重試:
@Service
public class OrderServiceImpl implements OrderService {
public void addOrder() {
int times = 1;
while (times <= 5) {
try {
// 故意拋異常
int i = 3 / 0;
// addOrder
} catch (Exception e) {
System.out.println("重試" + times + "次");
Thread.sleep(2000);
times++;
if (times > 5) {
throw new RuntimeException("不再重試!");
}
}
}
}
}
運行上述代碼:

上述代碼看上去可以解決重試問題,但實際上存在一些弊端:
-
由于沒有重試間隔,很可能遠程調用的服務還沒有從網絡異常中恢復,所以有可能接下來的幾次調用都會失敗 -
代碼侵入式太高,調用方代碼不夠優(yōu)雅 -
項目中遠程調用的服務可能有很多,每個都去添加重試會出現(xiàn)大量的重復代碼
2. 靜態(tài)代理
上面的處理方式由于需要對業(yè)務代碼進行大量修改,雖然實現(xiàn)了功能,但是對原有代碼的侵入性太強,可維護性差。所以需要使用一種更優(yōu)雅一點的方式,不直接修改業(yè)務代碼,那要怎么做呢?
其實很簡單,直接在業(yè)務代碼的外面再包一層就行了,代理模式在這里就有用武之地了。
@Service
public class OrderServiceProxyImpl implements OrderService {
@Autowired
private OrderServiceImpl orderService;
@Override
public void addOrder() {
int times = 1;
while (times <= 5) {
try {
// 故意拋異常
int i = 3 / 0;
orderService.addOrder();
} catch (Exception e) {
System.out.println("重試" + times + "次");
try {
Thread.sleep(2000);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
times++;
if (times > 5) {
throw new RuntimeException("不再重試!");
}
}
}
}
}
這樣,重試邏輯就都由代理類來完成,原業(yè)務類的邏輯就不需要修改了,以后想修改重試邏輯也只需要修改這個類就行了
代理模式雖然要更加優(yōu)雅,但是如果依賴的服務很多的時候,要為每個服務都創(chuàng)建一個代理類,顯然過于麻煩,而且其實重試的邏輯都大同小異,無非就是重試的次數和延時不一樣而已。如果每個類都寫這么一長串類似的代碼,顯然,不優(yōu)雅!
3. JDK 動態(tài)代理
這時候,動態(tài)代理就閃亮登場了。只需要寫一個代理處理類就 ok 了
public class RetryInvocationHandler implements InvocationHandler {
private final Object subject;
public RetryInvocationHandler(Object subject) {
this.subject = subject;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
int times = 1;
while (times <= 5) {
try {
// 故意拋異常
int i = 3 / 0;
return method.invoke(subject, args);
} catch (Exception e) {
System.out.println("重試【" + times + "】次");
try {
Thread.sleep(2000);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
times++;
if (times > 5) {
throw new RuntimeException("不再重試!");
}
}
}
return null;
}
public static Object getProxy(Object realSubject) {
InvocationHandler handler = new RetryInvocationHandler(realSubject);
return Proxy.newProxyInstance(handler.getClass().getClassLoader(), realSubject.getClass().getInterfaces(), handler);
}
}
測試:
@RestController
@RequestMapping("/order")
public class OrderController {
@Qualifier("orderServiceImpl")
@Autowired
private OrderService orderService;
@GetMapping("/addOrder")
public String addOrder() {
OrderService orderServiceProxy = (OrderService)RetryInvocationHandler.getProxy(orderService);
orderServiceProxy.addOrder();
return "addOrder";
}
}
動態(tài)代理可以將重試邏輯都放到一塊,顯然比直接使用代理類要方便很多,也更加優(yōu)雅。
這里使用的是JDK動態(tài)代理,因此就存在一個天然的缺陷,如果想要被代理的類,沒有實現(xiàn)任何接口,那么就無法為其創(chuàng)建代理對象,這種方式就行不通了
4. CGLib 動態(tài)代理
既然已經說到了 JDK 動態(tài)代理,那就不得不提 CGLib 動態(tài)代理了。使用 JDK 動態(tài)代理對被代理的類有要求,不是所有的類都能被代理,而 CGLib 動態(tài)代理則剛好解決了這個問題
@Component
public class CGLibRetryProxyHandler implements MethodInterceptor {
private Object target;
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
int times = 1;
while (times <= 5) {
try {
// 故意拋異常
int i = 3 / 0;
return method.invoke(target, objects);
} catch (Exception e) {
System.out.println("重試【" + times + "】次");
try {
Thread.sleep(2000);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
times++;
if (times > 5) {
throw new RuntimeException("不再重試!");
}
}
}
return null;
}
public Object getCglibProxy(Object objectTarget){
this.target = objectTarget;
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(objectTarget.getClass());
enhancer.setCallback(this);
Object result = enhancer.create();
return result;
}
}
測試:
@GetMapping("/addOrder")
public String addOrder() {
OrderService orderServiceProxy = (OrderService) cgLibRetryProxyHandler.getCglibProxy(orderService);
orderServiceProxy.addOrder();
return "addOrder";
}
這樣就很棒了,完美的解決了 JDK 動態(tài)代理帶來的缺陷。優(yōu)雅指數上漲了不少。
但這個方案仍舊存在一個問題,那就是需要對原來的邏輯進行侵入式修改,在每個被代理實例被調用的地方都需要進行調整,這樣仍然會對原有代碼帶來較多修改
5. 手動 Aop
考慮到以后可能會有很多的方法也需要重試功能,咱們可以將重試這個共性功能通過 AOP 來實現(xiàn):使用 AOP 來為目標調用設置切面,即可在目標方法調用前后添加一些重試的邏輯
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
自定義注解:
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyRetryable {
// 最大重試次數
int retryTimes() default 3;
// 重試間隔
int retryInterval() default 1;
}
@Slf4j
@Aspect
@Component
public class RetryAspect {
@Pointcut("@annotation(com.hcr.sbes.retry.annotation.MyRetryable)")
private void retryMethodCall(){}
@Around("retryMethodCall()")
public Object retry(ProceedingJoinPoint joinPoint) throws InterruptedException {
// 獲取重試次數和重試間隔
MyRetryable retry = ((MethodSignature)joinPoint.getSignature()).getMethod().getAnnotation(MyRetryable.class);
int maxRetryTimes = retry.retryTimes();
int retryInterval = retry.retryInterval();
Throwable error = new RuntimeException();
for (int retryTimes = 1; retryTimes <= maxRetryTimes; retryTimes++){
try {
Object result = joinPoint.proceed();
return result;
} catch (Throwable throwable) {
error = throwable;
log.warn("調用發(fā)生異常,開始重試,retryTimes:{}", retryTimes);
}
Thread.sleep(retryInterval * 1000L);
}
throw new RuntimeException("重試次數耗盡", error);
}
}
給需要重試的方法添加注解 @MyRetryable:
@Service
public class OrderServiceImpl implements OrderService {
@Override
@MyRetryable(retryTimes = 5, retryInterval = 2)
public void addOrder() {
int i = 3 / 0;
// addOrder
}
}
這樣即不用編寫重復代碼,實現(xiàn)上也比較優(yōu)雅了:一個注解就實現(xiàn)重試。
6. spring-retry
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
開啟重試功能:在啟動類或者配置類上添加 @EnableRetry 注解
在需要重試的方法上添加 @Retryable 注解
@Slf4j
@Service
public class OrderServiceImpl implements OrderService {
@Override
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 2000, multiplier = 2))
public void addOrder() {
System.out.println("重試...");
int i = 3 / 0;
// addOrder
}
@Recover
public void recover(RuntimeException e) {
log.error("達到最大重試次數", e);
}
}
該方法調用后會進行重試,最大重試次數為 3,第一次重試間隔為 2s,之后以 2 倍大小進行遞增,第二次重試間隔為 4 s,第三次為 8s
Spring 的重試機制還支持很多很有用的特性,由三個注解完成:
-
@Retryable -
@Backoff -
@Recover
查看 @Retryable 注解源碼:指定異常重試、次數
public @interface Retryable {
// 設置重試攔截器的 bean 名稱
String interceptor() default "";
// 只對特定類型的異常進行重試。默認:所有異常
Class<? extends Throwable>[] value() default {};
// 包含或者排除哪些異常進行重試
Class<? extends Throwable>[] include() default {};
Class<? extends Throwable>[] exclude() default {};
// l設置該重試的唯一標志,用于統(tǒng)計輸出
String label() default "";
boolean stateful() default false;
// 最大重試次數,默認為 3 次
int maxAttempts() default 3;
String maxAttemptsExpression() default "";
// 設置重試補償機制,可以設置重試間隔,并且支持設置重試延遲倍數
Backoff backoff() default @Backoff;
// 異常表達式,在拋出異常后執(zhí)行,以判斷后續(xù)是否進行重試
String exceptionExpression() default "";
String[] listeners() default {};
}
@Backoff 注解: 指定重試回退策略(如果因為網絡波動導致調用失敗,立即重試可能還是會失敗,最優(yōu)選擇是等待一小會兒再重試。決定等待多久之后再重試的方法。通俗的說,就是每次重試是立即重試還是等待一段時間后重試)
@Recover 注解: 進行善后工作:當重試達到指定次數之后,會調用指定的方法來進行日志記錄等操作
注意:
-
@Recover注解標記的方法必須和被@Retryable標記的方法在同一個類中 -
重試方法拋出的異常類型需要與 recover()方法參數類型保持一致 -
recover()方法返回值需要與重試方法返回值保證一致 -
recover()方法中不能再拋出 Exception,否則會報無法識別該異常的錯誤
這里還需要再提醒的一點是,由于 Spring Retry 用到了 Aspect 增強,所以就會有使用 Aspect 不可避免的坑——方法內部調用,如果被 @Retryable 注解的方法的調用方和被調用方處于同一個類中,那么重試將會失效
通過以上幾個簡單的配置,可以看到 Spring Retry 重試機制考慮的比較完善,比自己寫AOP實現(xiàn)要強大很多
弊端:
但也還是存在一定的不足,Spring的重試機制只支持對 異常 進行捕獲,而無法對返回值進行校驗
@Retryable
public String hello() {
long current = count.incrementAndGet();
System.out.println("第" + current +"次被調用");
if (current % 3 != 0) {
log.warn("調用失敗");
return "error";
}
return "success";
}
因此就算在方法上添加 @Retryable,也無法實現(xiàn)失敗重試
除了使用注解外,Spring Retry 也支持直接在調用時使用代碼進行重試:
@Test
public void normalSpringRetry() {
// 表示哪些異常需要重試,key表示異常的字節(jié)碼,value為true表示需要重試
Map<Class<? extends Throwable>, Boolean> exceptionMap = new HashMap<>();
exceptionMap.put(HelloRetryException.class, true);
// 構建重試模板實例
RetryTemplate retryTemplate = new RetryTemplate();
// 設置重試回退操作策略,主要設置重試間隔時間
FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy();
long fixedPeriodTime = 1000L;
backOffPolicy.setBackOffPeriod(fixedPeriodTime);
// 設置重試策略,主要設置重試次數
int maxRetryTimes = 3;
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(maxRetryTimes, exceptionMap);
retryTemplate.setRetryPolicy(retryPolicy);
retryTemplate.setBackOffPolicy(backOffPolicy);
Boolean execute = retryTemplate.execute(
//RetryCallback
retryContext -> {
String hello = helloService.hello();
log.info("調用的結果:{}", hello);
return true;
},
// RecoverCallBack
retryContext -> {
//RecoveryCallback
log.info("已達到最大重試次數");
return false;
}
);
}
此時唯一的好處是可以設置多種重試策略:
-
NeverRetryPolicy:只允許調用RetryCallback一次,不允許重試 -
AlwaysRetryPolicy:允許無限重試,直到成功,此方式邏輯不當會導致死循環(huán) -
SimpleRetryPolicy:固定次數重試策略,默認重試最大次數為3次,RetryTemplate默認使用的策略 -
TimeoutRetryPolicy:超時時間重試策略,默認超時時間為1秒,在指定的超時時間內允許重試 -
ExceptionClassifierRetryPolicy:設置不同異常的重試策略,類似組合重試策略,區(qū)別在于這里只區(qū)分不同異常的重試 -
CircuitBreakerRetryPolicy:有熔斷功能的重試策略,需設置3個參數openTimeout、resetTimeout和delegate -
CompositeRetryPolicy:組合重試策略,有兩種組合方式,樂觀組合重試策略是指只要有一個策略允許即可以重試,悲觀組合重試策略是指只要有一個策略不允許即可以重試,但不管哪種組合方式,組合中的每一個策略都會執(zhí)行
7. guava-retry
和 Spring Retry 相比,Guava Retry 具有更強的靈活性,并且能夠根據 返回值 來判斷是否需要重試
<dependency>
<groupId>com.github.rholder</groupId>
<artifactId>guava-retrying</artifactId>
<version>2.0.0</version>
</dependency>
@Override
public String guavaRetry(Integer num) {
Retryer<String> retryer = RetryerBuilder.<String>newBuilder()
//無論出現(xiàn)什么異常,都進行重試
.retryIfException()
//返回結果為 error時,進行重試
.retryIfResult(result -> Objects.equals(result, "error"))
//重試等待策略:等待 2s 后再進行重試
.withWaitStrategy(WaitStrategies.fixedWait(2, TimeUnit.SECONDS))
//重試停止策略:重試達到 3 次
.withStopStrategy(StopStrategies.stopAfterAttempt(3))
.withRetryListener(new RetryListener() {
@Override
public <V> void onRetry(Attempt<V> attempt) {
System.out.println("RetryListener: 第" + attempt.getAttemptNumber() + "次調用");
}
})
.build();
try {
retryer.call(() -> testGuavaRetry(num));
} catch (Exception e) {
e.printStackTrace();
}
return "test";
}
先創(chuàng)建一個Retryer實例,然后使用這個實例對需要重試的方法進行調用,可以通過很多方法來設置重試機制:
-
retryIfException():對所有異常進行重試 -
retryIfRuntimeException():設置對指定異常進行重試 -
retryIfExceptionOfType():對所有 RuntimeException 進行重試 -
retryIfResult():對不符合預期的返回結果進行重試
還有五個以 withXxx 開頭的方法,用來對重試策略/等待策略/阻塞策略/單次任務執(zhí)行時間限制/自定義監(jiān)聽器進行設置,以實現(xiàn)更加強大的異常處理:
-
withRetryListener():設置重試監(jiān)聽器,用來執(zhí)行額外的處理工作 -
withWaitStrategy():重試等待策略 -
withStopStrategy():停止重試策略 -
withAttemptTimeLimiter:設置任務單次執(zhí)行的時間限制,如果超時則拋出異常 -
withBlockStrategy():設置任務阻塞策略,即可以設置當前重試完成,下次重試開始前的這段時間做什么事情
總結
從手動重試,到使用 Spring AOP 自己動手實現(xiàn),再到站在巨人肩上使用特別優(yōu)秀的開源實現(xiàn) Spring Retry 和 Google guava-retrying,經過對各種重試實現(xiàn)方式的介紹,可以看到以上幾種方式基本上已經滿足大部分場景的需要:
-
如果是基于 Spring 的項目,使用 Spring Retry 的注解方式已經可以解決大部分問題 -
如果項目沒有使用 Spring 相關框架,則適合使用 Google guava-retrying:自成體系,使用起來更加靈活強大
來源:blog.csdn.net/sco5282/article/details/131390099
程序汪資料鏈接
堪稱神級的Spring Boot手冊,從基礎入門到實戰(zhàn)進階
臥槽!字節(jié)跳動《算法中文手冊》火了,完整版 PDF 開放下載!
臥槽!阿里大佬總結的《圖解Java》火了,完整版PDF開放下載!
字節(jié)跳動總結的設計模式 PDF 火了,完整版開放下載!
歡迎添加程序汪個人微信 itwang007 進粉絲群或圍觀朋友圈
