Java面向容錯程式設計之重試機制

張哥說技術發表於2024-01-11

Java面向容錯程式設計之重試機制

來源:阿里雲開發者

阿里妹導讀

容錯程式設計是一種重要的程式設計思想,它能夠提高應用程式的可靠性和穩定性,同時提高程式碼的健壯性。本文總結了一些作者在面對服務失敗時如何進行優雅重試,比如aop、cglib等同時對重試工具\元件的原始碼和注意事項進行總結分析。
容錯程式設計是一種旨在確保應用程式的可靠性和穩定性的程式設計思想,它採取以下措施:

1.異常處理:透過捕獲和處理異常來避免應用程式崩潰。

2.錯誤處理:透過檢查錯誤程式碼並採取適當的措施,如重試或回滾,來處理錯誤。

3.重試機制:在出現錯誤時,嘗試重新執行程式碼塊,直到成功或達到最大嘗試次數。

4.備份機制:在主要系統出現故障時,切換到備用系統以保持應用程式的正常執行。

5.日誌記錄:記錄錯誤和異常資訊以便後續排查問題。
容錯程式設計是一種重要的程式設計思想,它能夠提高應用程式的可靠性和穩定性,同時提高程式碼的健壯性。

一、為什麼需要重試

在做業務技術時,設計具備可複用、可擴充套件、可編排的系統架構至關重要,它直接決定著業務需求迭代的效率。但同時業務技術人員也應具備悲觀思維:在分散式環境下因單點問題導致的HSF服務瞬時抖動並不少見,比如系統瞬時抖動、單點故障、服務超時、服務異常、中介軟體抖動、網路超時、配置錯誤等等各種軟硬體問題。如果直接忽略掉這些異常則會降低服務的健壯性,嚴重時會影響使用者體驗、引起使用者投訴,甚至導致系統故障。因此在做方案設計和技術實現時要充分考慮各種失敗場景,針對性地做防禦性程式設計。
我們在呼叫第三方介面時,經常會遇到失敗的情況,針對這些情況,我們通常會處理失敗重試和失敗落庫邏輯。然而,重試並不是適用於所有場景的,例如引數校驗不合法、讀寫操作是否適合重試,資料是否冪等。遠端呼叫超時或網路突然中斷則可以進行重試。我們可以設定多次重試來提高呼叫成功的機率。為了方便後續排查問題和統計失敗率,我們也可以將失敗次數和是否成功記錄落庫,便於統計和定時任務兜底重試。
培訓研發組在大本營、培訓業務、本地e站等多業務對各種失敗場景做了充分演練,並對其中一些核心流程做了各種形式的失敗重試處理,比如騎手考試提交、同步資料到洞察平臺、獲取量子平臺圈人標籤等。本文總結了一些我們在面對服務失敗時如何進行優雅重試,比如aop、cglib等同時對重試工具\元件的原始碼和注意事項進行總結分析。

Java面向容錯程式設計之重試機制

二、如何重試


2.1   簡單重試方法

測試demo





















@Testpublic Integer sampleRetry(int code) {    System.out.println("sampleRetry,時間:" + LocalTime.now());    int times = 0;    while (times < MAX_TIMES) {        try {            postCommentsService.retryableTest(code);        } catch (Exception e) {            times++;            System.out.println("重試次數" + times);            if (times >= MAX_TIMES) {                //記錄落庫,後續定時任務兜底重試                //do something record...                 throw new RuntimeException(e);            }        }    }    System.out.println("sampleRetry,返回!");    return null;}


2.2    動態代理模式版本

在某些情況下,一個物件不適合或不能直接引用另一個物件,這時我們可以使用代理物件來起到中介作用,它可以在客戶端和目標物件之間進行通訊。使用代理物件的好處在於,它相容性比較好,每個重試方法都可以呼叫。

使用方式








































public class DynamicProxyTest implements InvocationHandler {    private final Object subject;    public DynamicProxy(Object subject) {        this.subject = subject;    }
     /**     * 獲取動態代理     *     * @param realSubject 代理物件     */    public static Object getProxy(Object realSubject) {        //    我們要代理哪個真實物件,就將該物件傳進去,最後是透過該真實物件來呼叫其方法的        InvocationHandler handler = new DynamicProxy(realSubject);        return Proxy.newProxyInstance(handler.getClass().getClassLoader(),                realSubject.getClass().getInterfaces(), handler);    }
   @Override    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {        int times = 0;        while (times < MAX_TIMES) {            try {                // 當代理物件呼叫真實物件的方法時,其會自動的跳轉到代理物件關聯的handler物件的invoke方法來進行呼叫                return method.invoke(subject, args);            } catch (Exception e) {                times++;                System.out.println("重試次數" + times);                if (times >= MAX_TIMES) {                    //記錄落庫,後續定時任務兜底重試                    //do something record...                    throw new RuntimeException(e);                }            }        }
       return null;    }}

測試demo







@Test public Integer V2Retry(int code) {         RetryableTestServiceImpl realService = new RetryableTestServiceImpl();        RetryableTesterviceImpl proxyService = (RetryableTestServiceImpl) DynamicProxyTest.getProxy(realService);        proxyService.retryableTest(code);}


2.3  位元組碼技術 生成代理重試

CGLIB 是一種程式碼生成庫,它能夠擴充套件 Java 類並在執行時實現介面。它具有功能強大、高效能和高質量的特點。使用 CGLIB 可以生成子類來代理目標物件,從而在不改變原始類的情況下,實現對其進行擴充套件和增強。這種技術被廣泛應用於 AOP 框架、ORM 框架、快取框架以及其他許多 Java 應用程式中。CGLIB 透過生成位元組碼來建立代理類,具有較高的效能。

使用方式




































public class CglibProxyTest implements MethodInterceptor {
   @Override    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {        int times = 0;        while (times < MAX_TIMES) {            try {                //透過代理子類呼叫父類的方法                return methodProxy.invokeSuper(o, objects);            } catch (Exception e) {                times++;
               if (times >= MAX_TIMES) {                    throw new RuntimeException(e);                }            }        }        return null;    }
   /**     * 獲取代理類     * @param clazz 類資訊     * @return 代理類結果     */    public Object getProxy(Class clazz){        Enhancer enhancer = new Enhancer();        //目標物件類        enhancer.setSuperclass(clazz);        enhancer.setCallback(this);        //透過位元組碼技術建立目標物件類的子類例項作為代理        return enhancer.create();    }
}


測試demo






@Test public Integer CglibRetry(int code) {        RetryableTestServiceImpl proxyService = (RetryableTestServiceImpl) new CglibProxyTest().getProxy(RetryableTestServiceImpl.class);        proxyService.retryableTest(code);}


2.4 HSF呼叫超時重試

在我們日常開發中,呼叫第三方 HSF服務時出現瞬時抖動是很常見的。為了降低呼叫超時對業務造成的影響,我們可以根據業務特性和下游服務特性,使用 HSF 同步重試的方式。如果使用的框架沒有特別設定,HSF 介面超時預設不會進行自動重試。在註解 @HSFConsumer 中,有一個引數 retries,透過它可以設定失敗重試的次數。預設情況下,這個引數的值預設是0。

Java面向容錯程式設計之重試機制





  @HSFConsumer(serviceVersion = "1.0.0", serviceGroup = "hsf",clientTimeout = 2000, methodSpecials = {            @ConsumerMethodSpecial(methodName = "methodA", clientTimeout = "100", retries = "2"),            @ConsumerMethodSpecial(methodName = "methodB", clientTimeout = "200", retries = "1")})    private XxxHSFService xxxHSFServiceConsumer;

HSFConsumer超時重試原理

一次HSF的服務呼叫過程示意圖:

Java面向容錯程式設計之重試機制

HSF 超時重試發生在AsyncToSyncInvocationHandler # invokeType(.): 如果配置的retries引數大於0則使用retry()方法進行重試,且重試只發生在TimeoutException異常的情況下。
原始碼分析



































private RPCResult invokeType(Invocation invocation, InvocationHandler invocationHandler) throws Throwable {        final ConsumerMethodModel consumerMethodModel = invocation.getClientInvocationContext().getMethodModel();        String methodName = consumerMethodModel.getMethodName(invocation.getHsfRequest());
       final InvokeMode invokeType = getInvokeType(consumerMethodModel.getMetadata(), methodName);        invocation.setInvokeType(invokeType);
       ListenableFuture<RPCResult> future = invocationHandler.invoke(invocation);
       if (InvokeMode.SYNC == invokeType) {            if (invocation.getBroadcastFutures() != null && invocation.getBroadcastFutures().size() > 1) {                //broadcast                return broadcast(invocation, future);            } else if (consumerMethodModel.getExecuteTimes() > 1) {                //retry                return retry(invocation, invocationHandler, future, consumerMethodModel.getExecuteTimes());            } else {                //normal                return getRPCResult(invocation, future);            }        } else {            // pseudo response, should be ignored            HSFRequest request = invocation.getHsfRequest();            Object appResponse = null;            if (request.getReturnClass() != null) {                appResponse = ReflectUtils.defaultReturn(request.getReturnClass());            }            HSFResponse hsfResponse = new HSFResponse();            hsfResponse.setAppResponse(appResponse);
           RPCResult rpcResult = new RPCResult();            rpcResult.setHsfResponse(hsfResponse);            return rpcResult;        }    }
從上面這段程式碼可以看出,retry 重試只有發生在同步呼叫當中。消費者方法的後設資料的執行次數大於1(consumerMethodModel.getExecuteTimes() > 1)時會走到retry方法去嘗試重試:































private RPCResult retry(Invocation invocation, InvocationHandler invocationHandler,                            ListenableFuture<RPCResult> future, int executeTimes) throws Throwable {
       int retryTime = 0;
       while (true) {            retryTime++;            if (retryTime > 1) {                future = invocationHandler.invoke(invocation);            }
           int timeout = -1;            try {                timeout = (int) invocation.getInvokerContext().getTimeout();                RPCResult rpcResult = future.get(timeout, TimeUnit.MILLISECONDS);
               return rpcResult;            } catch (ExecutionException e) {                throw new HSFTimeOutException(getErrorLog(e.getMessage()), e);            } catch (TimeoutException e) {                //retry only when timeout                if (retryTime < executeTimes) {                    continue;                } else {                    throw new HSFTimeOutException(getErrorLog(e.getMessage()), timeout + "", e);                }            } catch (Throwable e) {                throw new HSFException("", e);            }        }    }
HSFConsumer超時重試原理利用的是簡單的while迴圈+ try-catch

缺陷:

1、只有方法被同步呼叫時候才會發生重試。

2、只有hsf介面出現TimeoutException才會呼叫重試方法。

3、如果為某個 HSFConsumer 中的 method 設定了 retries 引數,當方法返回時出現超時異常,HSF SDK 會自動重試。重試實現的方式是一個 while+ try-catch迴圈。所以,如果自動重試的介面變得緩慢,而且重試次數設定得過大,會導致 RT 變長,極端情況下還可能導致 HSF 執行緒池被打滿。因此,HSF 的自動重試特性是一個基礎、簡單的能力,不推薦大面積使用。


2.5 Spring Retry

Spring Retry 是 Spring 系列中的一個子專案,它提供了宣告式的重試支援,可以幫助我們以標準化的方式處理任何特定操作的重試。這個框架非常適合於需要進行重試的業務場景,比如網路請求、資料庫訪問等。使用 Spring Retry,我們可以使用註解來設定重試策略,而不需要編寫冗長的程式碼。所有的配置都是基於註解的,這使得使用 Spring Retry 變得非常簡單和直觀。

POM依賴









<dependency>    <groupId>org.springframework.retry</groupId>    <artifactId>spring-retry</artifactId></dependency><dependency>    <groupId>org.springframework</groupId>    <artifactId>spring-aspects</artifactId></dependency>

啟用@Retryable

引入spring-retry jar包後在spring boot的啟動類上打上@EnableRetry註解。









@EnableRetry@SpringBootApplication(scanBasePackages = {"me.ele.camp"},excludeName = {"me.ele.oc.orm.OcOrmAutoConfiguraion"})@ImportResource({"classpath*:sentinel-tracer.xml"})public class Application {
   public static void main(String[] args) {        System.setProperty("APPID","alsc-info-local-camp");        System.setProperty("project.name","alsc-info-local-camp");}

service實現類新增@Retryable註解






















    @Override    @Retryable(value = BizException.class, maxAttempts = 6)    public Integer retryableTest(Integer code) {        System.out.println("retryableTest,時間:" + LocalTime.now());        if (code == 0) {            throw new BizException("異常", "異常");        }        BaseResponse<Object> objectBaseResponse = ResponseHandler.serviceFailure(ResponseErrorEnum.UPDATE_COMMENT_FAILURE);
       System.out.println("retryableTest,正確!");        return 200;            }

   @Recover    public Integer recover(BizException e) {        System.out.println("回撥方法執行!!!!");        //記日誌到資料庫 或者呼叫其餘的方法        return 404;        };

可以看到程式碼裡面,實現方法上面加上了註解 @Retryable,@Retryable有以下引數可以配置:

value
丟擲指定異常才會重試;
include
和value一樣,預設為空,當exclude也為空時,預設所有異常;
exclude
指定不處理的異常;
maxAttempts
最大重試次數,預設3次;
backoff
重試等待策略,預設使用@Backoff,@Backoff的value預設為1000(單位毫秒);
multiplier
指定延遲倍數)預設為0,表示固定暫停1秒後進行重試,如果把multiplier設定為1.5,則第一次重試為2秒,第二次為3秒,第三次為4.5秒;

Spring-Retry還提供了@Recover註解,用於@Retryable重試失敗後處理方法。如果不需要回撥方法,可以直接不寫回撥方法,那麼實現的效果是,重試次數完了後,如果還是沒成功沒符合業務判斷,就丟擲異常。可以看到傳參裡面寫的是 BizException e,這個是作為回撥的接頭暗號(重試次數用完了,還是失敗,我們丟擲這個BizException e通知觸發這個回撥方法)。

注意事項:

  • @Recover註解來開啟重試失敗後呼叫的方法,此註解註釋的方法引數一定要是@Retryable丟擲的異常,否則無法識別。
  • @Recover標註的方法的返回值必須與@Retryable標註的方法一致。
  • 該回撥方法與重試方法寫在同一個實現類裡面。
  • 由於是基於AOP實現,所以不支援類裡自呼叫方法。
  • 方法內不能使用try catch,只能往外拋異常,而且異常必須是Throwable型別的。

原理

Spring-retyr呼叫時序圖 :

Java面向容錯程式設計之重試機制

Spring Retry 的基本原理是透過 @EnableRetry 註解引入 AOP 能力。在 Spring 容器啟動時,會掃描所有帶有 @Retryable 和 @CircuitBreaker(熔斷器)註解的方法,併為其生成 PointCut 和 Advice。當發生方法呼叫時,Spring 會委託攔截器 RetryOperationsInterceptor 進行呼叫,攔截器內部實現了失敗退避重試和降級恢復方法。這種設計模式使得重試邏輯的實現非常簡單易懂,並且能夠充分利用 Spring 框架提供的 AOP 能力,從而實現高效而優雅的重試機制。

缺陷

儘管 Spring Retry 工具能夠優雅地實現重試,但它仍然存在兩個不太友好的設計:
首先,重試實體被限定為 Throwable 子類,這意味著重試針對的是可捕獲的功能異常,但實際上我們可能希望依賴某個資料物件實體作為重試實體,但是 Spring Retry 框架必須強制將其轉換為 Throwable 子類。
其次,重試根源的斷言物件使用的是 doWithRetry 的 Exception 異常例項,這不符合正常內部斷言的返回設計。
Spring Retry 建議使用註解來對方法進行重試,重試邏輯是同步執行的。重試的“失敗”是指 Throwable 異常,如果你要透過返回值的某個狀態來判斷是否需要重試,則可能需要自己判斷返回值並手動丟擲異常。


2.6 Guava Retrying

Guava Retrying 是基於 Google 的核心類庫 Guava 的重試機制實現的一個庫,它提供了一種通用方法,可以使用 Guava 謂詞匹配增強的特定停止、重試和異常處理功能來重試任意 Java 程式碼。這個庫支援多種重試策略,比如指定重試次數、指定重試時間間隔等。此外,它還支援謂詞匹配來確定是否應該重試,以及在重試時應該做些什麼。Guava Retrying 的最大特點是它能夠靈活地與其他 Guava 類庫整合,這使得它非常易於使用。

POM依賴






      <dependency>      <groupId>com.github.rholder</groupId>      <artifactId>guava-retrying</artifactId>      <version>2.0.0</version>    </dependency>

測試demo

































public static void main(String[] args) {     Callable<Boolean> callable = new Callable<Boolean>() {            @Override            public Boolean call() throws Exception {                // do something useful here                log.info("call...");                throw new RuntimeException();            }        };
       Retryer<Boolean> retryer = RetryerBuilder.<Boolean>newBuilder()             //retryIf 重試條件                .retryIfException()                .retryIfRuntimeException()                .retryIfExceptionOfType(Exception.class)                .retryIfException(Predicates.equalTo(new Exception()))                .retryIfResult(Predicates.equalTo(false))           //等待策略:每次請求間隔1s                .withWaitStrategy(WaitStrategies.fixedWait(1, TimeUnit.SECONDS))          //停止策略 : 嘗試請求6次              .withStopStrategy(StopStrategies.stopAfterAttempt(6))                //時間限制 : 某次請求不得超過2s              .withAttemptTimeLimiter(          AttemptTimeLimiters.fixedTimeLimit(2, TimeUnit.SECONDS))           //註冊一個自定義監聽器(可以實現失敗後的兜底方法)              .withRetryListener(new MyRetryListener()).build();        try {            retryer.call(callable);        } catch (Exception ee) {            ee.printStackTrace();        }}
當發生重試之後,假如我們需要做一些額外的處理動作,比如發個告警郵件啥的,那麼可以使用RetryListener。每次重試之後,guava-retrying會自動回撥我們註冊的監聽。可以註冊多個RetryListener,會按照註冊順序依次呼叫。























public class MyRetryListener implements RetryListener {    @Override    public <V> void onRetry(Attempt<V> attempt) {         // 第幾次重試        System.out.print("[retry]time=" + attempt.getAttemptNumber());        // 距離第一次重試的延遲        System.out.print(",delay=" + attempt.getDelaySinceFirstAttempt());
       // 重試結果: 是異常終止, 還是正常返回        System.out.print(",hasException=" + attempt.hasException());        System.out.print(",hasResult=" + attempt.hasResult());
       // 是什麼原因導致異常        if (attempt.hasException()) {            System.out.print(",causeBy=" + attempt.getExceptionCause().toString());            // do something useful here        } else {            // 正常返回時的結果            System.out.print(",result=" + attempt.getResult());        }        System.out.println();    }}

RetryerBuilder是一個factory建立者,可以定製設定重試源且可以支援多個重試源,可以配置重試次數或重試超時時間,以及可以配置等待時間間隔,建立重試者Retryer例項。

RetryerBuilder的重試源支援Exception異常物件 和自定義斷言物件,透過retryIfException 和retryIfResult設定,同時支援多個且能相容。

  • retryIfException,丟擲runtime異常、checked異常時都會重試,但是丟擲error不會重試。

  • retryIfRuntimeException只會在拋runtime異常的時候才重試,checked異常和error都不重試。

  • retryIfExceptionOfType允許我們只在發生特定異常的時候才重試,比如NullPointerException和IllegalStateException都屬於runtime異常,也包括自定義的error。

  • retryIfResult可以指定你的Callable方法在返回值的時候進行重試。

StopStrategy:停止重試策略,提供以下方式:

StopAfterDelayStrategy
設定一個最長允許的執行時間;比如設定最長執行10s,無論任務執行次數,只要重試的時候超出了最長時間,則任務終止,並返回重試異常
NeverStopStrategy
用於需要一直輪訓知道返回期望結果的情況。
StopAfterAttemptStrategy
設定最大重試次數,如果超出最大重試次數則停止重試,並返回重試異常。
WaitStrategy
等待時長策略(控制時間間隔)
FixedWaitStrategy
固定等待時長策略。
RandomWaitStrategy
隨機等待時長策略(可以提供一個最小和最大時長,等待時長為其區間隨機值)。
IncrementingWaitStrategy
遞增等待時長策略(提供一個初始值和步長,等待時間隨重試次數增加而增加)。
ExponentialWaitStrategy
指數等待時長策略
FibonacciWaitStrategy
等待時長策略
ExceptionWaitStrategy
異常時長等待策略
CompositeWaitStrategy
複合時長等待策略

優勢

Guava Retryer 工具與 Spring Retry 類似,都是透過定義重試者角色來包裝正常邏輯重試。然而,Guava Retryer 在策略定義方面更優秀。它不僅支援設定重試次數和重試頻度控制,還能夠相容多個異常或自定義實體物件的重試源定義,從而提供更多的靈活性。這使得 Guava Retryer 能夠適用於更多的業務場景,比如網路請求、資料庫訪問等。此外,Guava Retryer 還具有很好的可擴充套件性,可以很方便地與其他 Guava 類庫整合使用。

三、優雅重試共性和原理

Spring Retry 和 Guava Retryer 工具都是執行緒安全的重試工具,它們能夠支援併發業務場景下的重試邏輯,並保證重試的正確性。這些工具可以設定重試間隔時間、差異化的重試策略和重試超時時間,進一步提高了重試的有效性和流程的穩定性。
同時,Spring Retry 和 Guava Retryer 都使用了命令設計模式,透過委託重試物件來完成相應的邏輯操作,並在內部實現了重試邏輯的封裝。這種設計模式使得重試邏輯的擴充套件和修改變得非常容易,同時也增強了程式碼的可重用性。

四、總結

在某些功能邏輯中,存在不穩定依賴的場景,這時我們需要使用重試機制來獲取預期結果或嘗試重新執行邏輯而不立即結束。比如在遠端介面訪問、資料載入訪問、資料上傳校驗等場景中,都可能需要使用重試機制。
不同的異常場景需要採用不同的重試方式,同時,我們應該儘可能將正常邏輯和重試邏輯解耦。在設定重試策略時,我們需要根據實際情況考慮一些問題。比如,我們需要考慮什麼時機進行重試比較合適?是否應該同步阻塞重試還是非同步延遲請重試?是否具備一鍵快速失敗的能力?另外,我們需要考慮失敗不重試會不會嚴重影響使用者體驗。在設定超時時間、重試策略、重試場景和重試次數時,我們也需要慎重考慮以上問題。
本文只講解了重試機制的一小部分,我們在實際應用時要根據實際情況採用合適的失敗重試方案。
參考文件:
Guava-Retrying實現重試機制:https://blog.csdn.net/dxh0823/article/details/80850367
面向失敗程式設計之重試:
Springboot-Retry元件@Recover失效問題解決:https://blog.csdn.net/zhiweihongyan1/article/details/121630529
Spring Boot 一個註解,優雅的實現重試機制:https://mp.weixin.qq.com/s/hDRUh0KBV9mA0Nd33qJo7g
圖文並茂:一文帶你探索HSF的實現原理
自動重試:HSF/Spring-retry/Resilience4j/自研小工具:

來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70024923/viewspace-3003425/,如需轉載,請註明出處,否則將追究法律責任。