隨著互聯網的發展項目中的業務功能越來越復雜,有一些基礎服務我們不可避免的會去調用一些第三方的接口或者公司內其他項目中提供的服務,但是遠程服務的健壯性和網絡穩定性都是不可控因素。
在測試階段可能沒有什么異常情況,但上線后可能會出現調用的接口因為內部錯誤或者網絡波動而出錯或返回系統異常,因此我們必須考慮加上重試機制
重試機制 可以提高系統的健壯性,并且減少因網絡波動依賴服務臨時不可用帶來的影響,讓系統能更穩定的運行
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("不再重試!");}}}}
}
運行上述代碼:
上述代碼看上去可以解決重試問題,但實際上存在一些弊端:
- 由于沒有重試間隔,很可能遠程調用的服務還沒有從網絡異常中恢復,所以有可能接下來的幾次調用都會失敗
- 代碼侵入式太高,調用方代碼不夠優雅
- 項目中遠程調用的服務可能有很多,每個都去添加重試會出現大量的重復代碼
2. 靜態代理
上面的處理方式由于需要對業務代碼進行大量修改,雖然實現了功能,但是對原有代碼的侵入性太強,可維護性差。所以需要使用一種更優雅一點的方式,不直接修改業務代碼,那要怎么做呢?
其實很簡單,直接在業務代碼的外面再包一層就行了,代理模式在這里就有用武之地了。
@Service
public class OrderServiceProxyImpl implements OrderService {@Autowiredprivate OrderServiceImpl orderService;@Overridepublic 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;}@Overridepublic 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")@Autowiredprivate 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;@Overridepublic 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
<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}@Recoverpublic 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(//RetryCallbackretryContext -> {String hello = helloService.hello();log.info("調用的結果:{}", hello);return true;},// RecoverCallBackretryContext -> {//RecoveryCallbacklog.info("已達到最大重試次數");return false;});
}
此時唯一的好處是可以設置多種重試策略:
NeverRetryPolicy:只允許調用RetryCallback一次,不允許重試
AlwaysRetryPolicy:允許無限重試,直到成功,此方式邏輯不當會導致死循環
SimpleRetryPolicy:固定次數重試策略,默認重試最大次數為3次,RetryTemplate默認使用的策略
TimeoutRetryPolicy:超時時間重試策略,默認超時時間為1秒,在指定的超時時間內允許重試
ExceptionClassifierRetryPolicy:設置不同異常的重試策略,類似組合重試策略,區別在于這里只區分不同異常的重試
CircuitBreakerRetryPolicy:有熔斷功能的重試策略,需設置3個參數openTimeout、resetTimeout和delegate
CompositeRetryPolicy:組合重試策略,有兩種組合方式,樂觀組合重試策略是指只要有一個策略允許即可以重試,悲觀組合重試策略是指只要有一個策略不允許即可以重試,但不管哪種組合方式,組合中的每一個策略都會執行
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() {@Overridepublic <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:自成體系,使用起來更加靈活強大