重試機制在分散式系統中,或者呼叫外部介面中,都是十分重要的。
重試機制可以保護系統減少因網路波動、依賴服務短暫性不可用帶來的影響,讓系統能更穩定的執行的一種保護機制。
為了方便說明,先假設我們想要進行重試的方法如下:
@Slf4j
@Component
public class HelloService {
private static AtomicLong helloTimes = new AtomicLong();
public String hello(){
long times = helloTimes.incrementAndGet();
if (times % 4 != 0){
log.warn("發生異常,time:{}", LocalTime.now() );
throw new HelloRetryException("發生Hello異常");
}
return "hello";
}
}
呼叫處:
@Slf4j
@Service
public class HelloRetryService implements IHelloService{
@Autowired
private HelloService helloService;
public String hello(){
return helloService.hello();
}
}
也就是說,這個介面每調4次才會成功一次。
手動重試
先來用最簡單的方法,直接在呼叫的時候進重試:
// 手動重試
public String hello(){
int maxRetryTimes = 4;
String s = "";
for (int retry = 1; retry <= maxRetryTimes; retry++) {
try {
s = helloService.hello();
log.info("helloService返回:{}", s);
return s;
} catch (HelloRetryException e) {
log.info("helloService.hello() 呼叫失敗,準備重試");
}
}
throw new HelloRetryException("重試次數耗盡");
}
發生異常,time:10:17:21.079413300
helloService.hello() 呼叫失敗,準備重試
發生異常,time:10:17:21.085861800
helloService.hello() 呼叫失敗,準備重試
發生異常,time:10:17:21.085861800
helloService.hello() 呼叫失敗,準備重試
helloService返回:hello
service.helloRetry():hello
程式在極短的時間內進行了4次重試,然後成功返回。
這樣雖然看起來可以解決問題,但實踐上,由於沒有重試間隔,很可能當時依賴的服務尚未從網路異常中恢復過來,所以極有可能接下來的幾次呼叫都是失敗的。
而且,這樣需要對程式碼進行大量的侵入式修改,顯然,不優雅。
代理模式
上面的處理方式由於需要對業務程式碼進行大量修改,雖然實現了功能,但是對原有程式碼的侵入性太強,可維護性差。
所以需要使用一種更優雅一點的方式,不直接修改業務程式碼,那要怎麼做呢?
其實很簡單,直接在業務程式碼的外面再包一層就行了,代理模式在這裡就有用武之地了。你會發現又是代理。
@Slf4j
public class HelloRetryProxyService implements IHelloService{
@Autowired
private HelloRetryService helloRetryService;
@Override
public String hello() {
int maxRetryTimes = 4;
String s = "";
for (int retry = 1; retry <= maxRetryTimes; retry++) {
try {
s = helloRetryService.hello();
log.info("helloRetryService 返回:{}", s);
return s;
} catch (HelloRetryException e) {
log.info("helloRetryService.hello() 呼叫失敗,準備重試");
}
}
throw new HelloRetryException("重試次數耗盡");
}
}
這樣,重試邏輯就都由代理類來完成,原業務類的邏輯就不需要修改了,以後想修改重試邏輯也只需要修改這個類就行了,分工明確。比如,現在想要在重試之間加上一個延遲,只需要做一點點修改即可:
@Override
public String hello() {
int maxRetryTimes = 4;
String s = "";
for (int retry = 1; retry <= maxRetryTimes; retry++) {
try {
s = helloRetryService.hello();
log.info("helloRetryService 返回:{}", s);
return s;
} catch (HelloRetryException e) {
log.info("helloRetryService.hello() 呼叫失敗,準備重試");
}
// 延時一秒
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
throw new HelloRetryException("重試次數耗盡");
}
代理模式雖然要更加優雅,但是如果依賴的服務很多的時候,要為每個服務都建立一個代理類,顯然過於麻煩,而且其實重試的邏輯都大同小異,無非就是重試的次數和延時不一樣而已。
如果每個類都寫這麼一長串類似的程式碼,顯然,不優雅!
JDK動態代理
這時候,動態代理就閃亮登場了。只需要寫一個代理處理類就ok了。
@Slf4j
public class RetryInvocationHandler implements InvocationHandler {
private final Object subject;
public RetryInvocationHandler(Object subject) {
this.subject = subject;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
int times = 0;
while (times < RetryConstant.MAX_TIMES) {
try {
return method.invoke(subject, args);
} catch (Exception e) {
times++;
log.info("times:{},time:{}", times, LocalTime.now());
if (times >= RetryConstant.MAX_TIMES) {
throw new RuntimeException(e);
}
}
// 延時一秒
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return null;
}
/**
* 獲取動態代理
*
* @param realSubject 代理物件
*/
public static Object getProxy(Object realSubject) {
InvocationHandler handler = new RetryInvocationHandler(realSubject);
return Proxy.newProxyInstance(handler.getClass().getClassLoader(),
realSubject.getClass().getInterfaces(), handler);
}
}
我們們測試一下:
@Test
public void helloDynamicProxy() {
IHelloService realService = new HelloService();
IHelloService proxyService = (IHelloService)RetryInvocationHandler.getProxy(realService);
String hello = proxyService.hello();
log.info("hello:{}", hello);
}
輸出結果如下:
hello times:1
發生異常,time:11:22:20.727586700
times:1,time:11:22:20.728083
hello times:2
發生異常,time:11:22:21.728858700
times:2,time:11:22:21.729343700
hello times:3
發生異常,time:11:22:22.729706600
times:3,time:11:22:22.729706600
hello times:4
hello:hello
在重試了4次之後輸出了Hello,符合預期。
動態代理可以將重試邏輯都放到一塊,顯然比直接使用代理類要方便很多,也更加優雅。
不過不要高興的太早,這裡因為被代理的HelloService是一個簡單的類,沒有依賴其它類,所以直接建立是沒有問題的,但如果被代理的類依賴了其它被Spring容器管理的類,則這種方式會丟擲異常,因為沒有把被依賴的例項注入到建立的代理例項中。
這種情況下,就比較複雜了,需要從Spring容器中獲取已經裝配好的,需要被代理的例項,然後為其建立代理類例項,並交給Spring容器來管理,這樣就不用每次都重新建立新的代理類例項了。
話不多說,擼起袖子就是幹。
新建一個工具類,用來獲取代理例項:
@Component
public class RetryProxyHandler {
@Autowired
private ConfigurableApplicationContext context;
public Object getProxy(Class clazz) {
// 1. 從Bean中獲取物件
DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory)context.getAutowireCapableBeanFactory();
Map<String, Object> beans = beanFactory.getBeansOfType(clazz);
Set<Map.Entry<String, Object>> entries = beans.entrySet();
if (entries.size() <= 0){
throw new ProxyBeanNotFoundException();
}
// 如果有多個候選bean, 判斷其中是否有代理bean
Object bean = null;
if (entries.size() > 1){
for (Map.Entry<String, Object> entry : entries) {
if (entry.getKey().contains(PROXY_BEAN_SUFFIX)){
bean = entry.getValue();
}
};
if (bean != null){
return bean;
}
throw new ProxyBeanNotSingleException();
}
Object source = beans.entrySet().iterator().next().getValue();
Object source = beans.entrySet().iterator().next().getValue();
// 2. 判斷該物件的代理物件是否存在
String proxyBeanName = clazz.getSimpleName() + PROXY_BEAN_SUFFIX;
Boolean exist = beanFactory.containsBean(proxyBeanName);
if (exist) {
bean = beanFactory.getBean(proxyBeanName);
return bean;
}
// 3. 不存在則生成代理物件
bean = RetryInvocationHandler.getProxy(source);
// 4. 將bean注入spring容器
beanFactory.registerSingleton(proxyBeanName, bean);
return bean;
}
}
使用的是JDK動態代理:
@Slf4j
public class RetryInvocationHandler implements InvocationHandler {
private final Object subject;
public RetryInvocationHandler(Object subject) {
this.subject = subject;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
int times = 0;
while (times < RetryConstant.MAX_TIMES) {
try {
return method.invoke(subject, args);
} catch (Exception e) {
times++;
log.info("retry times:{},time:{}", times, LocalTime.now());
if (times >= RetryConstant.MAX_TIMES) {
throw new RuntimeException(e);
}
}
// 延時一秒
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return null;
}
/**
* 獲取動態代理
*
* @param realSubject 代理物件
*/
public static Object getProxy(Object realSubject) {
InvocationHandler handler = new RetryInvocationHandler(realSubject);
return Proxy.newProxyInstance(handler.getClass().getClassLoader(),
realSubject.getClass().getInterfaces(), handler);
}
}
至此,主要程式碼就完成了,修改一下HelloService類,增加一個依賴:
@Slf4j
@Component
public class HelloService implements IHelloService{
private static AtomicLong helloTimes = new AtomicLong();
@Autowired
private NameService nameService;
public String hello(){
long times = helloTimes.incrementAndGet();
log.info("hello times:{}", times);
if (times % 4 != 0){
log.warn("發生異常,time:{}", LocalTime.now() );
throw new HelloRetryException("發生Hello異常");
}
return "hello " + nameService.getName();
}
}
NameService其實很簡單,建立的目的僅在於測試依賴注入的Bean能否正常執行。
@Service
public class NameService {
public String getName(){
return "Frank";
}
}
測試一下:
@Test
public void helloJdkProxy() throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
IHelloService proxy = (IHelloService) retryProxyHandler.getProxy(HelloService.class);
String hello = proxy.hello();
log.info("hello:{}", hello);
}
結果:
hello times:1
發生異常,time:14:40:27.540672200
retry times:1,time:14:40:27.541167400
hello times:2
發生異常,time:14:40:28.541584600
retry times:2,time:14:40:28.542033500
hello times:3
發生異常,time:14:40:29.542161500
retry times:3,time:14:40:29.542161500
hello times:4
hello:hello Frank
完美,這樣就不用擔心依賴注入的問題了,因為從Spring容器中拿到的Bean物件都是已經注入配置好的。當然,這裡僅考慮了單例Bean的情況,可以考慮的更加完善一點,判斷一下容器中Bean的型別是Singleton還是Prototype,如果是Singleton則像上面這樣進行操作,如果是Prototype則每次都新建代理類物件。
另外,這裡使用的是JDK動態代理,因此就存在一個天然的缺陷,如果想要被代理的類,沒有實現任何介面,那麼就無法為其建立代理物件,這種方式就行不通了。
Spring AOP
想要無侵入式的修改原有邏輯?想要一個註解就實現重試?用Spring AOP不就能完美實現嗎?使用AOP來為目標呼叫設定切面,即可在目標方法呼叫前後新增一些額外的邏輯。
先建立一個註解:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Retryable {
int retryTimes() default 3;
int retryInterval() default 1;
}
有兩個引數,retryTimes 代表最大重試次數,retryInterval代表重試間隔。
@Retryable(retryTimes = 4, retryInterval = 2)
public String hello(){
long times = helloTimes.incrementAndGet();
log.info("hello times:{}", times);
if (times % 4 != 0){
log.warn("發生異常,time:{}", LocalTime.now() );
throw new HelloRetryException("發生Hello異常");
}
return "hello " + nameService.getName();
}
接著,進行最後一步,編寫AOP切面:
@Slf4j
@Aspect
@Component
public class RetryAspect {
@Pointcut("@annotation(com.mfrank.springboot.retry.demo.annotation.Retryable)")
private void retryMethodCall(){}
@Around("retryMethodCall()")
public Object retry(ProceedingJoinPoint joinPoint) throws InterruptedException {
// 獲取重試次數和重試間隔
Retryable retry = ((MethodSignature)joinPoint.getSignature()).getMethod().getAnnotation(Retryable.class);
int maxRetryTimes = retry.retryTimes();
int retryInterval = retry.retryInterval();
Throwable error = new RuntimeException();
for (int retryTimes = 1; retryTimes <= maxRetryTimes; retryTimes++){
try {
Object result = joinPoint.proceed();
return result;
} catch (Throwable throwable) {
error = throwable;
log.warn("呼叫發生異常,開始重試,retryTimes:{}", retryTimes);
}
Thread.sleep(retryInterval * 1000);
}
throw new RetryExhaustedException("重試次數耗盡", error);
}
}
開始測試:
@Autowired
private HelloService helloService;
@Test
public void helloAOP(){
String hello = helloService.hello();
log.info("hello:{}", hello);
}
列印結果:
hello times:1
發生異常,time:16:49:30.224649800
呼叫發生異常,開始重試,retryTimes:1
hello times:2
發生異常,time:16:49:32.225230800
呼叫發生異常,開始重試,retryTimes:2
hello times:3
發生異常,time:16:49:34.225968900
呼叫發生異常,開始重試,retryTimes:3
hello times:4
hello:hello Frank
這樣就相當優雅了,一個註解就能搞定重試,簡直不要更棒。
Spring 的重試註解
實際上Spring中就有比較完善的重試機制,比上面的切面更加好用,還不需要自己動手重新造輪子。
那讓我們先來看看這個輪子究竟好不好使。
先引入重試所需的jar包:
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
然後在啟動類或者配置類上新增@EnableRetry註解,接下來在需要重試的方法上新增@Retryable註解(嗯?好像跟我自定義的註解一樣?竟然抄襲我的註解【手動狗頭】)
@Retryable
public String hello(){
long times = helloTimes.incrementAndGet();
log.info("hello times:{}", times);
if (times % 4 != 0){
log.warn("發生異常,time:{}", LocalTime.now() );
throw new HelloRetryException("發生Hello異常");
}
return "hello " + nameService.getName();
}
預設情況下,會重試三次,重試間隔為1秒。當然我們也可以自定義重試次數和間隔。這樣就跟我前面實現的功能是一毛一樣的了。
但Spring裡的重試機制還支援很多很有用的特性,比如說,可以指定只對特定型別的異常進行重試,這樣如果丟擲的是其它型別的異常則不會進行重試,就可以對重試進行更細粒度的控制。預設為空,會對所有異常都重試。
@Retryable{value = {HelloRetryException.class}}
public String hello(){
...
}
也可以使用include和exclude來指定包含或者排除哪些異常進行重試。
可以用maxAttemps指定最大重試次數,預設為3次。
可以用interceptor設定重試攔截器的bean名稱。
可以透過label設定該重試的唯一標誌,用於統計輸出。
可以使用exceptionExpression來新增異常表示式,在丟擲異常後執行,以判斷後續是否進行重試。
此外,Spring中的重試機制還支援使用backoff來設定重試補償機制,可以設定重試間隔,並且支援設定重試延遲倍數。
舉個例子:
@Retryable(value = {HelloRetryException.class}, maxAttempts = 5,
backoff = @Backoff(delay = 1000, multiplier = 2))
public String hello(){
...
}
該方法呼叫將會在丟擲HelloRetryException異常後進行重試,最大重試次數為5,第一次重試間隔為1s,之後以2倍大小進行遞增,第二次重試間隔為2s,第三次為4s,第四次為8s。
重試機制還支援使用@Recover 註解來進行善後工作,當重試達到指定次數之後,將會呼叫該方法,可以在該方法中進行日誌記錄等操作。
這裡值得注意的是,想要@Recover 註解生效的話,需要跟被@Retryable 標記的方法在同一個類中,且被@Retryable 標記的方法不能有返回值,否則不會生效。
並且如果使用了@Recover註解的話,重試次數達到最大次數後,如果在@Recover標記的方法中無異常丟擲,是不會丟擲原異常的。
@Recover
public boolean recover(Exception e) {
log.error("達到最大重試次數",e);
return false;
}
除了使用註解外,Spring Retry 也支援直接在呼叫時使用程式碼進行重試:
@Test
public void normalSpringRetry() {
// 表示哪些異常需要重試,key表示異常的位元組碼,value為true表示需要重試
Map<Class<? extends Throwable>, Boolean> exceptionMap = new HashMap<>();
exceptionMap.put(HelloRetryException.class, true);
// 構建重試模板例項
RetryTemplate retryTemplate = new RetryTemplate();
// 設定重試回退操作策略,主要設定重試間隔時間
FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy();
long fixedPeriodTime = 1000L;
backOffPolicy.setBackOffPeriod(fixedPeriodTime);
// 設定重試策略,主要設定重試次數
int maxRetryTimes = 3;
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(maxRetryTimes, exceptionMap);
retryTemplate.setRetryPolicy(retryPolicy);
retryTemplate.setBackOffPolicy(backOffPolicy);
Boolean execute = retryTemplate.execute(
//RetryCallback
retryContext -> {
String hello = helloService.hello();
log.info("呼叫的結果:{}", hello);
return true;
},
// RecoverCallBack
retryContext -> {
//RecoveryCallback
log.info("已達到最大重試次數");
return false;
}
);
}
此時唯一的好處是可以設定多種重試策略:
NeverRetryPolicy:只允許呼叫RetryCallback一次,不允許重試
AlwaysRetryPolicy:允許無限重試,直到成功,此方式邏輯不當會導致死迴圈
SimpleRetryPolicy:固定次數重試策略,預設重試最大次數為3次,RetryTemplate預設使用的策略
TimeoutRetryPolicy:超時時間重試策略,預設超時時間為1秒,在指定的超時時間內允許重試
ExceptionClassifierRetryPolicy:設定不同異常的重試策略,類似組合重試策略,區別在於這裡只區分不同異常的重試
CircuitBreakerRetryPolicy:有熔斷功能的重試策略,需設定3個引數openTimeout、resetTimeout和delegate
CompositeRetryPolicy:組合重試策略,有兩種組合方式,樂觀組合重試策略是指只要有一個策略允許即可以重試,
悲觀組合重試策略是指只要有一個策略不允許即可以重試,但不管哪種組合方式,組閤中的每一個策略都會執行
可以看出,Spring中的重試機制還是相當完善的,比上面自己寫的AOP切面功能更加強大。
這裡還需要再提醒的一點是,由於Spring Retry用到了Aspect增強,所以就會有使用Aspect不可避免的坑——方法內部呼叫,如果被 @Retryable 註解的方法的呼叫方和被呼叫方處於同一個類中,那麼重試將會失效。
但也還是存在一定的不足,Spring的重試機制只支援對異常進行捕獲,而無法對返回值進行校驗。