关于重试,这个模式应该是一个很普遍的设计模式了。当我们把单体应用服务化,尤其是微服务化,本来在一个进程内的函数调用就成了远程调用,这样就会涉及到网络上的问题。

网络上有很多的各式各样的组件,如DNS服务、网卡、交换机、路由器、负载均衡等设备,这些设备都不一定是稳定的。在数据传输的整个过程中,只要任何一个环节出了问题,最后都会影响系统的稳定性。

重试的场景

所以,我们需要一个重试的机制。但是,我们需要明白的是,“重试”的语义是我们认为这个故障是暂时的,而不是永久的,所以,我们会去重试

我认为,设计重试时,我们需要定义出什么情况下需要重试,例如,调用超时、被调用端返回了某种可以重试的错误(如繁忙中、流控中、维护中、资源不足等)。

而对于一些别的错误,则最好不要重试,比如:业务级的错误(如没有权限、或是非法数据等错误),技术上的错误(如:HTTP的503等,这种原因可能是触发了代码的bug,重试下去没有意义)。

重试的策略

关于重试的设计,一般来说,都需要有个重试的最大值,经过一段时间不断的重试后,就没有必要再重试了,应该报故障了。在重试过程中,每一次重试失败时都应该休息一会儿再重试,这样可以避免因为重试过快而导致网络上的负担加重。

在重试的设计中,我们一般都会引入,Exponential Backoff的策略,也就是所谓的“指数级退避”。在这种情况下,每一次重试所需要的休息时间都会成倍增加。这种机制主要是用来让被调用方能够有更多的时间来从容处理我们的请求。这其实和TCP的拥塞控制有点像。

如果我们写成代码应该是下面这个样子。

首先,我们定义一个调用返回的枚举类型,其中包括了5种返回错误——成功SUCCESS、维护中NOT_READY、流控中TOO_BUSY、没有资源NO_RESOURCE、系统错误SERVER_ERROR。

public enum Results {
    SUCCESS, 
    NOT_READY, 
    TOO_BUSY,
    NO_RESOURCE,
    SERVER_ERROR
}

接下来,我们定义一个Exponential Backoff的函数,其返回2的指数。这样,每多一次重试就需要多等一段时间。如:第一次等200ms,第二次要400ms,第三次要等800ms……

public static long getWaitTimeExp(int retryCount) {
    long waitTime = ((long) Math.pow(2, retryCount) );
    return waitTime;
}

下面是真正的重试逻辑。我们可以看到,在成功的情况下,以及不属于我们定义的错误下,我们是不需要重试的,而两次重试间需要等的时间是以指数上升的。

public static void doOperationAndWaitForResult() {
    
    // Do some asynchronous operation.
long token = asyncOperation();

    int retries = 0;
    boolean retry = false;

    do {
        // Get the result of the asynchronous operation.
        Results result = getAsyncOperationResult(token);

        if (Results.SUCCESS == result) {
            retry = false;
        } else if ( (Results.NOT_READY == result) ||
                      (Results.TOO_BUSY == result) ||
                      (Results.NO_RESOURCE == result) ||
                      (Results.SERVER_ERROR == result) ) {
            retry = true;
        } else {
            retry = false;
        }
        if (retry) {
            long waitTime = Math.min(getWaitTimeExp(retries), MAX_WAIT_INTERVAL);
            // Wait for the next Retry.
            Thread.sleep(waitTime);
        }
    } while (retry && (retries++ < MAX_RETRIES));
}

上面的代码是非常基本的重试代码,没有什么新鲜的,我们来看看Spring中所支持的一些重试策略。

Spring的重试策略

Spring Retry 是一个单独实现重试功能的项目,我们可以通过Annotation的方式使用。具体如下。

@Service
public interface MyService {
    @Retryable(
      value = { SQLException.class }, 
      maxAttempts = 2,
      backoff = @Backoff(delay = 5000))
    void retryService(String sql) throws SQLException;
    ...
}

配置 @Retryable 注解,只对 SQLException 的异常进行重试,重试两次,每次延时5000ms。相关的细节可以看相应的文档。我在这里,只想让你看一下Spring有哪些重试的策略。

关于Backoff的策略如下。

重试设计的重点

重试的设计重点主要如下:

小结

好了,我们来总结一下今天分享的主要内容。首先,我讲了重试的场景,比如流控,但并不是所有的失败场景都适合重试。接着我讲了重试的策略,包括简单的指数退避策略,和Spring实现的多种策略。

这些策略可以用Java的Annotation来实现,或者用Service Mesh的方式,从而不必写在业务逻辑里。最后,我总结了重试设计的重点。下篇文章中,我们讲述熔断设计。希望对你有帮助。

也欢迎你分享一下你实现过哪些场景下的重试?所采用的策略是什么?实现的过程中遇到过哪些坑?

文末给出了《分布式系统设计模式》系列文章的目录,希望你能在这个列表里找到自己感兴趣的内容。