使用 Guava Retry 優雅的實現重試機制

王有志發表於2023-12-05

王有志,一個分享硬核Java技術的互金摸魚俠
加入Java人的提桶跑路群:共同富裕的Java人

大家好,我是王有志。今天我會透過一個真實的專案改造案例和大家聊一聊如何優雅的實現 Java 中常用的的重試機制。

業務背景

在我們的系統中當客戶完成支付後,保單管理系統會透過 MQ 推送出一條包含保單資訊的訊息,該訊息被多個系統訂閱。

訊息推送平臺會根據保單資訊傳送各式各樣的通知(簡訊,微信通知等),會員中心則根據保單資訊,完成會員的積分累積和會員等級變更。在早期推送的通知中,並不包含會員的等級資訊,現在為了彰顯客戶的身份,要求在通知中表明客戶的等級。

理想的情況是,訊息推送平臺查詢客戶資訊前,會員中心已經完成了客戶等級的變更,但現實中可能因為各種原因,客戶中心無法及時處理會員等級變更,導致訊息推送平臺無法查詢到最新的會員等級。為了“避免”這種情況,我們在查詢客戶資訊時引入了重試機制。

我們的業務要求中,通知中的客戶的會員等級允許稍有偏差, 但通知一定要及時發出,因此我們的重試策略較為寬鬆,判斷是否由該訂單或之後的訂單引起的會員資訊變更,如果是則跳出重試,否則重試 3 次,每次間隔 1 秒,如果依舊沒有獲取到預期的結果,則使用當前結果傳送通知

技術選型

最容易想到的方案是透過 while 迴圈進行重試,對查詢結果和重試次數加以限制,決定何時跳出重試,例如:

CustomerInfo customerInfo;
int count = 0;

while(count < 3) {
  customerInfo = CustomerCenter.queryCustomerInfo(customerid);
  if(判斷條件) {
    break;
  }
  count ++;
  if(count < 3) {
    TimeUnit.SECONDS.sleep(1);
  }
}

雖然這麼寫已經能夠滿足業務需求,但重試條件,重試次數,休眠時間與重試機制耦合在一起,任意點的變動都相當於修改整個重試機制,這樣顯得不夠優雅,甚至非常粗糙,因此我們想到了市面上兩個較為流行的重試框架:Spring Retry 和 Guava Retry

Spring Retry

Spring Retry 支援透過註解和程式碼實現重試機制,但問題是 Spring Retry 只支援在丟擲異常時進行重試,例如我們透過程式碼構建一個 ReteyTemplate:

RetryTemplate retryTemplate = RetryTemplate.builder().retryOn(Exception.class).build();

重試條件透過RetryTemplateBuilder#retryOn來設定,我們來看該方法的宣告:

public class RetryTemplateBuilder {
  public RetryTemplateBuilder retryOn(Class<? extends Throwable> throwable) {
    // 省略
  }

  public RetryTemplateBuilder retryOn(List<Class<? extends Throwable>> throwables) {
    // 省略
  }
}

可以看到RetryTemplateBuilder#retryOn方法的入參僅支援 Throwable 及其子類,因此 Spring Retry 並不能滿足我們的業務需求被否決。

Guava Retry

再來看 Guava Retry,它提供了比較靈活的重試條件,允許在丟擲異常時,或在結果不符合預期時進行重試。

public class RetryerBuilder<V> {
  public RetryerBuilder<V> retryIfException() {
    // 省略
  }

  public RetryerBuilder<V> retryIfRuntimeException() {
    // 省略
  }

  public RetryerBuilder<V> retryIfExceptionOfType(@Nonnull Class<? extends Throwable> exceptionClass) {
    // 省略
  }

  public RetryerBuilder<V> retryIfException(@Nonnull Predicate<Throwable> exceptionPredicate) {
    // 省略
  }

  public RetryerBuilder<V> retryIfResult(@Nonnull Predicate<V> resultPredicate) {
    // 省略
  }
}

結合實際的業務需求,Guava Retry 能夠滿足我們的業務需求。

使用 Guava Retry

首先是引入 Guava Retry 的依賴:

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

引入依賴後,我們就可以構建並使用重試器 Retryer 了,接下來我們來看構建 Retryer 的兩種方式:透過構造器建立和透過建造者建立

Tips:以下涉及到原始碼的部分,均會省略引數檢查的部分。

Retryer 的構造器

先來看 Retryer 的構造器:

public final class Retryer<V> {
  public Retryer(@Nonnull StopStrategy stopStrategy,
                 @Nonnull WaitStrategy waitStrategy,
                 @Nonnull Predicate<Attempt<V>> rejectionPredicate) {
    this(AttemptTimeLimiters.<V>noTimeLimit(), stopStrategy, waitStrategy, BlockStrategies.threadSleepStrategy(), rejectionPredicate);
  }

  public Retryer(@Nonnull AttemptTimeLimiter<V> attemptTimeLimiter,
                 @Nonnull StopStrategy stopStrategy,
                 @Nonnull WaitStrategy waitStrategy,
                 @Nonnull Predicate<Attempt<V>> rejectionPredicate) {
    this(attemptTimeLimiter, stopStrategy, waitStrategy, BlockStrategies.threadSleepStrategy(), rejectionPredicate);
  }
  
  public Retryer(@Nonnull AttemptTimeLimiter<V> attemptTimeLimiter,
                 @Nonnull StopStrategy stopStrategy,
                 @Nonnull WaitStrategy waitStrategy,
                 @Nonnull BlockStrategy blockStrategy,
                 @Nonnull Predicate<Attempt<V>> rejectionPredicate) {
    this(attemptTimeLimiter, stopStrategy, waitStrategy, blockStrategy, rejectionPredicate, new ArrayList<RetryListener>());
  }

  @Beta
  public Retryer(@Nonnull AttemptTimeLimiter<V> attemptTimeLimiter,
                 @Nonnull StopStrategy stopStrategy,
                 @Nonnull WaitStrategy waitStrategy,
                 @Nonnull BlockStrategy blockStrategy,
                 @Nonnull Predicate<Attempt<V>> rejectionPredicate,
                 @Nonnull Collection<RetryListener> listeners) {
    this.attemptTimeLimiter = attemptTimeLimiter;
    this.stopStrategy = stopStrategy;
    this.waitStrategy = waitStrategy;
    this.blockStrategy = blockStrategy;
    this.rejectionPredicate = rejectionPredicate;
    this.listeners = listeners;
  }
}

Retryer 提供了 4 個構造器,前 3 個構造器都會迴歸到包含 6 個引數的構造器中,分別解釋下這 6 個引數的作用:

  • AttemptTimeLimiter<V>,允許設定請求的超時時間,當超過該時間後,Retryer 會被中斷;
  • StopStrategy,重試次數策略,用於設定最大的重試次數,當達到最大的重試次數時 Retryer 中斷;
  • WaitStrategy,休眠時間策略,計算每次請求後的休眠時間;
  • BlockStrategy,阻塞策略,同樣作用於請求後的,決定 Retry 以何種方式阻塞(需藉助 WaitStrategy 計算的時間);
  • Predicate<Attempt<V>>,條件謂詞,決定是否需要進行重試;
  • Collection<RetryListener>,監聽器,允許在請求後進行回撥。

以上 6 個引數均為介面,除了 RetryListener 外,Guava Retry 都提供了預設的實現,同時我們也可以根據業務需求自行實現定製化的策略。

Retryer 的建造者

除了使用構造器建立 Retryer 物件外,Guava Retry 還提供了建造者模式 RetryerBuilder:

public class RetryerBuilder<V> {

  public static <V> RetryerBuilder<V> newBuilder() {
    return new RetryerBuilder<V>();
  }

  // 省略設定策略的部分

  public Retryer<V> build() {
    AttemptTimeLimiter<V> theAttemptTimeLimiter = attemptTimeLimiter == null ? AttemptTimeLimiters.<V>noTimeLimit() : attemptTimeLimiter;
    StopStrategy theStopStrategy = stopStrategy == null ? StopStrategies.neverStop() : stopStrategy;
    WaitStrategy theWaitStrategy = waitStrategy == null ? WaitStrategies.noWait() : waitStrategy;
    BlockStrategy theBlockStrategy = blockStrategy == null ? BlockStrategies.threadSleepStrategy() : blockStrategy;

    return new Retryer<V>(theAttemptTimeLimiter, theStopStrategy, theWaitStrategy, theBlockStrategy, rejectionPredicate, listeners);
  }
}

RetryerBuilder#build方法最終也是呼叫了 Retryer 的構造器,我們舉個透過建造者建立 Retryer 的例子:

Retryer<Long> retryer = RetryerBuilder.<Long>newBuilder()
.retryIfException() // 丟擲異常時重試
.withStopStrategy(StopStrategies.stopAfterAttempt(3)) // 最大重試次數 3 次
.withWaitStrategy(WaitStrategies.fixedWait(1, TimeUnit.SECONDS)) // 每次重試間隔 1 秒
.build();

在這裡我使用了不同策略對應的工具類來獲取 Guava 中提供的預設策略。

當我們透過構造器或者建造者建立了重試器 Retryer 後,就可以直接使用Retryer#call進行包含重試機制的呼叫了,如:

Long time = retryer.call(new Callable<Long>() {
	@Override
	public Long call() throws Exception {
		return System.currentTimeMillis();
	}
});

接下來我們就透過原始碼來分析 Retryer 的重試機制和 Guava Retry 提供的策略。

原始碼分析

Retryer 重試器

Retryer 是 Guava Retry 的核心類,提供了重試機制,除了構造方法外 Retryer 只提供了兩個方法:Retryer#callRetryer#warp

其中Retryer#warp提供了對 Retryer 和 Callable 的包裝,原始碼非常簡單,不再過多贅述,重點來看Retryer#call方法:

public V call(Callable<V> callable) throws ExecutionException, RetryException {
  long startTime = System.nanoTime();
  // 建立計數器
  for (int attemptNumber = 1; ; attemptNumber++) {
    Attempt<V> attempt;
    try {
      // 呼叫 Callable 介面
      V result = attemptTimeLimiter.call(callable);
      // 封裝結果未 ResultAttempt 物件
      attempt = new ResultAttempt<V>(result, attemptNumber, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime));
    } catch (Throwable t) {
      // 封裝異常為 ExceptionAttempt 物件
      attempt = new ExceptionAttempt<V>(t, attemptNumber, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime));
    }

    // 呼叫監聽器
    for (RetryListener listener : listeners) {
      listener.onRetry(attempt);
    }

    // 判斷是否滿足重試條件
    if (!rejectionPredicate.apply(attempt)) {
      return attempt.get();
    }

    // 判斷是否達到最大重試次數
    if (stopStrategy.shouldStop(attempt)) {
      throw new RetryException(attemptNumber, attempt);
    } else {
      // 計算休眠時間
      long sleepTime = waitStrategy.computeSleepTime(attempt);
      try {
        // 呼叫阻塞策略
        blockStrategy.block(sleepTime);
      } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RetryException(attemptNumber, attempt);
      }
    }
  }
}

Retryer#call的原始碼並不複雜,與我們最開始想到的透過 while 迴圈進行重試的原理是一樣的,只不過 Guava Retry 使用了各種策略介面來代替我們耦合到程式碼中的重試次數,重試條件,休眠時間等,並且對結果和異常進行了封裝。

Guava Retry 中使用介面將各種策略與重試機制解耦,當需要修改其中任意策略或替換策略時,只需要修改對應策略的實現,或者新增策略並替換即可,而不需要去改動重試機制的程式碼,這是它使用起來非常優雅的關鍵

接下來,我們按照Retryer#call方法中各種策略呼叫的順序,來逐個分析每個策略介面所提供的功能。

Tips:以下涉及到Retryer#call方法的行數,均為該章節展現原始碼的行數。

AttemptTimeLimiter 介面

首先來看Retryer#call方法中的第 8 行:

V result = attemptTimeLimiter.call(callable);

這行程式碼中使用了 AttemptTimeLimiter 介面,該介面只提供了一個方法:

public interface AttemptTimeLimiter<V> {
  V call(Callable<V> callable) throws Exception;
}

該方法用於呼叫 Callable 介面的實現,Guava Retry 中提供了兩個 AttemptTimeLimiter 的實現:NoAttemptTimeLimit 和 FixedAttemptTimeLimit。它們均為工具類 AttemptTimeLimiters 的內部類:

public class AttemptTimeLimiters {
  @Immutable
  private static final class NoAttemptTimeLimit<V> implements AttemptTimeLimiter<V> {
    @Override
    public V call(Callable<V> callable) throws Exception {
      return callable.call();
    }
  }

  @Immutable
  private static final class FixedAttemptTimeLimit<V> implements AttemptTimeLimiter<V> {

    private final TimeLimiter timeLimiter;
    private final long duration;
    private final TimeUnit timeUnit;

    // 省略構造方法

    @Override
    public V call(Callable<V> callable) throws Exception {
      return timeLimiter.callWithTimeout(callable, duration, timeUnit, true);
    }
  }
}

從原始碼中可以很清晰的看到NoAttemptTimeLimit#call不限制呼叫的超時時間,而FixedAttemptTimeLimit#call新增了超時時間。其中帶有超時的呼叫時透過 Guava 中的 TimeLimiter 實現的。

因為 NoAttemptTimeLimit 和 FixedAttemptTimeLimit 是工具類 AttemptTimeLimiters 的私有內部類,所以我們無法直接在外部類中使用,但是可以透過工具類 AttemptTimeLimiters 來獲取 NoAttemptTimeLimit 和 FixedAttemptTimeLimit,原始碼如下:

public class AttemptTimeLimiters {
  public static <V> AttemptTimeLimiter<V> noTimeLimit() {
    return new NoAttemptTimeLimit<V>();
  }
  
  public static <V> AttemptTimeLimiter<V> fixedTimeLimit(long duration, @Nonnull TimeUnit timeUnit) {
    return new FixedAttemptTimeLimit<V>(duration, timeUnit);
  }
  
  public static <V> AttemptTimeLimiter<V> fixedTimeLimit(long duration, @Nonnull TimeUnit timeUnit, @Nonnull ExecutorService executorService) {
    return new FixedAttemptTimeLimit<V>(duration, timeUnit, executorService);
  }
}

Attempt 介面

接著來看Retryer#call方法中第 5 行中宣告的 Attempt,該介面宣告如下:

public interface Attempt<V> {

  public V get() throws ExecutionException;

  public boolean hasResult();

  public boolean hasException();

  public V getResult() throws IllegalStateException;

  public Throwable getExceptionCause() throws IllegalStateException;

  public long getAttemptNumber();

  public long getDelaySinceFirstAttempt();
}

Attempt 介面提供了對重試機制結果(正確呼叫的結果或發生的異常)的封裝,介面中提供了 7 個方法,相信大家透過方法名就可以得知每個方法的作用(下面也會透過實現類解釋每個方法的作用)。

我們看 Attempt 介面的兩個實現類 ResultAttempt 和 ExceptionAttempt,這兩個類是 Retryer 的靜態內部類,先來看Retryer#call方法中是如何使用 ResultAttempt 的:

attempt = new ResultAttempt<V>(result, attemptNumber, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime));

建立 ResultAttempt 物件時,傳入了 3 個引數,分別是 Callacle 的呼叫結果,重試次數和自首次呼叫後的耗時。下面來看 ResultAttempt 的原始碼:

@Immutable
static final class ResultAttempt<R> implements Attempt<R> {
  private final R result;
  private final long attemptNumber;
  private final long delaySinceFirstAttempt;

  // 省略構造方法

  // 獲取呼叫結果
  @Override
  public R get() throws ExecutionException {
    return result;
  }

  // 是否包含結果,ResultAttempt 的實現中只返回 true
  @Override
  public boolean hasResult() {
    return true;
  }

  // 是否包含異常,ResultAttempt 的實現中只返回 false
  @Override
  public boolean hasException() {
    return false;
  }

  // 獲取呼叫結果
  @Override
  public R getResult() throws IllegalStateException {
    return result;
  }

  // 獲取異常原因,因為 ResultAttempt 是成功呼叫,因此無異常
  @Override
  public Throwable getExceptionCause() throws IllegalStateException {
    throw new IllegalStateException("The attempt resulted in a result, not in an exception");
  }

  // 獲取重試次數
  @Override
  public long getAttemptNumber() {
    return attemptNumber;
  }

  // 獲取自首次呼叫後的耗時
  @Override
  public long getDelaySinceFirstAttempt() {
    return delaySinceFirstAttempt;
  }
}

實現上非常的簡單,這裡我們就不過多贅述了。再來看Retryer#call方法中是如何使用 ExceptionAttempt 的:

try {
  // 此處是使用 ResultAttempt的邏輯
} catch (Throwable t) {
  attempt = new ExceptionAttempt<V>(t, attemptNumber, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime));
}

同樣是 3 個引數,只是將 ResultAttempt 中的結果替換為了異常資訊。

至於 ExceptionAttempt 的原始碼實現,因為同樣是實現 Attempt 介面,因此我們很容易就能想到 ExceptionAttempt 只是與 ResultAttempt“反過來了”。

RetryListener 介面

Retryer#call方法中第 17~19 行中呼叫了 RetryListener:

for (RetryListener listener : listeners) {
  listener.onRetry(attempt);
}

RetryListener 介面只提供了一個方法:

@Beta
public interface RetryListener {
  <V> void onRetry(Attempt<V> attempt);
}

RetryListener 是作為重試過程中的監聽器出現的,為擴充套件處理提供了回撥機制。Guava Retry 並沒有提供預設實現,另外,RetryListener 被標記為 Beta,在 Guava 的解釋中,使用註解 Beta 標識的,未來可能會做出較大改動或者被移除。

Predicate 介面

Retryer#call方法中的第 22 ~ 24 行呼叫了 Predicate:

if (!rejectionPredicate.apply(attempt)) {
  return attempt.get();
}

Predicate 是 Guava 中的謂詞介面,我們來看 Predicate 介面中提供的方法:

public interface Predicate<T extends @Nullable Object> extends java.util.function.Predicate<T> {

  // Guava Retry 中定義的方法
  boolean apply(@ParametricNullness T input);

  // 繼承自 Java 中的 Object 類
  @Override
  boolean equals(@CheckForNull Object object);
}

除了以上兩個方法外,Guava 的 Predicate 介面還繼承了 Java 的 Predicate 介面,不過這些並不是我們今天的重點。

Predicate 介面在 Guava Retry 中有 3 個實現類,ResultPredicate,ExceptionClassPredicate 和 ExceptionPredicate,它們均是作為 RetryerBuilder 的內部類出現的:

private static final class ResultPredicate<V> implements Predicate<Attempt<V>> {

  private Predicate<V> delegate;

  public ResultPredicate(Predicate<V> delegate) {
    this.delegate = delegate;
  }

  @Override
  public boolean apply(Attempt<V> attempt) {
    // 判斷 Attempt 中是否包含結果
    if (!attempt.hasResult()) {
      return false;
    }
    // 獲取結果並呼叫條件謂詞的 apply 方法
    V result = attempt.getResult();
    return delegate.apply(result);
  }
}

private static final class ExceptionClassPredicate<V> implements Predicate<Attempt<V>> {

  private Class<? extends Throwable> exceptionClass;

  public ExceptionClassPredicate(Class<? extends Throwable> exceptionClass) {
    this.exceptionClass = exceptionClass;
  }

  @Override
  public boolean apply(Attempt<V> attempt) {
    if (!attempt.hasException()) {
      return false;
    }
    return exceptionClass.isAssignableFrom(attempt.getExceptionCause().getClass());
  }
}

private static final class ExceptionPredicate<V> implements Predicate<Attempt<V>> {

  private Predicate<Throwable> delegate;

  public ExceptionPredicate(Predicate<Throwable> delegate) {
    this.delegate = delegate;
  }

  @Override
  public boolean apply(Attempt<V> attempt) {
    if (!attempt.hasException()) {
      return false;
    }
    return delegate.apply(attempt.getExceptionCause());
  }
}

我們透過一段程式碼來重點解釋下 ResultPredicate 的工作原理。首先透過建造者模式建立 Retryer 物件:

Retryer<Integer> retryer = RetryerBuilder.<Integer>newBuilder()
.retryIfResult(new Predicate<Integer>() {
  @Override
  public boolean apply(Integer result) {
    return result > 0;
  }
}).withStopStrategy(StopStrategies.stopAfterAttempt(3))
.build();

接著來看RetryerBuilder#retryIfResult方法的原始碼:

public class RetryerBuilder<V> {
  public RetryerBuilder<V> retryIfResult(@Nonnull Predicate<V> resultPredicate) {
    rejectionPredicate = Predicates.or(rejectionPredicate, new ResultPredicate<V>(resultPredicate));
    return this;
  }
}

可以看到RetryerBuilder#retryIfResult中使用Predicates#or去構建條件謂詞,第一個引數是 RetryerBuilder 的成員變數 rejectionPredicate,透過前面的 RetryerBuilder 的原始碼,我們可以知道 RetryerBuilder 的 rejectionPredicate 最終會成為 Retryer 的成員變數,第二個引數是透過我們傳入的 Predicate 物件構建的 ResultPredicate 物件。

Predicates#or方法的作用是將傳入的引數合併為新的 Predicate 物件:

public static <T extends @Nullable Object> Predicate<T> or(Predicate<? super T> first, Predicate<? super T> second) {
  return new OrPredicate<>(Predicates.<T>asList(checkNotNull(first), checkNotNull(second)));
}

注意合併後的 Predicate 物件是其實現類 OrPredicate,該類中的成員變數private final List<? extends Predicate<? super T>> components,包含了所有的透過 RetryerBuilder 新增的條件謂詞。

OrPredicate#apply方法是透過迴圈呼叫不同的 Predicate 物件的:

private static class OrPredicate<T extends @Nullable Object> implements Predicate<T>, Serializable {

  private final List<? extends Predicate<? super T>> components;

  @Override
  public boolean apply(@ParametricNullness T t) {
    for (int i = 0; i < components.size(); i++) {
      if (components.get(i).apply(t)) {
        return true;
      }
    }
    return false;
  }
}

OrPredicate#apply方法會迴圈遍歷條件謂詞並呼叫Predicate#apply方法,這就回到了ResultPredicate#apply方法中了。

我們注意到在RetryerBuilder#retryIfResult構建 ResultPredicate 物件時,我們傳入的內部類 Predicate 物件會作為 ResultPredicate 的成員變數 delegate,而最終判斷結果是否滿足條件的也正是透過成員變數 delegate 實現的:

private static final class ResultPredicate<V> implements Predicate<Attempt<V>> {
  @Override
  public boolean apply(Attempt<V> attempt) {
    if (!attempt.hasResult()) {
      return false;
    }
    V result = attempt.getResult();
    return delegate.apply(result);
  }
}

至此,我們已經知曉了 ResultPredicate 在Retryer#call方法中的工作原理,至於 ExceptionClassPredicate 和 ExceptionPredicate,由於原理和 ResultPredicate 相似,我就不做解釋了。

StopStrategy 介面

Retryer#call方法中第 27 行中呼叫了 StopStrategy:

if (stopStrategy.shouldStop(attempt)) {
  throw new RetryException(attemptNumber, attempt);
} else {
  // 省略休眠策略 
}

StopStrategy 介面只提供了一個方法,用於判斷是否需要重試,介面宣告如下:

public interface StopStrategy {
  boolean shouldStop(Attempt failedAttempt);
}

Guava Retry 中提供了 3 個實現類:NeverStopStrategy,StopAfterAttemptStrategy 和 StopAfterDelayStrategy。這 3 個實現類均為工具類 StopStrategys 的內部類:

public final class StopStrategies {
  @Immutable
  private static final class NeverStopStrategy implements StopStrategy {
    @Override
    public boolean shouldStop(Attempt failedAttempt) {
      return false;
    }
  }
  
  @Immutable
  private static final class StopAfterAttemptStrategy implements StopStrategy {
    private final int maxAttemptNumber;

    // 省略構造方法

    @Override
    public boolean shouldStop(Attempt failedAttempt) {
      return failedAttempt.getAttemptNumber() >= maxAttemptNumber;
    }
  }

  @Immutable
  private static final class StopAfterDelayStrategy implements StopStrategy {
    private final long maxDelay;

    // 省略構造方法

    @Override
    public boolean shouldStop(Attempt failedAttempt) {
      return failedAttempt.getDelaySinceFirstAttempt() >= maxDelay;
    }
  }
}

來解釋下這 3 個策略的功能:

  • NeverStopStrategy,永遠不會停止重試,除非滿足條件謂詞的情況出現
  • StopAfterAttemptStrategy,在指定次數後停止重試;
  • StopAfterDelayStrategy,在指定時間後停止重試。

通常我們會選擇 StopAfterAttemptStrategy,在有時間要求的場景下也可以選擇 StopAfterDelayStrategy。

需要注意的是,在Retryer#call的方法中,如果是因為觸發 StopStrategy 而導致的停止重試,則會丟擲異常 RetryException,該異常中封裝了異常資訊和最後一次請求的結果。這就要求在使用 Retryer 時,需要做好異常處理。

WaitStrategy 介面和 BlockStrategy 介面

這兩個介面分別在Retryer#call方法的第 31 行和 34 行呼叫:

if (stopStrategy.shouldStop(attempt)) {
  throw new RetryException(attemptNumber, attempt);
} else {
  // 呼叫計算休眠時間策略
  long sleepTime = waitStrategy.computeSleepTime(attempt);
  try {
    // 呼叫阻塞策略
    blockStrategy.block(sleepTime);
  } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new RetryException(attemptNumber, attempt);
  }
}

WaitStrategy 介面提供了計算休眠時間的策略,而 BlockStrategy 介面提供了重試阻塞策略,介面宣告如下:

public interface WaitStrategy {
  long computeSleepTime(Attempt failedAttempt);
}

public interface BlockStrategy {
  void block(long sleepTime) throws InterruptedException;
}

兩者的功能是相輔相成的,WaitStrategy#computeSleepTime計算每次要休眠的時間,而BlockStrategy#block負責執行阻塞策略

我們先來看 Guava Retry 中提供的 BlockStrategy 的實現 ThreadSleepStrategy,該實現作為工具類 BlockStrategys 的內部類出現,實現非常簡單:

public final class BlockStrategies {
  @Immutable
  private static class ThreadSleepStrategy implements BlockStrategy {

    @Override
    public void block(long sleepTime) throws InterruptedException {
      // 休眠指定時間
      Thread.sleep(sleepTime);
    }
  }
}

接著來看 WaitStrategy 的實現類,Guava Retry 中提供了 7 種 WaitStrategy 介面的實現:

  • FixedWaitStrategy,固定休眠時間策略;
  • RandomWaitStrategy,隨機休眠時間策略;
  • IncrementingWaitStrategy,按步長增長的休眠時間策略;
  • ExponentialWaitStrategy,指數增長的休眠時間策略;
  • FibonacciWaitStrategy,透過斐波那契數列計算休眠時間的策略;
  • CompositeWaitStrategy,混合的休眠時間策略;
  • ExceptionWaitStrategy,發生異常時的休眠時間。

以上 7 種策略均為工具類 WaitStrategys 的內部類,可以直接透過 WaitStrategys 來使用:

public final class WaitStrategies {

	// 使用固定休眠時間策略
	public static WaitStrategy fixedWait(long sleepTime, @Nonnull TimeUnit timeUnit) throws IllegalStateException {
		return new FixedWaitStrategy(timeUnit.toMillis(sleepTime));
	}

	// 使用隨機休眠時間策略
	public static WaitStrategy randomWait(long maximumTime, @Nonnull TimeUnit timeUnit) {
		return new RandomWaitStrategy(0L, timeUnit.toMillis(maximumTime));
	}

	public static WaitStrategy randomWait(long maximumTime, @Nonnull TimeUnit timeUnit) {
		return new RandomWaitStrategy(0L, timeUnit.toMillis(maximumTime));
	}

	// 使用按步長增長的休眠時間策略
	public static WaitStrategy incrementingWait(long initialSleepTime,  @Nonnull TimeUnit initialSleepTimeUnit, long increment, @Nonnull TimeUnit incrementTimeUnit) {
		return new IncrementingWaitStrategy(initialSleepTimeUnit.toMillis(initialSleepTime), incrementTimeUnit.toMillis(increment));
	}

	// 使用指數增長的休眠時間策略
	public static WaitStrategy exponentialWait() {
		return new ExponentialWaitStrategy(1, Long.MAX_VALUE);
	}

	public static WaitStrategy exponentialWait(long maximumTime, @Nonnull TimeUnit maximumTimeUnit) {
		return new ExponentialWaitStrategy(1, maximumTimeUnit.toMillis(maximumTime));
	}

	public static WaitStrategy exponentialWait(long multiplier, long maximumTime, @Nonnull TimeUnit maximumTimeUnit) {
		return new ExponentialWaitStrategy(multiplier, maximumTimeUnit.toMillis(maximumTime));
	}

	// 使用透過斐波那契數列計算休眠時間的策略
	public static WaitStrategy fibonacciWait() {
		return new FibonacciWaitStrategy(1, Long.MAX_VALUE);
	}

	public static WaitStrategy fibonacciWait(long maximumTime, @Nonnull TimeUnit maximumTimeUnit) {
		return new FibonacciWaitStrategy(1, maximumTimeUnit.toMillis(maximumTime));
	}

	public static WaitStrategy fibonacciWait(long multiplier, long maximumTime, @Nonnull TimeUnit maximumTimeUnit) {
		return new FibonacciWaitStrategy(multiplier, maximumTimeUnit.toMillis(maximumTime));
	}

	// 使用混合的休眠時間策略
	public static WaitStrategy join(WaitStrategy... waitStrategies) {
		List<WaitStrategy> waitStrategyList = Lists.newArrayList(waitStrategies);
		return new CompositeWaitStrategy(waitStrategyList);
	}

	// 使用發生異常時的休眠時間
	public static <T extends Throwable> WaitStrategy exceptionWait(@Nonnull Class<T> exceptionClass, @Nonnull Function<T, Long> function) {
		return new ExceptionWaitStrategy<T>(exceptionClass, function);
	}
}

最後我們來逐個分析每種策略的實現方式。

FixedWaitStrategy

最常用的策略,每次重試後休眠固定的時間,原始碼如下:

@Immutable
private static final class FixedWaitStrategy implements WaitStrategy {
  private final long sleepTime;

  public FixedWaitStrategy(long sleepTime) {
    this.sleepTime = sleepTime;
  }

  @Override
  public long computeSleepTime(Attempt failedAttempt) {
    return sleepTime;
  }
}

RandomWaitStrategy

每次在最小休眠時間和最大休眠時間之間隨機出一個休眠時間,原始碼如下:

@Immutable
private static final class RandomWaitStrategy implements WaitStrategy {
  private static final Random RANDOM = new Random();
  private final long minimum;
  private final long maximum;

  public RandomWaitStrategy(long minimum, long maximum) {
    this.minimum = minimum;
    this.maximum = maximum;
  }

  @Override
  public long computeSleepTime(Attempt failedAttempt) {
    long t = Math.abs(RANDOM.nextLong()) % (maximum - minimum);
    return t + minimum;
  }
}

計算方法並不複雜,計算出最小時間到最大時間中的一個隨機數,再加上最小時間即可。

IncrementingWaitStrategy

隨著每次重試,休眠時間都會固定增長的策略:

@Immutable
private static final class IncrementingWaitStrategy implements WaitStrategy {
  private final long initialSleepTime;
  private final long increment;

  public IncrementingWaitStrategy(long initialSleepTime, long increment) {
    this.initialSleepTime = initialSleepTime;
    this.increment = increment;
  }

  @Override
  public long computeSleepTime(Attempt failedAttempt) {
    long result = initialSleepTime + (increment * (failedAttempt.getAttemptNumber() - 1));
    return result >= 0L ? result : 0L;
  }
}

引數為初始的休眠時間和每次增長的步長,透過 Retryer 中的重試次數計算每次增長的時間。

ExponentialWaitStrategy

按照重試次數指數增長休眠時間的策略:

@Immutable
private static final class ExponentialWaitStrategy implements WaitStrategy {
  private final long multiplier;
  private final long maximumWait;

  public ExponentialWaitStrategy(long multiplier, long maximumWait) {
    this.multiplier = multiplier;
    this.maximumWait = maximumWait;
  }

  @Override
  public long computeSleepTime(Attempt failedAttempt) {
    double exp = Math.pow(2, failedAttempt.getAttemptNumber());
    long result = Math.round(multiplier * exp);
    if (result > maximumWait) {
      result = maximumWait;
    }
    return result >= 0L ? result : 0L;
  }
}

傳入引數為最大休眠時間和係數,以 2 位底,以重試次數為指數計算出休眠時間的基數,並乘以傳入的係數,得出真正的休眠時間,當計算結果超過最大休眠時間時,使用最大休眠時間。

FibonacciWaitStrategy

按照重試次數獲取對應斐波那契數作為休眠時間的策略:

@Immutable
private static final class FibonacciWaitStrategy implements WaitStrategy {
	private final long multiplier;
	private final long maximumWait;

	public FibonacciWaitStrategy(long multiplier, long maximumWait) {
		this.multiplier = multiplier;
		this.maximumWait = maximumWait;
	}

	@Override
	public long computeSleepTime(Attempt failedAttempt) {
		long fib = fib(failedAttempt.getAttemptNumber());
		long result = multiplier * fib;

		if (result > maximumWait || result < 0L) {
			result = maximumWait;
		}

		return result >= 0L ? result : 0L;
	}

	private long fib(long n) {
		if (n == 0L) return 0L;
		if (n == 1L) return 1L;

		long prevPrev = 0L;
		long prev = 1L;
		long result = 0L;

		for (long i = 2L; i <= n; i++) {
			result = prev + prevPrev;
			prevPrev = prev;
			prev = result;
		}

		return result;
	}
}

與 ExponentialWaitStrategy 的策略非常相似,傳入引數為最大休眠時間和係數,獲取重試次數對應的斐波那契數為休眠時間的基數,並乘以傳入的係數,得出真正的休眠時間,當計算結果超過最大休眠時間時,使用最大休眠時間。

CompositeWaitStrategy

融合多種計算休眠時間策略的策略:

@Immutable
private static final class CompositeWaitStrategy implements WaitStrategy {
	private final List<WaitStrategy> waitStrategies;

	public CompositeWaitStrategy(List<WaitStrategy> waitStrategies) {
		this.waitStrategies = waitStrategies;
	}

	@Override
	public long computeSleepTime(Attempt failedAttempt) {
		long waitTime = 0L;
		for (WaitStrategy waitStrategy : waitStrategies) {
			waitTime += waitStrategy.computeSleepTime(failedAttempt);
		}
		return waitTime;
	}
}

計算出每種休眠時間策略的休眠時間,並相加得到最終的休眠時間。

ExceptionWaitStrategy

這種策略用於發生異常時計算休眠時間:

@Immutable
private static final class ExceptionWaitStrategy<T extends Throwable> implements WaitStrategy {
	private final Class<T> exceptionClass;
	private final Function<T, Long> function;

	public ExceptionWaitStrategy(@Nonnull Class<T> exceptionClass, @Nonnull Function<T, Long> function) {
		this.exceptionClass = exceptionClass;
		this.function = function;
	}

	@SuppressWarnings({"ThrowableResultOfMethodCallIgnored", "ConstantConditions", "unchecked"})
	@Override
	public long computeSleepTime(Attempt lastAttempt) {
		if (lastAttempt.hasException()) {
			Throwable cause = lastAttempt.getExceptionCause();
			if (exceptionClass.isAssignableFrom(cause.getClass())) {
				return function.apply((T) cause);
			}
		}
		return 0L;
	}
}

需要傳入異常型別和 Function 的實現,當發生相應型別的異常時,執行Function#apply方法計算休眠時間,可以實現不同異常的休眠時間不相同。

舉個例子,首先定義 3 個異常以及它們的父類:

public class BaseException extends Exception {
	public BaseException(String message) {
		super(message);
	}
}

public class OneException extends BaseException {
	public OneException(String message) {
		super(message);
	}
}

public class TwoException extends BaseException {
	public TwoException(String message) {
		super(message);
	}
}

public class ThreeException extends BaseException {
	public ThreeException(String message) {
		super(message);
	}
}

接著實現 Function 介面:

public class ExceptionFunction implements Function<BaseException, Long> {

	@Override
	public Long apply(BaseException input) {
		if (OneException.class.isAssignableFrom(input.getClass())) {
			System.out.println("觸發OneException,休眠1秒!");
			return 1000L;
		}
		if (TwoException.class.isAssignableFrom(input.getClass())) {
			System.out.println("觸發TwoException,休眠2秒!");
			return 2000L;
		}
		if (ThreeException.class.isAssignableFrom(input.getClass())) {
			System.out.println("觸發ThreeException,休眠3秒!");
			return 3000L;
		}
		return 0L;
	}
}

該介面中根據不同的異常,返回不同的休眠時間。

最後我們來構建重試器,並呼叫Retryer#call方法:

Retryer<Integer> retryer = RetryerBuilder.<Integer>newBuilder()
.retryIfException()
.withWaitStrategy(WaitStrategies.exceptionWait(BaseException.class, new ExceptionFunction()))
.withStopStrategy(StopStrategies.stopAfterAttempt(4))
.build();

int number = retryer.call(new Callable<>() {
	private int count = 1;
	@Override
	public Integer call() throws Exception {
		if (count < 2) {
			count++;
			throw new OneException("One");
		}
		if (count < 3) {
			count++;
			throw new TwoException("Two");
		}
		if (count < 4) {
			count++;
			throw new ThreeException("Three");
		}
		return count;
	}
});
System.out.println(number);

重試器 Retryer 在呼叫介面異常時進行重試,最大重試次數為 4 次,休眠時間的策略上,選擇發生異常時,根據不同的異常休眠不同的時間。Retryer.call呼叫的 Callable 介面中,前 3 次呼叫分別丟擲 OneException,TwoException 和 ThreeException,在第 4 次呼叫時返回數字 4。執行程式碼後可以觀察到如下輸出內容:

證明在發生不同異常時,觸發了不同的休眠時間策略。

實戰演練

目前為止,我們已經從使用和原理上了解了 Guava Retry,接下來我們就以開篇所說的場景為例,進行實戰演練。

我們的業務場景中,可以接受通知中會員等級的變化不夠及時,但因為金融監管的要求,不能接受因為客戶等級的變化,導致延後傳送通知。因此我們在重試策略的制定中非常寬鬆:重試 3 次每次間隔 1 秒,如果 3 次後依舊沒有獲取到最新的資料,就使用前一次請求的結果。

首先建立客戶類:

public class CustomerDTO {

  private Long customerId;

  private String customerName;

  private CustomerLevel customerLevel;

  private Long lastOrderId;
}

其中 lastOrderId 記錄了最後一次引起客戶等級,客戶積分發生變化的訂單 Id,我們需要以此來判斷是否獲取到對應的客戶資訊。

接著建立獲取客戶資訊的方法,用來模仿客戶中心:

public class CustomerCenter {

  private static int count = 0;

  public static CustomerDTO getCustomerInfo(Long customerId) {
    if (count < 1) {
      count++;
      return createCustomerInfo(customerId, CustomerLevel.JUNIOR_MEMBER, 1234567L);
    } else if (count < 2) {
      count++;
      return createCustomerInfo(customerId, CustomerLevel.INTERMEDIATE_MEMBER, 12345678L);
    } else {
      count = 0;
      return createCustomerInfo(customerId, CustomerLevel.SENIOR_MEMBER, 123456789L);
    }
  }

  private static CustomerDTO createCustomerInfo(Long customerId, CustomerLevel customerLevel, Long lastOrdertId) {
    CustomerDTO customerDTO = new CustomerDTO();
    customerDTO.setCustomerId(customerId);
    customerDTO.setCustomerName("WYZ");
    customerDTO.setCustomerLevel(customerLevel);
    customerDTO.setLastOrderId(lastOrdertId);

    return customerDTO;
  }
}

其中CustomerCenter#getCustomerInfo模擬了在第 3 次查詢時獲取到最新的客戶資訊。

最後我們來寫重試的程式碼:

public static void main(String[] args) throws ExecutionException {

  Long lastOrderId = 123456789L;

  Retryer<CustomerDTO> retryer = RetryerBuilder.<CustomerDTO>newBuilder()
  .retryIfResult(customerDTO -> !lastOrderId.equals(customerDTO.getLastOrderId()))
  .withWaitStrategy(failedAttempt -> 1000)
  .withStopStrategy(attempt -> attempt.getAttemptNumber() > 2)
  .build();

  CustomerDTO customerDTO;
  try {
    customerDTO = retryer.call(() -> CustomerCenter.getCustomerInfo(1L));
  } catch (RetryException e) {
    Attempt<?> attempt = e.getLastFailedAttempt();
    customerDTO = (CustomerDTO) attempt.get();
  }
}

建立 Retryer 的過程我就不過多解釋了,我們來看第 15,16 行中的部分,透過前面原始碼的部分我們可以看到,Guava Retry 在超出重試次數後,依舊無法獲取到預期結果時,會丟擲 RetryException 異常,該異常中除了包含異常資訊外,還包含最後一次執行後的 Attempt,因此,我可以透過 Attempt 來獲取到最後一次的執行結果,剛剛好滿足了我們的業務需求。

好了,今天的內容就到這裡了。


如果本文對你有幫助的話,希望多多點贊支援。如果文章中出現任何錯誤,還請批評指正。最後歡迎大家關注分享硬核Java技術的金融摸魚俠王有志,我們下次再見!

相關文章