服務治理之重試篇

WindWant發表於2020-09-04

一、背景

什麼是重試?

一種保障機制,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 區分:

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 查詢獲取相應的策略。

 

相關文章