常用的重試技術—如何優雅的重試

  • 2019 年 10 月 3 日
  • 筆記

背景

分散式環境下,重試是高可用技術中的一個部分,大家在調用RPC介面或者發送MQ時,針對可能會出現網路抖動請求超時情況採取一下重試操作,自己簡單的編寫重試大多不夠優雅,而重試目前已有很多技術實現和框架支援,但也是有個有缺點,本文主要對其中進行整理,以求找到比較優雅的實現方案;

重試在功能設計上需要根據應用場景進行設計,讀數據的介面比較適合重試的場景,寫數據的介面就需要注意介面的冪等性了,還有就是重試次數如果太多的話會導致請求量加倍,給後端造成更大的壓力,設置合理的重試機制是關鍵;

重試技術實現

本文整理比較常見的重試技術實現:
1、Spring Retry重試框架;
2、Guava Retry重試框架;
3、Spring Cloud 重試配置;

具體使用面進行整理:

1、 Spring Retry重試框架

SpringRetry使用有兩種方式:

  • 註解方式
    最簡單的一種方式

    @Retryable(value = RuntimeException.class,maxAttempts = 3, backoff = @Backoff(delay = 5000L, multiplier = 2))

設置重試捕獲條件,重試策略,熔斷機制即可實現重試到熔斷整個機制,這種標準方式查閱網文即可;
這裡介紹一個自己處理熔斷的情況,及不用 @Recover 來做兜底處理,繼續往外拋出異常,程式碼大致如下:
Service中對方法進行重試:

@Override@Transactional      @Retryable(value = ZcSupplyAccessException.class,maxAttempts = 3,backoff = @Backoff(delay = 2000,multiplier = 1.5))      public OutputParamsDto doZcSupplyAccess(InputParamsDto inputDto) throws ZcSupplyAccessException {          //1. 校驗         ....          //2. 數據轉換        ....          //3、存儲          try {              doSaveDB(ioBusIcsRtnDatList);              log.info("3.XXX-數據接入存儲完成");          } catch (Exception e) {              log.info("3.XXX-數據接入存儲失敗{}", e);              throw new ZcSupplyAccessException("XXX數據接入存儲失敗");          }          return new OutputParamsDto(true, "XXX處理成功");      }

Controller中捕獲異常進行處理,注意這裡不用異常我們需要進行不同的處理,不能在@Recover 中進行處理,以免無法在外層拿到不同的異常;

@PostMapping("/accessInfo")      public OutputParamsDto accessInfo( @RequestBody InputParamsDto inputDto ){            log.info("接入報文為:"+JSONUtil.serialize(inputDto));          OutputParamsDto output = validIdentity(inputDto);          if(output==null || output.getSuccess()==false){              return output;          }          log.info("Pre.1.安全認證通過");          IAccessService accessService = null;          try {              ....              accessService = (IAccessService) ApplicationContextBeansHolder.getBean(param.getParmVal());              //先轉發(異常需處理)              output = accessService.doZcSupplyTranfer(inputDto);              //後存儲(異常不處理)              accessService.doZcSupplyAccess(inputDto);          } catch (ZcSupplyTransferException e){              log.error("轉發下游MQ重試3次均失敗,請確認是否MQ服務不可用");              return new OutputParamsDto(false,"轉發下游MQ重試3次均失敗,請確認是否MQ服務不可用");          } catch (ZcSupplyAccessException e){              log.error("接入存儲重試3次均失敗,請確認是否資料庫不可用");          } catch (Exception e) {              log.error("通過bean名調用方法和處理髮生異常:"+e);              return new OutputParamsDto(false,"通過bean名調用方法和處理髮生異常");          }          ...            return output;        }      

注意:
1、 @Recover中不能再拋出Exception,否則會報無法識別該異常的錯誤;
2、以註解的方式對方法進行重試,重試邏輯是同步執行的,重試的「失敗」針對的是Throwable,如果你要以返回值的某個狀態來判定是否需要重試,可能只能通過自己判斷返回值然後顯式拋出異常了。

  • 方法式
    註解式只是讓我們使用更加便捷,但是有一定限制,比如要求拋異常才能重試,不能基於實體,Recover方法如果定義多個比較難指定具體哪個,尤其是在結構化的程式設計中,父子類中的覆蓋等需要比較小心,SpringRetry提供編碼方式可以提高靈活性,返回你自定義的實體進行後續處理,也更加友好。

下面程式碼中RecoveryCallback部分進行了異常的拋出,這裡也可以返回實體對象,這樣就比註解式更友好了。

import com.alibaba.fastjson.JSONObject;  import com.alibaba.fastjson.serializer.SerializerFeature;  import lombok.extern.slf4j.Slf4j;  import org.springframework.beans.factory.annotation.Autowired;  import org.springframework.beans.factory.annotation.Value;  import org.springframework.cloud.context.config.annotation.RefreshScope;  import org.springframework.retry.RecoveryCallback;  import org.springframework.retry.RetryCallback;  import org.springframework.retry.RetryContext;  import org.springframework.retry.backoff.ExponentialBackOffPolicy;  import org.springframework.retry.backoff.FixedBackOffPolicy;  import org.springframework.retry.policy.CircuitBreakerRetryPolicy;  import org.springframework.retry.policy.SimpleRetryPolicy;  import org.springframework.retry.support.RetryTemplate;  import org.springframework.stereotype.Component;    import java.time.LocalTime;  import java.util.Collections;  import java.util.Map;    /**   * <p>   * 系統 <br>   * <br>   * Created by    on 2019/9/1016:12  <br>   * Revised by [修改人] on [修改日期] for [修改說明]<br>   * </p>   */  @Slf4j  @Component  @RefreshScope  public class ZcSupplySynRemoteRetryHandler {        @Autowired      RestTemplateFactory restTemplateFactory;        final RetryTemplate retryTemplate = new RetryTemplate();        //簡單重試策略      final SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(3, Collections.<Class<? extends Throwable>, Boolean>              singletonMap(ZcSupplySynRemoteException.class, true));        @Value("${retry.initialInterval}")      private String initialInterval;        @Value("${retry.multiplier}")      private String multiplier;        /**       * 重試處理       *       * @param reqMap       * @return       * @throws ZcSupplySynRemoteException       */      public  Map<String, Object> doSyncWithRetry(Map<String, Object> reqMap, String url) throws ZcSupplySynRemoteException {          //熔斷重試策略          CircuitBreakerRetryPolicy cbRetryPolicy = new CircuitBreakerRetryPolicy(new SimpleRetryPolicy(3));          cbRetryPolicy.setOpenTimeout(3000);          cbRetryPolicy.setResetTimeout(10000);            //固定值退避策略          FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy();          fixedBackOffPolicy.setBackOffPeriod(100);            //指數退避策略          ExponentialBackOffPolicy exponentialBackOffPolicy = new ExponentialBackOffPolicy();          exponentialBackOffPolicy.setInitialInterval(Long.parseLong(initialInterval));          exponentialBackOffPolicy.setMultiplier(Double.parseDouble(multiplier));            //設置策略          retryTemplate.setRetryPolicy(retryPolicy);          retryTemplate.setBackOffPolicy(exponentialBackOffPolicy);            //重試回調          RetryCallback<Map<String, Object>, ZcSupplySynRemoteException> retryCallback = new RetryCallback<Map<String, Object>, ZcSupplySynRemoteException>() {              /**               * Execute an operation with retry semantics. Operations should generally be               * idempotent, but implementations may choose to implement compensation               * semantics when an operation is retried.               *               * @param context the current retry context.               * @return the result of the successful operation.               * @throws ZcSupplySynRemoteException of type E if processing fails               */              @Override              public Map<String, Object> doWithRetry(RetryContext context) throws ZcSupplySynRemoteException {                  try {                      log.info(String.valueOf(LocalTime.now()));                      Map<String, Object> rtnMap = (Map<String, Object>) restTemplateFactory.callRestService(url,                              JSONObject.toJSONString(reqMap, SerializerFeature.WriteMapNullValue));                      context.setAttribute("rtnMap",rtnMap);                      return rtnMap;                  }catch (Exception e){                      throw new ZcSupplySynRemoteException("調用資采同步介面發生錯誤,準備重試");                  }              }          };            //兜底回調          RecoveryCallback<Map<String, Object>> recoveryCallback = new RecoveryCallback<Map<String, Object>>() {              /**               * @param context the current retry context               * @return an Object that can be used to replace the callback result that failed               * @throws ZcSupplySynRemoteException when something goes wrong               */              public Map<String, Object> recover(RetryContext context) throws ZcSupplySynRemoteException{                  Map<String, Object> rtnMap = (Map<String, Object>)context.getAttribute("rtnMap");                  log.info("xxx重試3次均錯誤,請確認是否對方服務可用,調用結果{}", JSONObject.toJSONString(rtnMap, SerializerFeature.WriteMapNullValue));                    //注意:這裡可以拋出異常,註解方式不可以,需要外層處理的需要使用這種方式                  throw new ZcSupplySynRemoteException("xxx重試3次均錯誤,請確認是否對方服務可用。");              }          };            return retryTemplate.execute(retryCallback, recoveryCallback);      }  }

核心類
RetryCallback: 封裝你需要重試的業務邏輯;

RecoverCallback:封裝在多次重試都失敗後你需要執行的業務邏輯;

RetryContext: 重試語境下的上下文,可用於在多次Retry或者Retry 和Recover之間傳遞參數或狀態;

RetryOperations : 定義了「重試」的基本框架(模板),要求傳入RetryCallback,可選傳入RecoveryCallback;

RetryListener:典型的「監聽者」,在重試的不同階段通知「監聽者」;

RetryPolicy : 重試的策略或條件,可以簡單的進行多次重試,可以是指定超時時間進行重試;

BackOffPolicy: 重試的回退策略,在業務邏輯執行發生異常時。如果需要重試,我們可能需要等一段時間(可能伺服器過於繁忙,如果一直不間隔重試可能拖垮伺服器),當然這段時間可以是 0,也可以是固定的,可以是隨機的(參見tcp的擁塞控制演算法中的回退策略)。回退策略在上文中體現為wait();

RetryTemplate: RetryOperations的具體實現,組合了RetryListener[],BackOffPolicy,RetryPolicy。

重試策略
NeverRetryPolicy:只允許調用RetryCallback一次,不允許重試

AlwaysRetryPolicy:允許無限重試,直到成功,此方式邏輯不當會導致死循環

SimpleRetryPolicy:固定次數重試策略,默認重試最大次數為3次,RetryTemplate默認使用的策略

TimeoutRetryPolicy:超時時間重試策略,默認超時時間為1秒,在指定的超時時間內允許重試

ExceptionClassifierRetryPolicy:設置不同異常的重試策略,類似組合重試策略,區別在於這裡只區分不同異常的重試

CircuitBreakerRetryPolicy:有熔斷功能的重試策略,需設置3個參數openTimeout、resetTimeout和delegate

CompositeRetryPolicy:組合重試策略,有兩種組合方式,樂觀組合重試策略是指只要有一個策略允許重試即可以,
悲觀組合重試策略是指只要有一個策略不允許重試即可以,但不管哪種組合方式,組合中的每一個策略都會執行

重試回退策略
重試回退策略,指的是每次重試是立即重試還是等待一段時間後重試。

默認情況下是立即重試,如果需要配置等待一段時間後重試則需要指定回退策略BackoffRetryPolicy。

NoBackOffPolicy:無退避演算法策略,每次重試時立即重試

FixedBackOffPolicy:固定時間的退避策略,需設置參數sleeper和backOffPeriod,sleeper指定等待策略,默認是Thread.sleep,即執行緒休眠,backOffPeriod指定休眠時間,默認1秒

UniformRandomBackOffPolicy:隨機時間退避策略,需設置sleeper、minBackOffPeriod和maxBackOffPeriod,該策略在[minBackOffPeriod,maxBackOffPeriod之間取一個隨機休眠時間,minBackOffPeriod默認500毫秒,maxBackOffPeriod默認1500毫秒

ExponentialBackOffPolicy:指數退避策略,需設置參數sleeper、initialInterval、maxInterval和multiplier,initialInterval指定初始休眠時間,默認100毫秒,maxInterval指定最大休眠時間,默認30秒,multiplier指定乘數,即下一次休眠時間為當前休眠時間*multiplier

ExponentialRandomBackOffPolicy:隨機指數退避策略,引入隨機乘數可以實現隨機乘數回退

2、Guava retry重試框架

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

3、Spring Cloud 重試配置

Spring Cloud Netflix 提供了各種HTTP請求的方式。
你可以使用負載均衡的RestTemplate, Ribbon, 或者 Feign。
無論你選擇如何創建HTTP 請求,都存在請求失敗的可能性。
當一個請求失敗時,你可能想它自動地去重試。
當使用Sping Cloud Netflix這麼做,你需要在應用的classpath引入Spring Retry。
當存在Spring Retry,負載均衡的RestTemplates, Feign, 和 Zuul,會自動地重試失敗的請求

RestTemplate+Ribbon全局設置:

spring:    cloud:     loadbalancer:        retry:          enabled: true  ribbon:      ReadTimeout: 6000      ConnectTimeout: 6000      MaxAutoRetries: 1      MaxAutoRetriesNextServer: 2         OkToRetryOnAllOperations: true

指定服務service1配置

service1:    ribbon:      MaxAutoRetries: 1      MaxAutoRetriesNextServer: 2      ConnectTimeout: 5000      ReadTimeout: 2000      OkToRetryOnAllOperations: true  
配置 說明
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds 斷路器的超時時間需要大於ribbon的超時時間,不然不會觸發重試。
hello-service.ribbon.ConnectTimeout 請求連接的超時時間
hello-service.ribbon.ReadTimeout 請求處理的超時時間
hello-service.ribbon.OkToRetryOnAllOperations 是否對所有操作請求都進行重試
hello-service.ribbon.MaxAutoRetriesNextServer 重試負載均衡其他的實例最大重試次數,不包括首次server
hello-service.ribbon.MaxAutoRetries 同一台實例最大重試次數,不包括首次調用

feign重試完整配置yml

eureka:    client:      serviceUrl:        defaultZone: http://localhost:8761/eureka/  server:    port: 7001  spring:    application:      name: feign-service    feign:    hystrix:      enabled: true      client1:    ribbon:      #配置首台伺服器重試1次      MaxAutoRetries: 1      #配置其他伺服器重試兩次      MaxAutoRetriesNextServer: 2      #鏈接超時時間      ConnectTimeout: 500      #請求處理時間      ReadTimeout: 2000      #每個操作都開啟重試機制      OkToRetryOnAllOperations: true    #配置斷路器超時時間,默認是1000(1秒)  hystrix:    command:      default:        execution:          isolation:            thread:              timeoutInMilliseconds: 2001

參考

1、https://www.jianshu.com/p/96a5003c470c
2、https://www.imooc.com/article/259204
3、https://blog.csdn.net/kisscatforever/article/details/80048395
4、https://houbb.github.io/2018/08/07/guava-retry

Tags: