更好的 java 重試框架 sisyphus 背後的故事

老馬嘯西風 發表於 2021-10-22
Java 框架

sisyphus 綜合了 spring-retry 和 gauva-retrying 的優勢,使用起來也非常靈活。

今天,讓我們一起看一下西西弗斯背後的故事。

情景匯入

簡單的需求

產品經理:實現一個按條件,查詢使用者資訊的服務。

小明:好的。沒問題。

程式碼

  • UserService.java
public interface UserService {

    /**
     * 根據條件查詢使用者資訊
     * @param condition 條件
     * @return User 資訊
     */
    User queryUser(QueryUserCondition condition);

}
  • UserServiceImpl.java
public class UserServiceImpl implements UserService {

    private OutService outService;

    public UserServiceImpl(OutService outService) {
        this.outService = outService;
    }

    @Override
    public User queryUser(QueryUserCondition condition) {
        outService.remoteCall();
        return new User();
    }

}

談話

專案經理:這個服務有時候會失敗,你看下。

小明:OutService 在是一個 RPC 的外部服務,但是有時候不穩定。

專案經理:如果呼叫失敗了,你可以呼叫的時候重試幾次。你去看下重試相關的東西

重試

重試作用

對於重試是有場景限制的,不是什麼場景都適合重試,比如引數校驗不合法、寫操作等(要考慮寫是否冪等)都不適合重試。

遠端呼叫超時、網路突然中斷可以重試。在微服務治理框架中,通常都有自己的重試與超時配置,比如dubbo可以設定retries=1,timeout=500呼叫失敗只重試1次,超過500ms呼叫仍未返回則呼叫失敗。

比如外部 RPC 呼叫,或者資料入庫等操作,如果一次操作失敗,可以進行多次重試,提高呼叫成功的可能性

V1.0 支援重試版本

思考

小明:我手頭還有其他任務,這個也挺簡單的。5 分鐘時間搞定他。

實現

  • UserServiceRetryImpl.java
public class UserServiceRetryImpl implements UserService {

    @Override
    public User queryUser(QueryUserCondition condition) {
        int times = 0;
        OutService outService = new AlwaysFailOutServiceImpl();

        while (times < RetryConstant.MAX_TIMES) {
            try {
                outService.remoteCall();
                return new User();
            } catch (Exception e) {
                times++;

                if(times >= RetryConstant.MAX_TIMES) {
                    throw new RuntimeException(e);
                }
            }
        }

        return null;
    }

}

V1.1 代理模式版本

易於維護

專案經理:你的程式碼我看了,功能雖然實現了,但是儘量寫的易於維護一點。

小明:好的。(心想,是說要寫點註釋什麼的?)

代理模式

為其他物件提供一種代理以控制對這個物件的訪問。

在某些情況下,一個物件不適合或者不能直接引用另一個物件,而代理物件可以在客戶端和目標物件之間起到中介作用。

其特徵是代理與委託類有同樣的介面。

實現

小明想到以前看過的代理模式,心想用這種方式,原來的程式碼改動量較少,以後想改起來也方便些

  • UserServiceProxyImpl.java
public class UserServiceProxyImpl implements UserService {

    private UserService userService = new UserServiceImpl();

    @Override
    public User queryUser(QueryUserCondition condition) {
        int times = 0;

        while (times < RetryConstant.MAX_TIMES) {
            try {
                return userService.queryUser(condition);
            } catch (Exception e) {
                times++;

                if(times >= RetryConstant.MAX_TIMES) {
                    throw new RuntimeException(e);
                }
            }
        }
        return null;
    }

}

V1.2 動態代理模式

方便擴充

專案經理:小明啊,這裡還有個方法也是同樣的問題。你也給加上重試吧。

小明:好的。

小明心想,我在寫一個代理,但是轉念冷靜了下來,如果還有個服務也要重試怎麼辦呢?

  • RoleService.java
public interface RoleService {

    /**
     * 查詢
     * @param user 使用者資訊
     * @return 是否擁有許可權
     */
    boolean hasPrivilege(User user);

}

程式碼實現

  • DynamicProxy.java
public class DynamicProxy implements InvocationHandler {

    private final Object subject;

    public DynamicProxy(Object subject) {
        this.subject = subject;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        int times = 0;

        while (times < RetryConstant.MAX_TIMES) {
            try {
                // 當代理物件呼叫真實物件的方法時,其會自動的跳轉到代理物件關聯的handler物件的invoke方法來進行呼叫
                return method.invoke(subject, args);
            } catch (Exception e) {
                times++;

                if (times >= RetryConstant.MAX_TIMES) {
                    throw new RuntimeException(e);
                }
            }
        }

        return null;
    }

    /**
     * 獲取動態代理
     *
     * @param realSubject 代理物件
     */
    public static Object getProxy(Object realSubject) {
        //    我們要代理哪個真實物件,就將該物件傳進去,最後是通過該真實物件來呼叫其方法的
        InvocationHandler handler = new DynamicProxy(realSubject);
        return Proxy.newProxyInstance(handler.getClass().getClassLoader(),
                realSubject.getClass().getInterfaces(), handler);
    }

}
  • 測試程式碼
@Test
public void failUserServiceTest() {
        UserService realService = new UserServiceImpl();
        UserService proxyService = (UserService) DynamicProxy.getProxy(realService);

        User user = proxyService.queryUser(new QueryUserCondition());
        LOGGER.info("failUserServiceTest: " + user);
}


@Test
public void roleServiceTest() {
        RoleService realService = new RoleServiceImpl();
        RoleService proxyService = (RoleService) DynamicProxy.getProxy(realService);

        boolean hasPrivilege = proxyService.hasPrivilege(new User());
        LOGGER.info("roleServiceTest: " + hasPrivilege);
}

V1.3 動態代理模式增強

對話

專案經理:小明,你動態代理的方式是挺會偷懶的,可是我們有的類沒有介面。這個問題你要解決一下。

小明:好的。(誰?寫服務竟然不定義介面)

  • ResourceServiceImpl.java
public class ResourceServiceImpl {

    /**
     * 校驗資源資訊
     * @param user 入參
     * @return 是否校驗通過
     */
    public boolean checkResource(User user) {
        OutService outService = new AlwaysFailOutServiceImpl();
        outService.remoteCall();
        return true;
    }

}

位元組碼技術

小明看了下網上的資料,解決的辦法還是有的。

  • CGLIB

CGLIB 是一個功能強大、高效能和高質量的程式碼生成庫,用於擴充套件JAVA類並在執行時實現介面。

  • javassist

javassist (Java程式設計助手)使Java位元組碼操作變得簡單。

它是Java中編輯位元組碼的類庫;它允許Java程式在執行時定義新類,並在JVM載入類檔案時修改類檔案。

與其他類似的位元組碼編輯器不同,Javassist提供了兩個級別的API:源級和位元組碼級。

如果使用者使用原始碼級API,他們可以編輯類檔案,而不需要了解Java位元組碼的規範。

整個API只使用Java語言的詞彙表進行設計。您甚至可以以源文字的形式指定插入的位元組碼;Javassist動態編譯它。

另一方面,位元組碼級API允許使用者直接編輯類檔案作為其他編輯器。

  • ASM

ASM 是一個通用的Java位元組碼操作和分析框架。

它可以用來修改現有的類或動態地生成類,直接以二進位制形式。

ASM提供了一些通用的位元組碼轉換和分析演算法,可以從這些演算法中構建自定義複雜的轉換和程式碼分析工具。

ASM提供與其他Java位元組碼框架類似的功能,但主要關注效能。

因為它的設計和實現都儘可能地小和快,所以非常適合在動態系統中使用(當然也可以以靜態的方式使用,例如在編譯器中)。

實現

小明看了下,就選擇使用 CGLIB。

  • CglibProxy.java
public class CglibProxy implements MethodInterceptor {

    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        int times = 0;

        while (times < RetryConstant.MAX_TIMES) {
            try {
                //通過代理子類呼叫父類的方法
                return methodProxy.invokeSuper(o, objects);
            } catch (Exception e) {
                times++;

                if (times >= RetryConstant.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();
    }

}
  • 測試
@Test
public void failUserServiceTest() {
   UserService proxyService = (UserService) new CglibProxy().getProxy(UserServiceImpl.class);

   User user = proxyService.queryUser(new QueryUserCondition());
   LOGGER.info("failUserServiceTest: " + user);
}

@Test
public void resourceServiceTest() {
   ResourceServiceImpl proxyService = (ResourceServiceImpl) new CglibProxy().getProxy(ResourceServiceImpl.class);
   boolean result = proxyService.checkResource(new User());
   LOGGER.info("resourceServiceTest: " + result);
}

V2.0 AOP 實現

對話

專案經理:小明啊,最近我在想一個問題。不同的服務,重試的時候次數應該是不同的。因為服務對穩定性的要求各不相同啊。

小明:好的。(心想,重試都搞了一週了,今天都週五了。)

下班之前,小明一直在想這個問題。剛好週末,花點時間寫個重試小工具吧。

設計思路

  • 技術支援

spring

java 註解

  • 註解定義

註解可在方法上使用,定義需要重試的次數

  • 註解解析

攔截指定需要重試的方法,解析對應的重試次數,然後進行對應次數的重試。

實現

  • Retryable.java
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Retryable {

    /**
     * Exception type that are retryable.
     * @return exception type to retry
     */
    Class<? extends Throwable> value() default RuntimeException.class;

    /**
     * 包含第一次失敗
     * @return the maximum number of attempts (including the first failure), defaults to 3
     */
    int maxAttempts() default 3;

}
  • RetryAspect.java
@Aspect
@Component
public class RetryAspect {

    @Pointcut("execution(public * com.github.houbb.retry.aop..*.*(..)) &&" +
                      "@annotation(com.github.houbb.retry.aop.annotation.Retryable)")
    public void myPointcut() {
    }

    @Around("myPointcut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        Method method = getCurrentMethod(point);
        Retryable retryable = method.getAnnotation(Retryable.class);

        //1. 最大次數判斷
        int maxAttempts = retryable.maxAttempts();
        if (maxAttempts <= 1) {
            return point.proceed();
        }

        //2. 異常處理
        int times = 0;
        final Class<? extends Throwable> exceptionClass = retryable.value();
        while (times < maxAttempts) {
            try {
                return point.proceed();
            } catch (Throwable e) {
                times++;

                // 超過最大重試次數 or 不屬於當前處理異常
                if (times >= maxAttempts ||
                        !e.getClass().isAssignableFrom(exceptionClass)) {
                    throw new Throwable(e);
                }
            }
        }

        return null;
    }

    private Method getCurrentMethod(ProceedingJoinPoint point) {
        try {
            Signature sig = point.getSignature();
            MethodSignature msig = (MethodSignature) sig;
            Object target = point.getTarget();
            return target.getClass().getMethod(msig.getName(), msig.getParameterTypes());
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e);
        }
    }

}

方法的使用

  • fiveTimes()

當前方法一共重試 5 次。
重試條件:服務丟擲 AopRuntimeExption

@Override
@Retryable(maxAttempts = 5, value = AopRuntimeExption.class)
public void fiveTimes() {
    LOGGER.info("fiveTimes called!");
    throw new AopRuntimeExption();
}
  • 測試日誌
2018-08-08 15:49:33.814  INFO  [main] com.github.houbb.retry.aop.service.impl.UserServiceImpl:66 - fiveTimes called!
2018-08-08 15:49:33.815  INFO  [main] com.github.houbb.retry.aop.service.impl.UserServiceImpl:66 - fiveTimes called!
2018-08-08 15:49:33.815  INFO  [main] com.github.houbb.retry.aop.service.impl.UserServiceImpl:66 - fiveTimes called!
2018-08-08 15:49:33.815  INFO  [main] com.github.houbb.retry.aop.service.impl.UserServiceImpl:66 - fiveTimes called!
2018-08-08 15:49:33.815  INFO  [main] com.github.houbb.retry.aop.service.impl.UserServiceImpl:66 - fiveTimes called!

java.lang.reflect.UndeclaredThrowableException
...

V3.0 spring-retry 版本

對話

週一來到公司,專案經理又和小明談了起來。

專案經理:重試次數是滿足了,但是重試其實應該講究策略。比如呼叫外部,第一次失敗,可以等待 5S 在次呼叫,如果又失敗了,可以等待 10S 再呼叫。。。

小明:瞭解。

思考

可是今天週一,還有其他很多事情要做。

小明在想,沒時間寫這個呀。看看網上有沒有現成的。

spring-retry

Spring Retry 為 Spring 應用程式提供了宣告性重試支援。 它用於Spring批處理、Spring整合、Apache Hadoop(等等)的Spring。

在分散式系統中,為了保證資料分散式事務的強一致性,大家在呼叫RPC介面或者傳送MQ時,針對可能會出現網路抖動請求超時情況採取一下重試操作。 大家用的最多的重試方式就是MQ了,但是如果你的專案中沒有引入MQ,那就不方便了。

還有一種方式,是開發者自己編寫重試機制,但是大多不夠優雅。

註解式使用

  • RemoteService.java

重試條件:遇到 RuntimeException

重試次數:3

重試策略:重試的時候等待 5S, 後面時間依次變為原來的 2 倍數。

熔斷機制:全部重試失敗,則呼叫 recover() 方法。

@Service
public class RemoteService {

    private static final Logger LOGGER = LoggerFactory.getLogger(RemoteService.class);

    /**
     * 呼叫方法
     */
    @Retryable(value = RuntimeException.class,
               maxAttempts = 3,
               backoff = @Backoff(delay = 5000L, multiplier = 2))
    public void call() {
        LOGGER.info("Call something...");
        throw new RuntimeException("RPC呼叫異常");
    }

    /**
     * recover 機制
     * @param e 異常
     */
    @Recover
    public void recover(RuntimeException e) {
        LOGGER.info("Start do recover things....");
        LOGGER.warn("We meet ex: ", e);
    }

}
  • 測試
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
public class RemoteServiceTest {

    @Autowired
    private RemoteService remoteService;

    @Test
    public void test() {
        remoteService.call();
    }

}
  • 日誌
2018-08-08 16:03:26.409  INFO 1433 --- [           main] c.g.h.r.spring.service.RemoteService     : Call something...
2018-08-08 16:03:31.414  INFO 1433 --- [           main] c.g.h.r.spring.service.RemoteService     : Call something...
2018-08-08 16:03:41.416  INFO 1433 --- [           main] c.g.h.r.spring.service.RemoteService     : Call something...
2018-08-08 16:03:41.418  INFO 1433 --- [           main] c.g.h.r.spring.service.RemoteService     : Start do recover things....
2018-08-08 16:03:41.425  WARN 1433 --- [           main] c.g.h.r.spring.service.RemoteService     : We meet ex: 

java.lang.RuntimeException: RPC呼叫異常
    at com.github.houbb.retry.spring.service.RemoteService.call(RemoteService.java:38) ~[classes/:na]
...

三次呼叫的時間點:

2018-08-08 16:03:26.409 
2018-08-08 16:03:31.414
2018-08-08 16:03:41.416

缺陷

spring-retry 工具雖能優雅實現重試,但是存在兩個不友好設計:

一個是重試實體限定為 Throwable 子類,說明重試針對的是可捕捉的功能異常為設計前提的,但是我們希望依賴某個資料物件實體作為重試實體,
但 sping-retry框架必須強制轉換為Throwable子類。

另一個就是重試根源的斷言物件使用的是 doWithRetry 的 Exception 異常例項,不符合正常內部斷言的返回設計。

Spring Retry 提倡以註解的方式對方法進行重試,重試邏輯是同步執行的,重試的“失敗”針對的是Throwable,
如果你要以返回值的某個狀態來判定是否需要重試,可能只能通過自己判斷返回值然後顯式丟擲異常了。

@Recover 註解在使用時無法指定方法,如果一個類中多個重試方法,就會很麻煩。

guava-retrying

談話

小華:我們系統也要用到重試

專案經理:小明前段時間用了 spring-retry,分享下應該還不錯

小明:spring-retry 基本功能都有,但是必須是基於異常來進行控制。如果你要以返回值的某個狀態來判定是否需要重試,可能只能通過自己判斷返回值然後顯式丟擲異常了。

小華:我們專案中想根據物件的屬性來進行重試。你可以看下 guava-retry,我很久以前用過,感覺還不錯。

小明:好的。

guava-retrying

guava-retrying 模組提供了一種通用方法, 可以使用Guava謂詞匹配增強的特定停止、重試和異常處理功能來重試任意Java程式碼。

  • 優勢

guava retryer工具與spring-retry類似,都是通過定義重試者角色來包裝正常邏輯重試,但是Guava retryer有更優的策略定義,在支援重試次數和重試頻度控制基礎上,能夠相容支援多個異常或者自定義實體物件的重試源定義,讓重試功能有更多的靈活性。

Guava Retryer也是執行緒安全的,入口呼叫邏輯採用的是 java.util.concurrent.Callablecall() 方法

程式碼例子

入門案例

遇到異常之後,重試 3 次停止

  • HelloDemo.java
public static void main(String[] args) {
    Callable<Boolean> callable = new Callable<Boolean>() {
        @Override
        public Boolean call() throws Exception {
            // do something useful here
            LOGGER.info("call...");
            throw new RuntimeException();
        }
    };

    Retryer<Boolean> retryer = RetryerBuilder.<Boolean>newBuilder()
            .retryIfResult(Predicates.isNull())
            .retryIfExceptionOfType(IOException.class)
            .retryIfRuntimeException()
            .withStopStrategy(StopStrategies.stopAfterAttempt(3))
            .build();
    try {
        retryer.call(callable);
    } catch (RetryException | ExecutionException e) {
        e.printStackTrace();
    }

}
  • 日誌
2018-08-08 17:21:12.442  INFO  [main] com.github.houbb.retry.guava.HelloDemo:41 - call...
com.github.rholder.retry.RetryException: Retrying failed to complete successfully after 3 attempts.
2018-08-08 17:21:12.443  INFO  [main] com.github.houbb.retry.guava.HelloDemo:41 - call...
2018-08-08 17:21:12.444  INFO  [main] com.github.houbb.retry.guava.HelloDemo:41 - call...
    at com.github.rholder.retry.Retryer.call(Retryer.java:174)
    at com.github.houbb.retry.guava.HelloDemo.main(HelloDemo.java:53)
Caused by: java.lang.RuntimeException
    at com.github.houbb.retry.guava.HelloDemo$1.call(HelloDemo.java:42)
    at com.github.houbb.retry.guava.HelloDemo$1.call(HelloDemo.java:37)
    at com.github.rholder.retry.AttemptTimeLimiters$NoAttemptTimeLimit.call(AttemptTimeLimiters.java:78)
    at com.github.rholder.retry.Retryer.call(Retryer.java:160)
    ... 1 more

總結

優雅重試共性和原理

正常和重試優雅解耦,重試斷言條件例項或邏輯異常例項是兩者溝通的媒介。

約定重試間隔,差異性重試策略,設定重試超時時間,進一步保證重試有效性以及重試流程穩定性。

都使用了命令設計模式,通過委託重試物件完成相應的邏輯操作,同時內部封裝實現重試邏輯。

spring-retry 和 guava-retry 工具都是執行緒安全的重試,能夠支援併發業務場景的重試邏輯正確性。

優雅重試適用場景

功能邏輯中存在不穩定依賴場景,需要使用重試獲取預期結果或者嘗試重新執行邏輯不立即結束。比如遠端介面訪問,資料載入訪問,資料上傳校驗等等。

對於異常場景存在需要重試場景,同時希望把正常邏輯和重試邏輯解耦。

對於需要基於資料媒介互動,希望通過重試輪詢檢測執行邏輯場景也可以考慮重試方案。

談話

專案經理:我覺得 guava-retry 挺好的,就是不夠方便。小明啊,你給封裝個基於註解的吧。

小明:……

更好的實現

於是小明含淚寫下了 sisyphus.

java 重試框架——sisyphus

希望本文對你有所幫助,如果喜歡,歡迎點贊收藏轉發一波。

我是老馬,期待與你的下次重逢。

在這裡插入圖片描述