隨著互聯網的發展項目中的業務功能越來越複雜,有一些基礎服務我們不可避免的會去調用一些第三方的介面或者公司內其他項目中提供的服務,但是遠程服務的健壯性和網路穩定性都是不可控因素。 在測試階段可能沒有什麼異常情況,但上線後可能會出現調用的介面因為內部錯誤或者網路波動而出錯或返回系統異常,因此我們必須考慮 ...
隨著互聯網的發展項目中的業務功能越來越複雜,有一些基礎服務我們不可避免的會去調用一些第三方的介面或者公司內其他項目中提供的服務,但是遠程服務的健壯性和網路穩定性都是不可控因素。
在測試階段可能沒有什麼異常情況,但上線後可能會出現調用的介面因為內部錯誤或者網路波動而出錯或返回系統異常,因此我們必須考慮加上重試機制
重試機制 可以提高系統的健壯性,並且減少因網路波動依賴服務臨時不可用帶來的影響,讓系統能更穩定的運行。
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("不再重試!");
}
}
}
}
}
運行上述代碼:
上述代碼看上去可以解決重試問題,但實際上存在一些弊端:
- 由於沒有重試間隔,很可能遠程調用的服務還沒有從網路異常中恢復,所以有可能接下來的幾次調用都會失敗
- 代碼侵入式太高,調用方代碼不夠優雅
- 項目中遠程調用的服務可能有很多,每個都去添加重試會出現大量的重覆代碼
推薦一個開源免費的 Spring Boot 實戰項目:
2. 靜態代理
上面的處理方式由於需要對業務代碼進行大量修改,雖然實現了功能,但是對原有代碼的侵入性太強,可維護性差。所以需要使用一種更優雅一點的方式,不直接修改業務代碼,那要怎麼做呢?
其實很簡單,直接在業務代碼的外面再包一層就行了,代理模式在這裡就有用武之地了。
@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("不再重試!");
}
}
}
}
}
這樣,重試邏輯就都由代理類來完成,原業務類的邏輯就不需要修改了,以後想修改重試邏輯也只需要修改這個類就行了
代理模式雖然要更加優雅,但是如果依賴的服務很多的時候,要為每個服務都創建一個代理類,顯然過於麻煩,而且其實重試的邏輯都大同小異,無非就是重試的次數和延時不一樣而已。如果每個類都寫這麼一長串類似的代碼,顯然,不優雅!
3. JDK 動態代理
這時候,動態代理就閃亮登場了。只需要寫一個代理處理類就 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";
}
}
動態代理可以將重試邏輯都放到一塊,顯然比直接使用代理類要方便很多,也更加優雅。
這裡使用的是JDK動態代理,因此就存在一個天然的缺陷,如果想要被代理的類,沒有實現任何介面,那麼就無法為其創建代理對象,這種方式就行不通了
4. CGLib 動態代理
既然已經說到了 JDK 動態代理,那就不得不提 CGLib 動態代理了。使用 JDK 動態代理對被代理的類有要求,不是所有的類都能被代理,而 CGLib 動態代理則剛好解決了這個問題
@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 動態代理帶來的缺陷。優雅指數上漲了不少。
但這個方案仍舊存在一個問題,那就是需要對原來的邏輯進行侵入式修改,在每個被代理實例被調用的地方都需要進行調整,這樣仍然會對原有代碼帶來較多修改
5. 手動 Aop
考慮到以後可能會有很多的方法也需要重試功能,咱們可以將重試這個共性功能通過 AOP 來實現,使用 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("調用發生異常,開始重試,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
}
}
這樣即不用編寫重覆代碼,實現上也比較優雅了:一個註解就實現重試。
6. spring-retry
Spring Boot 基礎就不介紹了,推薦看這個實戰項目:
<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設置該重試的唯一標誌,用於統計輸出
String label() default "";
boolean stateful() default false;
// 最大重試次數,預設為 3 次
int maxAttempts() default 3;
String maxAttemptsExpression() default "";
// 設置重試補償機制,可以設置重試間隔,並且支持設置重試延遲倍數
Backoff backoff() default @Backoff;
// 異常表達式,在拋出異常後執行,以判斷後續是否進行重試
String exceptionExpression() default "";
String[] listeners() default {};
}
@Backoff 註解: 指定重試回退策略(如果因為網路波動導致調用失敗,立即重試可能還是會失敗,最優選擇是等待一小會兒再重試。決定等待多久之後再重試的方法。通俗的說,就是每次重試是立即重試還是等待一段時間後重試)
@Recover 註解: 進行善後工作:當重試達到指定次數之後,會調用指定的方法來進行日誌記錄等操作
註意:
@Recover
註解標記的方法必須和被@Retryable
標記的方法在同一個類中- 重試方法拋出的異常類型需要與
recover()
方法參數類型保持一致 recover()
方法返回值需要與重試方法返回值保證一致recover()
方法中不能再拋出 Exception,否則會報無法識別該異常的錯誤
這裡還需要再提醒的一點是,由於 Spring Retry 用到了 Aspect 增強,所以就會有使用 Aspect 不可避免的坑——方法內部調用,如果被 @Retryable
註解的方法的調用方和被調用方處於同一個類中,那麼重試將會失效
通過以上幾個簡單的配置,可以看到 Spring Retry 重試機制考慮的比較完善,比自己寫AOP實現要強大很多
弊端:
但也還是存在一定的不足,Spring的重試機制只支持對 異常 進行捕獲,而無法對返回值進行校驗
@Retryable
public String hello() {
long current = count.incrementAndGet();
System.out.println("第" + current +"次被調用");
if (current % 3 != 0) {
log.warn("調用失敗");
return "error";
}
return "success";
}
因此就算在方法上添加 @Retryable
,也無法實現失敗重試
除了使用註解外,Spring Retry 也支持直接在調用時使用代碼進行重試:
@Test
public void normalSpringRetry() {
// 表示哪些異常需要重試,key表示異常的位元組碼,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
:允許無限重試,直到成功,此方式邏輯不當會導致死迴圈SimpleRetryPolicy
:固定次數重試策略,預設重試最大次數為3次,RetryTemplate預設使用的策略TimeoutRetryPolicy
:超時時間重試策略,預設超時時間為1秒,在指定的超時時間內允許重試ExceptionClassifierRetryPolicy
:設置不同異常的重試策略,類似組合重試策略,區別在於這裡只區分不同異常的重試CircuitBreakerRetryPolicy
:有熔斷功能的重試策略,需設置3個參數openTimeout、resetTimeout和delegateCompositeRetryPolicy
:組合重試策略,有兩種組合方式,樂觀組合重試策略是指只要有一個策略允許即可以重試,悲觀組合重試策略是指只要有一個策略不允許即可以重試,但不管哪種組合方式,組合中的每一個策略都會執行
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()
//無論出現什麼異常,都進行重試
.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";
}
先創建一個Retryer實例,然後使用這個實例對需要重試的方法進行調用,可以通過很多方法來設置重試機制:
retryIfException()
:對所有異常進行重試retryIfRuntimeException()
:設置對指定異常進行重試retryIfExceptionOfType()
:對所有 RuntimeException 進行重試retryIfResult()
:對不符合預期的返回結果進行重試
還有五個以 withXxx 開頭的方法,用來對重試策略/等待策略/阻塞策略/單次任務執行時間限制/自定義監聽器進行設置,以實現更加強大的異常處理:
withRetryListener()
:設置重試監聽器,用來執行額外的處理工作withWaitStrategy()
:重試等待策略withStopStrategy()
:停止重試策略withAttemptTimeLimiter
:設置任務單次執行的時間限制,如果超時則拋出異常withBlockStrategy()
:設置任務阻塞策略,即可以設置當前重試完成,下次重試開始前的這段時間做什麼事情
總結
從手動重試,到使用 Spring AOP 自己動手實現,再到站在巨人肩上使用特別優秀的開源實現 Spring Retry 和 Google guava-retrying,經過對各種重試實現方式的介紹,可以看到以上幾種方式基本上已經滿足大部分場景的需要:
- 如果是基於 Spring 的項目,使用 Spring Retry 的註解方式已經可以解決大部分問題
- 如果項目沒有使用 Spring 相關框架,則適合使用 Google guava-retrying:自成體系,使用起來更加靈活強大
版權聲明:本文為CSDN博主「sco5282」的原創文章,遵循CC 4.0 BY-SA版權協議,轉載請附上原文出處鏈接及本聲明。原文鏈接:https://blog.csdn.net/sco5282/article/details/131390099
近期熱文推薦:
1.1,000+ 道 Java面試題及答案整理(2022最新版)
4.別再寫滿屏的爆爆爆炸類了,試試裝飾器模式,這才是優雅的方式!!
覺得不錯,別忘了隨手點贊+轉發哦!