Spring Retry

2023-12-01 21:00:17

工作中,經常遇到需要重試的場景,最簡單的方式可以用try...catch...加while迴圈來實現。那麼,有沒有統一的、優雅一點兒的處理方式呢?有的,Spring Retry就可以幫我們搞定重試問題。

關於重試,我們可以關注以下以下幾個方面:

  • 什麼情況下去觸發重試機制
  • 重試多少次,重試的時間間隔
  • 是否可以對重試過程進行監視

接下來,帶著這些思考,一起看下Spring Retry是如何解決這些問題的

首先,引入依賴。

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

有兩種使用方式:命令式和宣告式

1. 命令式

RetryTemplate template = RetryTemplate.builder()
        .maxAttempts(3)
        .fixedBackoff(1000)
        .retryOn(RemoteAccessException.class)
        .build();

template.execute(ctx -> {
    // ... do something
});

命令式主要是利用RetryTemplate。RetryTemplate 實現了 RetryOperations 介面。

RetryTemplate template = new RetryTemplate();

TimeoutRetryPolicy policy = new TimeoutRetryPolicy();
policy.setTimeout(30000L);  //  30秒內可以重試,超過30秒不再重試

template.setRetryPolicy(policy);

MyObject result = template.execute(new RetryCallback<MyObject, Exception>() {

    public MyObject doWithRetry(RetryContext context) {
        // Do stuff that might fail, e.g. webservice operation
        return result;
    }

});

RetryTemplate 也支援流式設定

//  最大重試10次,第一次間隔100ms,第二次200ms,第三次400ms,以此類推,最大間隔10000ms
RetryTemplate.builder()
      .maxAttempts(10)
      .exponentialBackoff(100, 2, 10000)
      .retryOn(IOException.class)
      .traversingCauses()
      .build();

//  3秒內可以一直重試,每次間隔10毫秒,3秒以後就不再重試了
RetryTemplate.builder()
      .fixedBackoff(10)
      .withinMillis(3000)
      .build();

//  無限重試,間隔最小1秒,最大3秒
RetryTemplate.builder()
      .infiniteRetry()
      .retryOn(IOException.class)
      .uniformRandomBackoff(1000, 3000)
      .build();

當重試耗盡時,RetryOperations可以將控制傳遞給另一個回撥:RecoveryCallback

template.execute(new RetryCallback<Object, Throwable>() {
    @Override
    public Object doWithRetry(RetryContext context) throws Throwable {
        // 業務邏輯
        return null;
    }
}, new RecoveryCallback<Object>() {
    @Override
    public Object recover(RetryContext context) throws Exception {
        //  恢復邏輯
        return null;
    }
});

如果重試次數耗盡時,業務邏輯還沒有執行成功,那麼執行恢復邏輯來進行兜底處理(兜底方案)

無狀態的重試

在最簡單的情況下,重試只是一個while迴圈:RetryTemplate可以一直嘗試,直到成功或失敗。RetryContext包含一些狀態,用於確定是重試還是中止。然而,這個狀態是在堆疊上的,不需要在全域性的任何地方儲存它。因此,我們稱之為「無狀態重試」。無狀態重試和有狀態重試之間的區別包含在RetryPolicy的實現中。在無狀態重試中,回撥總是在重試失敗時的同一個執行緒中執行。

有狀態的重試

如果故障導致事務性資源失效,則需要考慮一些特殊問題。這並不適用於簡單的遠端呼叫,因為(通常)沒有事務性資源,但它有時適用於資料庫更新,特別是在使用Hibernate時。在這種情況下,只有重新丟擲立即呼叫失敗的異常才有意義,這樣事務才能回滾,我們才能開始一個新的(有效的)事務。在這些情況下,無狀態重試還不夠好,因為重新丟擲和回滾必然涉及離開RetryOperations.execute()方法,並且可能丟失堆疊上的上下文。為了避免丟失上下文,我們必須引入一種儲存策略,將其從堆疊中取出,並(至少)將其放入堆儲存中。為此,Spring Retry提供了一個名為RetryContextCache的儲存策略,您可以將其注入到RetryTemplate中。RetryContextCache的預設實現是在記憶體中,使用一個簡單的Map。它具有嚴格強制的最大容量,以避免記憶體漏失,但它沒有任何高階快取特性(例如生存時間)。如果需要,你應該考慮注入具有這些特性的Map。

重試策略

在RetryTemplate中,由RetryPolicy決定是重試還是失敗。RetryTemplate負責使用當前策略建立RetryContext,並在每次重試時將其傳遞給RetryCallback。回撥失敗後,RetryTemplate必須呼叫RetryPolicy,要求它更新自己的狀態(儲存在RetryContext中)。然後詢問政策是否可以再嘗試一次。如果不能進行另一次重試(例如,因為已達到限制或檢測到超時),策略還負責標識耗盡狀態——但不負責處理異常。當沒有恢復可用時,RetryTemplate丟擲原始異常,但有狀態情況除外。在這種情況下,它會丟擲RetryExhaustedException。還可以在RetryTemplate中設定一個標誌,讓它無條件地丟擲回撥(即使用者程式碼)中的原始異常。

// Set the max attempts including the initial attempt before retrying
// and retry on all exceptions (this is the default):
SimpleRetryPolicy policy = new SimpleRetryPolicy(5, Collections.singletonMap(Exception.class, true));

// Use the policy...
RetryTemplate template = new RetryTemplate();
template.setRetryPolicy(policy);
template.execute(new RetryCallback<MyObject, Exception>() {
    public MyObject doWithRetry(RetryContext context) {
        // business logic here
    }
});

監聽器 

Spring Retry提供了RetryListener介面。RetryTemplate允許您註冊RetryListener範例。

template.registerListener(new RetryListener() {
    @Override
    public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
        return false;
    }
    @Override
    public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {

    }
    @Override
    public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {

    }
});

反射方法呼叫的監聽器

template.registerListener(new MethodInvocationRetryListenerSupport() {
    @Override
    protected <T, E extends Throwable> void doClose(RetryContext context, MethodInvocationRetryCallback<T, E> callback, Throwable throwable) {
        super.doClose(context, callback, throwable);
    }

    @Override
    protected <T, E extends Throwable> void doOnError(RetryContext context, MethodInvocationRetryCallback<T, E> callback, Throwable throwable) {
        super.doOnError(context, callback, throwable);
    }

    @Override
    protected <T, E extends Throwable> boolean doOpen(RetryContext context, MethodInvocationRetryCallback<T, E> callback) {
        return super.doOpen(context, callback);
    }
});

2. 宣告式

@EnableRetry
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

@Service
class Service {
    @Retryable(RemoteAccessException.class)
    public void service() {
        // ... do something
    }
    @Recover
    public void recover(RemoteAccessException e) {
       // ... panic
    }
}

可以將@EnableRetry註釋新增到@Configuration類上,並在想要重試的方法上(或在所有方法的型別級別上)使用@Retryable,還可以指定任意數量的重試監聽器。

@Configuration
@EnableRetry
public class Application {

    @Bean 
    public RetryListener retryListener1() {
        return new RetryListener() {...}
    }

    @Bean 
    public RetryListener retryListener2() {
        return new RetryListener() {...}
    }

}

@Service
class MyService {
    @Retryable(RemoteAccessException.class)
    public void hello() {
        // ... do something
    }
}

可以利用 @Retryable 的屬性來控制 RetryPolicy 和 BackoffPolicy

@Service
public class MyService {
    @Retryable(value = RuntimeException.class, maxAttempts = 5, backoff = @Backoff(value = 1000L, multiplier = 1.5))
    public void sayHello() {
        //  ... do something
    }

    @Retryable(value = {IOException.class, RemoteAccessException.class},
            listeners = {"myListener1", "myListener2", "myListener3"},
            maxAttempts = 5, backoff = @Backoff(delay = 100, maxDelay = 500))
    public void sayHi() {
        //  ... do something
    }

    @Retryable(maxAttempts = 5, backoff = @Backoff(delay = 1000, maxDelay = 30000, multiplier = 1.2, random = true))
    public void sayBye() {
        //  ... do something
    }
}

如果希望在重試耗盡時執行另外的邏輯,則可以提供恢復方法。恢復方法應該在與@Retryable範例相同的類中宣告,並標記為@Recover。返回型別必須匹配@Retryable方法。恢復方法的引數可以選擇性地包括丟擲的異常和(可選地)傳遞給原始可重試方法的引數(或它們的部分列表,只要在最後一個需要的引數之前沒有被省略)。

@Service
class MyService {
    @Retryable(RemoteAccessException.class)
    public void service(String str1, String str2) {
        // ... do something
    }
    @Recover
    public void recover(RemoteAccessException e, String str1, String str2) {
       // ... error handling making use of original args if required
    }
}

為了避免多個恢復方法搞混淆了,可以手動指定用哪個恢復方法

@Service
class Service {
    @Retryable(recover = "service1Recover", value = RemoteAccessException.class)
    public void service1(String str1, String str2) {
        // ... do something
    }

    @Retryable(recover = "service2Recover", value = RemoteAccessException.class)
    public void service2(String str1, String str2) {
        // ... do something
    }

    @Recover
    public void service1Recover(RemoteAccessException e, String str1, String str2) {
        // ... error handling making use of original args if required
    }

    @Recover
    public void service2Recover(RemoteAccessException e, String str1, String str2) {
        // ... error handling making use of original args if required
    }
}

1.3.2及以後版本支援匹配引數化(泛型)返回型別來檢測正確的恢復方法:

@Service
class Service {

    @Retryable(RemoteAccessException.class)
    public List<Thing1> service1(String str1, String str2) {
        // ... do something
    }

    @Retryable(RemoteAccessException.class)
    public List<Thing2> service2(String str1, String str2) {
        // ... do something
    }

    @Recover
    public List<Thing1> recover1(RemoteAccessException e, String str1, String str2) {
       // ... error handling for service1
    }

    @Recover
    public List<Thing2> recover2(RemoteAccessException e, String str1, String str2) {
       // ... error handling for service2
    }
}

1.2版本引入了對某些屬性使用表示式的能力

@Retryable(exceptionExpression="message.contains('this can be retried')")
public void service1() {
  ...
}

@Retryable(exceptionExpression="message.contains('this can be retried')")
public void service2() {
  ...
}

@Retryable(exceptionExpression="@exceptionChecker.shouldRetry(#root)",
    maxAttemptsExpression = "#{@integerFiveBean}",
    backoff = @Backoff(delayExpression = "#{1}", maxDelayExpression = "#{5}", multiplierExpression = "#{1.1}"))
public void service3() {
  ...
}

表示式可以包含屬性預留位置,比如:#{${max.delay}} 或者 #{@exceptionChecker.${retry.method}(#root)} 。規則如下:

  • exceptionExpression 以丟擲的異常為根物件進行計算求值的
  • maxAttemptsExpression 和 @BackOff 表示式屬性 只在初始化的時候被計算一次。它們沒有用於計算的根物件,但它們可以參照上下文中的其他bean

例如:

@Data
@Component("runtimeConfigs")
@ConfigurationProperties(prefix = "retry.cfg")
public class MyRuntimeConfig {

    private int maxAttempts;

    private long initial;

    private long max;

    private double mult;
}

application.properties

retry.cfg.maxAttempts=10
retry.cfg.initial=100
retry.cfg.max=2000
retry.cfg.mult=2.0

使用變數

@Retryable(maxAttemptsExpression = "@runtimeConfigs.maxAttempts", 
        backoff = @Backoff(delayExpression = "@runtimeConfigs.initial", 
                maxDelayExpression = "@runtimeConfigs.max", multiplierExpression = "@runtimeConfigs.mult"))
public void service() {
    System.out.println(LocalDateTime.now());
    boolean flag = sendMsg();
    if (flag) {
        throw new CustomException("呼叫失敗");
    }
}

@Retryable(maxAttemptsExpression = "args[0] == 'something' ? 3 : 1")
public void conditional(String string) {
    ...
}

最後,簡單看一下原始碼org.springframework.retry.support.RetryTemplate#doExecute()

 

RetryContext是執行緒區域性變數

間隔時間是通過執行緒休眠來實現的

https://github.com/spring-projects/spring-retry