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