一、背景
什麼是重試?
一種保障機制,why not try again!
無論是單體服務模組化的呼叫,或是微服務當道的今天服務間的相互呼叫。一次業務請求包含了太多的鏈條環扣,每一扣的失敗都會導致整個請求的失敗。因此需要保障每個環節的可用性。
二、動態策略配置
1、基本配置項
涉及重試,我們所需要關心的幾點基本包括:什麼時候重試?重試多少次?每次重試的間隔?
也即:重試異常、最大重試次數、重試間隔。
1)重試異常:
其實拿重試異常作為“什麼時候重試?”的結論也不太完整。異常是一個通常的觸發點,比如發生rpc超時了,此時需要觸發重試機制以再次請求獲取結果。但是有時,我們也會關注返回結果是否符合預期,比如,我們去請求某個狀態,但是返回的和我們預期的不符(通常發成此種情況,一方面可能是資料層面的一致性問題,或者服務層面,服務提供方存在異常處理或者降級策略等),我們就需要去再次嘗試獲取。此處對於此類不再展開討論。
2)最大重試次數:
最大,我們知道這是一個上限控制,重試也需要有終止條件(類似遞迴的終止),無論你的重試切入點是在入口,或者下游的某個鏈條,我們需要明確的是整個服務的【基本響應時間】要求必須得到保障。
重試是需要消耗額外時間的,包括每次的間隔及重試請求的耗時,因此必須綜合考量配置。
3)重試間隔:
上面一點,我們已經提到重視間隔時間的概念,即,每次重試請求之間的間隔。
為什麼會需要這個間隔呢?直接連續重試不行嗎?其實,也是可以的,但是可能不合理。
間隔的存在涉及分散服務壓力的需要,把請求平攤到更長的時間段內,減小下游服務的壓力,比如我們在第一點中提到的,如果是因為下游服務觸發降級導致的非預期結果重試,那麼提供必要的間隔時間以供下游服務恢復服務能力則是必須的。
當然,重試間隔也可以有多種策略,比如每次在一個數值範圍內隨機間隔時間、逐漸遞增間隔時間或者只是簡單地固定長度間隔時間,可以根據實際的業務情景進行定製化的處理。
2、配置中心選擇
其實此處,我們只是需要一種機制,即,配置的儲存和配置變更的及時發現。任何實現都是可以的。
成熟的配置中心如 spring-cloud-config、apollo、nacos 等。或者基於 zookeeper、redis等,加上自己實現監聽。
此處,我們簡單介紹基於apollo配置中心。
詳細可以參考:Apollo(阿波羅)配置中心Java客戶端使用指南使用指南
如下,基於註解配置相應的監聽 Listner,監聽重試策略配置key變動
interestedKeys 需要監聽的配置key。
3、配置
如下針對不同策略,新增不同的配置,以 name 區分:
[ { "name": "ht", //策略名稱 "max": 2, //最大重試次數 "dur": 200, //重試間隔 "ex": "xxx.XXXException" //需要重試的異常 } ]
4、策略建立
策略的建立時機主要分為兩部分,
一是服務啟動時的初始化,此時需要拉取配置中心的配置進行寫略的初始建立儲存;
二是配置變更,監聽獲取到配置變化時對策略的重新初始化建立替換。
三、重試框架
目前流行的的包含或者專於重試實現的框架可能比較多,限於認知,僅就如下調研的幾個做簡要入門介紹:
1、guava-retrying
docs:https://github.com/rholder/guava-retrying
guava-retrying是基於Guava核心庫的。
基本組成部分如下圖:
Retryer:重試的入口和實際執行者。
StopStrategy:重試終止策略,也即什麼時候停止重試。
WaitStrategy:間隔策略,確定每次重試間隔時間。
Attempt:代表每次重試請求,記錄請求資料及結果。
基本依賴:
<dependency> <groupId>com.github.rholder</groupId> <artifactId>guava-retrying</artifactId> <version>{version}</version> </dependency>
完整的重試配置如下:
基於記憶體儲存不同策略的重試器 RETRYERS
/** * dynamic retry config */ @Slf4j @Configuration public class MyRetryConfig { //retry apollo config private static final String RETRY_RULES_CONFIG = "retry_rules"; //retry duration min 100 ms private static long RETRY_DURATION_MIN = 100; //retry duration max 500 ms private static long RETRY_DURATION_MAX = 500; //retry min attempts 1 private static int RETRY_ATTEMPTS_MIN = 1; //retry max attempts 3 private static int RETRY_ATTEMPTS_MAX = 3; //retry default config private static String RETRY_DEFAULT_KEY = "default"; //retry default private static Retryer RETRY_DEFAULT = RetryerBuilder.newBuilder() .withWaitStrategy(WaitStrategies.fixedWait(RETRY_DURATION_MIN, TimeUnit.MILLISECONDS)) //retry duration .withStopStrategy(StopStrategies.stopAfterAttempt(RETRY_ATTEMPTS_MAX)) //max retry times .build(); //retryer private static Map<String, Retryer> RETRYERS = new HashMap<>(); @PostConstruct public void init() { String retryConfig = ApolloConfig.getValue(RETRY_RULES_CONFIG, StringUtils.EMPTY); try { RETRYERS.clear(); MyRetry[] config = Optional.ofNullable(JJsonUtil.jsonToBeanArray(retryConfig, MyRetry[].class)).orElse(new MyRetry[0]); for (MyRetry myRetry : config) { RETRYERS.put(myRetry.getName(), buildRetryer(myRetry)); } log.info("retry config init, config: {}", retryConfig); } catch (IOException e) { log.warn("init retry config failed"); } } /** * apollo retry config listener * * listening key: RETRY_RULES_CONFIG * * @param changeEvent */ @ApolloConfigChangeListener(interestedKeys = {RETRY_RULES_CONFIG}) private void apolloConfigChangeEvent(ConfigChangeEvent changeEvent) { log.info("retry config changed, reconfig retry: {}", changeEvent.getChange(RETRY_RULES_CONFIG)); init(); } /** * config and build retryer * * @param myRetry * @return */ private Retryer buildRetryer(MyRetry myRetry) { //suitable max int max = NumUtils.getLimitedNumber(myRetry.getMax(), RETRY_ATTEMPTS_MIN, RETRY_ATTEMPTS_MAX); //suitable duration long duration = NumUtils.getLimitedNumber(myRetry.getDur(), RETRY_DURATION_MIN, RETRY_DURATION_MAX); return buildRetryer(max, duration, parseRetryConfigEx(myRetry.getEx()), myRetry.getName()); } /** * retry trace exceptions config => Class * * @param config * @return */ private Class<? extends Throwable>[] parseRetryConfigEx(String config) { String [] exs = Optional.ofNullable(StringUtils.split(config, "|")).orElse(ArrayUtils.EMPTY_STRING_ARRAY); List<Class<? extends Throwable>> exClazz = new ArrayList<>(); for (String ex : exs) { try { exClazz.add((Class<? extends Throwable>) Class.forName(ex)); } catch (ClassNotFoundException e) { log.warn("parse retry ex config failed, config: {}, e: {}", ex, e.getMessage()); } } return exClazz.toArray(new Class[0]); } /** * config and build retryer * * @param maxAttempts * @param duration * @param errorClasses * @return */ public Retryer buildRetryer(int maxAttempts, long duration, Class<? extends Throwable>[] errorClasses, String name) { RetryerBuilder builder = RetryerBuilder.newBuilder() .withWaitStrategy(WaitStrategies.fixedWait(duration, TimeUnit.MILLISECONDS)) //retry dueation .withStopStrategy(StopStrategies.stopAfterAttempt(maxAttempts)); //max retry times //trace exceptions for (Class<? extends Throwable> errorClass : errorClasses) { builder.retryIfExceptionOfType(errorClass); } //retry listener builder.withRetryListener(new RetryListener() { @Override public <V> void onRetry(Attempt<V> attempt) { log.info("retry attempt, times: {}, duration: {}", attempt.getAttemptNumber(), attempt.getDelaySinceFirstAttempt()); } }); return builder.build(); } /** * get or default * * @param retryer * @return */ public static Retryer getRetryer(String retryer) { return RETRYERS.getOrDefault(StringUtils.defaultIfEmpty(retryer, RETRY_DEFAULT_KEY), RETRY_DEFAULT); } }
重試工具類:
基於預設策略或者指定策略的重試包裝呼叫:
@Slf4j public class RetryUtils { private RetryUtils(){} //default retry public static <T> T callWithRetry(Callable<T> callable) throws Exception { return callWithRetry(null, callable); } //custom retry public static <T> T callWithRetry(String retryer, Callable<T> callable) throws Exception { return (T) MyRetryConfig.getRetryer(retryer).call(callable); } }
呼叫:
List<Object> list = RetryUtils.callWithRetry(() -> xxxService.getXXXs(args));
2、spring-retry
docs:https://github.com/spring-projects/spring-retry
spring-retry 我們基於 RetryTemplate,使用方式和 guava-retrying 類似。spring-retry 支援基於註解的方式,此處不再展開討論。
基本依賴:
<dependency> <groupId>org.springframework.retry</groupId> <artifactId>spring-retry</artifactId> <version>{version}</version> </dependency>
完整的配置:
/** * dynamic retry config */ @Slf4j @Configuration public class MyRetryConfig { //retry apollo config private static final String RETRY_RULES_CONFIG = "retry_rules"; //retry duration min 100 ms private static long RETRY_DURATION_MIN = 100; //retry duration max 500 ms private static long RETRY_DURATION_MAX = 500; //retry min attempts 1 private static int RETRY_ATTEMPTS_MIN = 1; //retry max attempts 3 private static int RETRY_ATTEMPTS_MAX = 3; //retry default config private static String RETRY_DEFAULT_KEY = "default"; //retry default private static RetryTemplate RETRY_DEFAULT = RetryTemplate.builder() .fixedBackoff(RETRY_DURATION_MIN) //retry duration .maxAttempts(RETRY_ATTEMPTS_MAX) //max retry times .build(); private static Map<String, RetryTemplate> RETRYERS = new HashMap<>(); @PostConstruct public void init() { String retryConfig = ApolloConfig.getValue(RETRY_RULES_CONFIG, StringUtils.EMPTY); try { RETRYERS.clear(); MyRetry[] config = Optional.ofNullable(JJsonUtil.jsonToBeanArray(retryConfig, MyRetry[].class)).orElse(new MyRetry[0]); for (MyRetry myRetry : config) { RETRYERS.put(myRetry.getName(), buildRetryTemplate(myRetry)); } log.info("retry config init, config: {}", retryConfig); } catch (IOException e) { log.warn("init retry config failed"); } } /** * apollo retry config listener * * listening key: RETRY_RULES_CONFIG * * @param changeEvent */ @ApolloConfigChangeListener(interestedKeys = {RETRY_RULES_CONFIG}) private void apolloConfigChangeEvent(ConfigChangeEvent changeEvent) { log.info("retry config changed, reconfig retry: {}", changeEvent.getChange(RETRY_RULES_CONFIG)); init(); } /** * config and build retryTemplate * * @param myRetry * @return */ private RetryTemplate buildRetryTemplate(MyRetry myRetry) { //suitable max int max = NumUtils.getLimitedNumber(myRetry.getMax(), RETRY_ATTEMPTS_MIN, RETRY_ATTEMPTS_MAX); //suitable duration long duration = NumUtils.getLimitedNumber(myRetry.getDur(), RETRY_DURATION_MIN, RETRY_DURATION_MAX); return buildRetryTemplate(max, duration, parseRetryConfigEx(myRetry.getEx()), myRetry.getName()); } /** * retry trace exceptions config => Class * * @param config * @return */ private Class<? extends Throwable>[] parseRetryConfigEx(String config) { String [] exs = Optional.ofNullable(StringUtils.split(config, "|")).orElse(ArrayUtils.EMPTY_STRING_ARRAY); List<Class<? extends Throwable>> exClazz = new ArrayList<>(); for (String ex : exs) { try { exClazz.add((Class<? extends Throwable>) Class.forName(ex)); } catch (ClassNotFoundException e) { log.warn("parse retry ex config failed, config: {}, e: {}", ex, e.getMessage()); } } return exClazz.toArray(new Class[0]); } /** * config and build retryTemplate * * @param maxAttempts * @param duration * @param errorClasses * @return */ public RetryTemplate buildRetryTemplate(int maxAttempts, long duration, Class<? extends Throwable>[] errorClasses, String name) { RetryTemplateBuilder builder = RetryTemplate.builder() .maxAttempts(maxAttempts) //max retry times .fixedBackoff(duration); //retry dueation //trace exceptions for (Class<? extends Throwable> errorClass : errorClasses) { builder.retryOn(errorClass); } //retry listener builder.withListener(new RetryListenerSupport(){ @Override public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) { log.info("retry: {}", context); } }); return builder.build(); } /** * get or default * * @param retryTemplate * @return */ public static RetryTemplate getRetryTemplate(String retryTemplate) { return RETRYERS.getOrDefault(StringUtils.defaultIfEmpty(retryTemplate, RETRY_DEFAULT_KEY), RETRY_DEFAULT); } }
重試工具類:
@Slf4j public class RetryUtils { private RetryUtils(){} //default retry public static <T> T callWithRetry(RetryCallback<T, Exception> callback) throws Exception { return callWithRetry(null, callback); } //custom retry public static <T> T callWithRetry(String retryer, RetryCallback<T, Exception> callback) throws Exception { return (T) MyRetryConfig.getRetryTemplate(retryer).execute(callback); } }
呼叫:
List<Object> list = RetryUtils.callWithRetry(context -> xxxService.getXXXs(args));
3、resilience4j-retry
Resilience4j 是一個輕量級的容錯框架,提供包括熔斷降級,流控及重試等功能。
詳細參考文件:https://resilience4j.readme.io/docs/retry
基本依賴:
<dependency> <groupId>io.github.resilience4j</groupId> <artifactId>resilience4j-retry</artifactId> <version>{version}</version> </dependency>
完整配置:
此處使用 RetryRegistry 作為策略註冊管理中心。
/** * Resilience4j config */ @Slf4j @Configuration public class MyRetryConfig { private static final String RETRY_RULES_CONFIG = "retry_rules"; //retry duration min 100 ms private static long RETRY_DURATION_MIN = 100; //retry min attempts 1 private static int RETRY_ATTEMPTS_MIN = 1; //retry max attempts 3 private static int RETRY_ATTEMPTS_MAX = 3; @Resource private RetryRegistry retryRegistry; @PostConstruct public void init() { initRetry(false); } /** * init retry * * @param reInit config change reinit */ private void initRetry(boolean reInit) { String retryConfig = ApolloConfig.getValue(RETRY_RULES_CONFIG, StringUtils.EMPTY); try { MyRetry[] config = Optional.ofNullable(JJsonUtil.jsonToBeanArray(retryConfig, MyRetry[].class)).orElse(new MyRetry[0]); for (MyRetry myRetry : config) { if (reInit) { retryRegistry.replace(myRetry.getName(), Retry.of(myRetry.getName(), parseRetryConfig(myRetry))); } else { retryRegistry.retry(myRetry.getName(), parseRetryConfig(myRetry)); } } log.info("r4jConfigEvent, init retry: {}", retryConfig); } catch (IOException e) { log.warn("init retry config failed"); } } /** * apollo retry config listener * * listening key: RETRY_RULES_CONFIG * * @param changeEvent */ @DependsOn(value = "retryRegistry") @ApolloConfigChangeListener(interestedKeys = {RETRY_RULES_CONFIG}) private void apolloConfigChangeEvent(ConfigChangeEvent changeEvent) { log.info("retry config changed, reconfig retry: {}", changeEvent.getChange(RETRY_RULES_CONFIG)); initRetry(true); } /** * retry config => RetryConfig * * @param myRetry * @return */ private RetryConfig parseRetryConfig(MyRetry myRetry) { //suitable max int max = NumUtils.getLimitedNumber(myRetry.getMax(), RETRY_ATTEMPTS_MIN, RETRY_ATTEMPTS_MAX); //suitable duration long duration = NumUtils.getLimitedNumber(myRetry.getDur(), RETRY_DURATION_MIN, RetryConfig.DEFAULT_WAIT_DURATION); return configRetryConfig(max, duration, parseRetryConfigEx(myRetry.getEx())); } /** * retry exception config => Class * * @param config * @return */ private Class<? extends Throwable>[] parseRetryConfigEx(String config) { String [] exs = Optional.ofNullable(StringUtils.split(config, "|")).orElse(ArrayUtils.EMPTY_STRING_ARRAY); List<Class<? extends Throwable>> exClazz = new ArrayList<>(); for (String ex : exs) { try { exClazz.add((Class<? extends Throwable>) Class.forName(ex)); } catch (ClassNotFoundException e) { log.warn("parse retry ex config failed, config: {}, e: {}", ex, e.getMessage()); } } return exClazz.isEmpty() ? null : exClazz.toArray(new Class[0]); } /** * process retry config * * @param maxAttempts * @param duration * @param errorClasses * @return */ public RetryConfig configRetryConfig(int maxAttempts, long duration, Class<? extends Throwable>[] errorClasses) { return RetryConfig .custom() .maxAttempts(maxAttempts) //max retry times .waitDuration(Duration.ofMillis(duration)) //retry duration .retryExceptions(errorClasses) //tracing ex, if null trace all .build(); } }
結合註解使用:
@Retry(name = "xxx") //策略名稱
切面會根據配置的策略名稱從 RetryRegistry 查詢獲取相應的策略。