更新時間:2022-11-16 來源:黑馬程序員 瀏覽量:
1、背景介紹
隨著互聯網的發展項目中的業務功能越來越復雜,有一些基礎服務我們不可避免的會去調用一些第三方的接口或者公司內其他項目中提供的服務,但是遠程服務的健壯性和網絡穩定性都是不可控因素。在測試階段可能沒有什么異常情況,但上線后可能會出現調用的接口因為內部錯誤或者網絡波動而出錯或返回系統異常,因此我們必須考慮加上重試機制。
重試機制可以提高系統的健壯性,并且減少因網絡波動依賴服務臨時不可用帶來的影響,讓系統能更穩定的運行。
2、測試環境
2.1 模擬遠程調用
本文會用如下方法來模擬遠程調用的服務,其中**每調用3次才會成功一次:
@Slf4j @Service public class RemoteService { /** * 記錄調用次數 */ private final static AtomicLong count = new AtomicLong(0); /** * 每調用3次會成功一次 */ public String hello() { long current = count.incrementAndGet(); System.out.println("第" + current +"次被調用"); if (current % 3 != 0) { log.warn("調用失敗"); return "error"; } return "success"; } }
2.2 單元測試
編寫單元測試:
@SpringBootTest public class RemoteServiceTest { @Autowired private RemoteService remoteService; @Test public void hello() { for (int i = 1; i < 9; i++) { System.out.println("遠程調用:" + remoteService.hello()); } } }
執行后查看結果:驗證是否調用3次才成功一次
> 同時在上邊的單元測試中用for循環進行失敗重試:在調用的時候如果失敗則會進行了重復調用,直到成功。
> @Test > public void testRetry() { > for (int i = 1; i < 9; i++) { > String result = remoteService.hello(); > if (!result.equals("success")) { > System.out.println("調用失敗"); > continue; > } > System.out.println("遠程調用成功"); > break; > } > }
上述代碼看上去可以解決問題,但實際上存在一些弊端:
- 由于沒有重試間隔,很可能遠程調用的服務還沒有從網絡異常中恢復,所以有可能接下來的幾次調用都會失敗
- 代碼侵入式太高,調用方代碼不夠優雅
- 項目中遠程調用的服務可能有很多,每個都去添加重試會出現大量的重復代碼
3、自己動手使用AOP實現重試
考慮到以后可能會有很多的方法也需要重試功能,咱們可以將**重試這個共性功能**通過AOP來實現:
使用AOP來為目標調用設置切面,即可在目標方法調用前后添加一些重試的邏輯。
1)創建一個注解:用來標識需要重試的方法
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Retry { /** * 最多重試次數 */ int attempts() default 3; /** * 重試間隔 */ int interval() default 1; }
2)在需要重試的方法上加上注解:
//指定重試次數和間隔 @Retry(attempts = 4, interval = 5) public String hello() { long current = count.incrementAndGet(); System.out.println("第" + current +"次被調用"); if (current % 3 != 0) { log.warn("調用失敗"); return "error"; } return "success"; }
3)編寫AOP切面類,引入依賴:
<dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> </dependency>
/** * 重試切面類 */ @Aspect @Component @Slf4j public class RetryAspect { /** * 定義切入點 */ @Pointcut("@annotation(cn.itcast.annotation.Retry)") private void pt() {} /** * 定義重試的共性功能 */ @Around("pt()") public Object retry(ProceedingJoinPoint joinPoint) throws InterruptedException { //獲取@Retry注解上指定的重試次數和重試間隔 MethodSignature sign = (MethodSignature) joinPoint.getSignature(); Retry retry = sign.getMethod().getAnnotation(Retry.class); int maxRetry = retry.attempts(); //最多重試次數 int interval = retry.interval(); //重試間隔 Throwable ex = new RuntimeException();//記錄重試失敗異常 for (int i = 1; i <= maxRetry; i++) { try { Object result = joinPoint.proceed(); //第一種失敗情況:遠程調用成功返回,但結果是失敗了 if (result.equals("error")) { throw new RuntimeException("遠程調用返回失敗"); } return result; } catch (Throwable throwable) { //第二種失敗情況,遠程調用直接出現異常 ex = throwable; } //按照注解上指定的重試間隔執行下一次循環 Thread.sleep(interval * 1000); log.warn("調用失敗,開始第{}次重試", i); } throw new RuntimeException("重試次數耗盡", ex); } }
4)編寫單元測試
@Test public void testAOP() { System.out.println(remoteService.hello()); }
調用失敗后:等待5毫秒后會進行重試,直到**重試到達指定的上限**或者**調用成功**
> 這樣即不用編寫重復代碼,實現上也比較優雅了:一個注解就實現重試。
>
4、站在巨人肩上:Spring Retry
目前在Java開發領域,Spring框架基本已經是企業開發的事實標準。如果項目中已經引入了Spring,那咱們就可以直接使用Spring Retry,可以比較方便快速的實現重試功能,還不需要自己動手重新造輪子。
4.1 簡單使用
下面咱們來一塊來看看這個輪子究竟好不好使吧。
1)先引入重試所需的jar包
<dependency> <groupId>org.springframework.retry</groupId> <artifactId>spring-retry</artifactId> </dependency>
2)開啟重試功能:在啟動類或者配置類上添加@EnableRetry注解:
@SpringBootApplication @EnableRetry public class RemoteApplication { public static void main(String[] args) { SpringApplication.run(RemoteApplication.class); } }
3)在需要重試的方法上添加@Retryable注解
/** * 每調用3次會成功一次 */ @Retryable //默認重試三次,重試間隔為1秒 public String hello() { long current = count.incrementAndGet(); System.out.println("第" + current + "次被調用"); if (current % 3 != 0) { log.warn("調用失敗"); throw new RuntimeException("發生未知異常"); } return "success"; }
4)編寫單元測試,驗證效果
@Test public void testSpringRetry() { System.out.println(remoteService.hello()); }
通過日志可以看到:第一次調用失敗后,經過兩次重試,重試間隔為1s,最終調用成功
4.2 更靈活的重試設置
4.2.1 指定異常重試和次數
Spring的重試機制還支持很多很有用的特性:
- 可以指定只對特定類型的異常進行重試,這樣如果拋出的是其它類型的異常則不會進行重試,就可以對重試進行更細粒度的控制。
//@Retryable //默認為空,會對所有異常都重試 @Retryable(value = {MyRetryException.class}) //只有出現MyRetryException才重試 public String hello(){ //... }
- 也可以使用include和exclude來指定包含或者排除哪些異常進行重試。
@Retryable(exclude = {NoRetryException.class}) //出現NoRetryException異常不重試
- 可以用maxAttemps指定最大重試次數,默認為3次。
@Retryable(maxAttempts = 5)
4.2.2 指定重試回退策略
如果因為網絡波動導致調用失敗,立即重試可能還是會失敗,最優選擇是等待一小會兒再重試。決定等待多久之后再重試的方法叫做重試回退策略。通俗的說,就是每次重試是立即重試還是等待一段時間后重試。
默認情況下是立即重試,如果要指定策略則可以通過注解中backoff屬性來快速實現:
- 添加第二個重試方法,改為調用4次才成功一次。
- 指定重試回退策略為:延遲5秒后進行第一次重試,后面重試間隔依次變為原來的2倍(10s, 15s)
- 這種策略一般稱為指數回退,Spring中也提供很多其他方式的策略(實現BackOffPolicy接口的都是)
/** * 每調用4次會成功一次 */ @Retryable( maxAttempts = 3, //指定重試次數 //調用失敗后,等待5s重試,后面重試間隔依次變為原來的2倍 backoff = @Backoff(delay = 5000, multiplier = 2)) public String hello2() { long current = count.incrementAndGet(); System.out.println("第" + current + "次被調用"); if (current % 4 != 0) { log.warn("調用失敗"); throw new RuntimeException("發生未知異常"); } return "success"; }
編寫單元測試驗證:
```
@Test
public void testSpringRetry2() {
System.out.println(remoteService.hello2());
}
```
4.2.3 指定熔斷機制
重試機制還支持使用`@Recover` 注解來進行善后工作:當重試達到指定次數之后,會調用指定的方法來進行日志記錄等操作。
在重試方法的同一個類中編寫熔斷實現:
/** * 每調用4次會成功一次 */ @Retryable( maxAttempts = 3, //指定重試次數 //調用失敗后,等待5s重試,后面重試間隔依次變為原來的2倍 backoff = @Backoff(delay = 5000, multiplier = 2)) public String hello2() { long current = count.incrementAndGet(); System.out.println("第" + current + "次被調用"); if (current % 4 != 0) { log.warn("調用失敗"); throw new RuntimeException("發生未知異常"); } return "success"; }
```asciiarmor
注意:
1、@Recover注解標記的方法必須和被@Retryable標記的方法在同一個類中
2、重試方法拋出的異常類型需要與recover方法參數類型保持一致
3、recover方法返回值需要與重試方法返回值保證一致
4、recover方法中不能再拋出Exception,否則會報無法識別該異常的錯誤
```
總結
通過以上幾個簡單的配置,可以看到Spring Retry重試機制考慮的比較完善,比自己寫AOP實現要強大很多。
4.3 弊端
Spring Retry雖然功能強大使用簡單,但是也存在一些不足,Spring的重試機制只支持對異常進行捕獲,而無法對返回值進行校驗,具體看如下的方法:
```asciiarmor
1、方法執行失敗,但沒有拋出異常,只是在返回值中標識失敗了(return error;)
```
/** * 每調用3次會成功一次 */ @Retryable public String hello3() { long current = count.incrementAndGet(); System.out.println("第" + current +"次被調用"); if (current % 3 != 0) { log.warn("調用失敗"); return "error"; } return "success"; }
```asciiarmor
2、因此就算在方法上添加@Retryable,也無法實現失敗重試
```
編寫單元測試:
@Test public void testSpringRetry3() { System.out.println(remoteService.hello3()); }
輸出結果:只會調用一次,無論成功還是失敗
5、另一個巨人谷歌 guava-retrying
5.1 Guava 介紹
Guava是一個基于Java的開源類庫,其中包含谷歌在由他們很多項目使用的核心庫。這個庫目的是為了方便編碼,并減少編碼錯誤。這個庫提供用于集合,緩存,并發性,常見注解,字符串處理,I/O和驗證的實用方法。
源碼地址:https://github.com/google/guava
優勢:
- 標準化 - Guava庫是由谷歌托管。
- 高效 - 可靠,快速和有效的擴展JAVA標準庫
- 優化 -Guava庫經過高度的優化。
當然,此處咱們主要來看下 guava-retrying 功能。
5.2 使用guava-retrying
`guava-retrying`是Google Guava庫的一個擴展包,可以對任意方法的調用創建可配置的重試。該擴展包比較簡單,也已經好多年沒有維護,但這完全不影響它的使用,因為功能已經足夠完善。
源碼地址:https://github.com/rholder/guava-retrying
和Spring Retry相比,Guava Retry具有**更強的靈活性**,并且能夠根據返回值來判斷是否需要重試。
1)添加依賴坐標
<!--guava retry是基于guava實現的,因此需要先添加guava坐標--> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <!--繼承了SpringBoot后,父工程已經指定了版本--> <!--<version>29.0-jre</version>--> </dependency> <dependency> <groupId>com.github.rholder</groupId> <artifactId>guava-retrying</artifactId> <version>2.0.0</version> </dependency>
2)編寫遠程調用方法,不指定任何Spring Retry中的注解
/** * 每調用3次會成功一次 */ public String hello4() { long current = count.incrementAndGet(); System.out.println("第" + current + "次被調用"); if (current % 3 != 0) { log.warn("調用失敗"); //throw new RuntimeException("發生未知異常"); return "error"; } return "success"; }
3)編寫單元測試:創建Retryer實例,指定如下幾個配置
- 出現什么類型異常后進行重試:retryIfException()
- 返回值是什么時進行重試:retryIfResult()
- 重試間隔:withWaitStrategy()
- 停止重試策略:withStopStrategy()
@Test public void testGuavaRetry() { Retryer<String> retryer = RetryerBuilder.<String>newBuilder() .retryIfException() //無論出現什么異常,都進行重試 //返回結果為 error時,進行重試 .retryIfResult(result -> Objects.equals(result, "error")) //重試等待策略:等待5s后再進行重試 .withWaitStrategy(WaitStrategies.fixedWait(5, TimeUnit.SECONDS)) //重試停止策略:重試達到5次 .withStopStrategy(StopStrategies.stopAfterAttempt(5)) .build(); }
4)調用方法,驗證重試效果
try { retryer.call(() -> { String result = remoteService.hello4(); System.out.println(result); return result; }); } catch (Exception e) { System.out.println("exception:" + e); }
...
```asciiarmor
另外,也可以修改原始方法的失敗返回實現:發現不管是拋出異常失敗還是返回error失敗,都能進行重試
```
另外,guava-retrying還有很多更靈活的配置和使用方式:
1. 通過retryIfException 和 retryIfResult 來判斷什么時候進行重試,**同時支持多個且能兼容**。
2. 設置重試監聽器RetryListener,可以指定發生重試后,做一些日志記錄或其他操作
.withRetryListener(new RetryListener() { @Override public <V> void onRetry(Attempt<V> attempt) { System.out.println("RetryListener: 第" + attempt.getAttemptNumber() + "次調用"); } }) //也可以注冊多個RetryListener,會按照注冊順序依次調用
5.3 弊端
雖然guava-retrying提供更靈活的使用,但是官方沒有**提供注解方式**,頻繁使用會有點麻煩。大家可以自己動手通過Spring AOP將實現封裝為注解方式。
6、微服務架構中的重試(Feign+Ribbon)
在日常開發中,尤其是在微服務盛行的年代,我們在調用外部接口時,經常會因為第三方接口超時、限流等問題從而造成接口調用失敗,那么此時我們通常會對接口進行重試,可以使用Spring Cloud中的Feign+Ribbon進行配置后快速的實現重試功能,經過簡單配置即可:
spring: cloud: loadbalancer: retry: enabled: true #開啟重試功能 ribbon: ConnectTimeout: 2000 #連接超時時間,ms ReadTimeout: 5000 #等待請求響應的超時時間,ms MaxAutoRetries: 1 #同一臺服務器上的最大重試次數 MaxAutoRetriesNextServer: 2 #要重試的下一個服務器的最大數量 retryableStatusCodes: 500 #根據返回的狀態碼判斷是否重試 #是否對所有請求進行失敗重試 OkToRetryOnAllOperations: false #只對Get請求進行重試 #OkToRetryOnAllOperations: true #對所有請求進行重試
```
```asciiarmor
注意:
對接口進行重試時,必須考慮具體請求方式和是否保證了冪等;如果接口沒有保證冪等性(GET請求天然冪等),那么重試Post請求(新增操作),就有可能出現重復添加
```
7、總結
從手動重試,到使用Spring AOP自己動手實現,再到站在巨人肩上使用特別優秀的開源實現Spring Retry和Google guava-retrying,經過對各種重試實現方式的介紹,可以看到以上幾種方式基本上已經滿足大部分場景的需要:
- 如果是基于Spring的項目,使用Spring Retry的注解方式已經可以解決大部分問題
- 如果項目沒有使用Spring相關框架,則適合使用Google guava-retrying:自成體系,使用起來更加靈活強大
- 如果采用微服務架構開發,那直接使用Feign+Ribbon組件提供的重試即可