首頁技術(shù)文章正文

Java培訓(xùn):重試實(shí)現(xiàn)高可用方案

更新時(shí)間:2022-11-16 來源:黑馬程序員 瀏覽量:

IT培訓(xùn)班

  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次才成功一次

1668565604725_1.jpg

  > 同時(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)用成功**

1668565751713_2.jpg

  > 這樣即不用編寫重復(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)用成功

1668565859028_3.jpg

  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());

  }

  ```

1668565956782_4.jpg

  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)用一次,無論成功還是失敗

1668566056214_5.jpg

  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);
}

  ...

1668566176790_6.jpg

  ```asciiarmor

  另外,也可以修改原始方法的失敗返回實(shí)現(xiàn):發(fā)現(xiàn)不管是拋出異常失敗還是返回error失敗,都能進(jìn)行重試

  ```

1668566192670_7.jpg

  另外,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)用

1668566232211_8.jpg

  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組件提供的重試即可

分享到:
在線咨詢 我要報(bào)名
和我們在線交談!