跳转至

你好,我是陈皓,网名左耳朵耗子。

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

网络上有很多的各式各样的组件,如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有哪些重试的策略。

  • NeverRetryPolicy:只允许调用RetryCallback一次,不允许重试。
  • AlwaysRetryPolicy:允许无限重试,直到成功,此方式逻辑不当会导致死循环。
  • SimpleRetryPolicy:固定次数重试策略,默认重试最大次数为3次,RetryTemplate默认使用的策略。
  • TimeoutRetryPolicy:超时时间重试策略,默认超时时间为1秒,在指定的超时时间内允许重试。
  • CircuitBreakerRetryPolicy:有熔断功能的重试策略,需设置3个参数openTimeout、resetTimeout和delegate;关于熔断,会在后面描述。
  • CompositeRetryPolicy:组合重试策略。有两种组合方式,乐观组合重试策略是指只要有一个策略允许重试即可以,悲观组合重试策略是指只要有一个策略不允许重试即不可以。但不管哪种组合方式,组合中的每一个策略都会执行。

关于Backoff的策略如下。

  • 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:随机指数退避策略,引入随机乘数,之前说过固定乘数可能会引起很多服务同时重试导致DDos,使用随机休眠时间来避免这种情况。

重试设计的重点

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

  • 要确定什么样的错误下需要重试;
  • 重试的时间和重试的次数。这种在不同的情况下要有不同的考量。有时候,面对一些不是很重要的问题时,我们应该更快失败而不是重试一段时间若干次。比如一个前端的交互需要用到后端的服务。这种情况下,在面对错误的时候,应该快速失败报错(比如:网络错误请重试)。而面对其它的一些错误,比如流控,那么应该使用指数退避的方式,以避免造成更多的流量。
  • 如果超过重试次数,或是一段时间,那么重试就没有意义了。这个时候,说明这个错误不是一个短暂的错误,那么我们对于新来的请求,就没有必要再进行重试了,这个时候对新的请求直接返回错误就好了。但是,这样一来,如果后端恢复了,我们怎么知道呢,此时需要使用我们的熔断设计了。这个在后面会说。
  • 重试还需要考虑被调用方是否有幂等的设计。如果没有,那么重试是不安全的,可能会导致一个相同的操作被执行多次。
  • 重试的代码比较简单也比较通用,完全可以不用侵入到业务代码中。这里有两个模式。一个是代码级的,像Java那样可以使用Annotation的方式(在Spring中你可以用到这样的注解),如果没有注解也可以包装在底层库或是SDK库中不需要让上层业务感知到。另外一种是走Service Mesh的方式(关于Service Mesh的方式,我会在后面的文章中介绍)。
  • 对于有事务相关的操作。我们可能会希望能重试成功,而不至于走业务补偿那样的复杂的回退流程。对此,我们可能需要一个比较长的时间来做重试,但是我们需要保存请求的上下文,这可能对程序的运行有比较大的开销,因此,有一些设计会先把这样的上下文暂存在本机或是数据库中,然后腾出资源来做别的事,过一会再回来把之前的请求从存储中捞出来重试。

小结

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

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

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

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

精选留言(15)
  • 👍(39) 💬(0)

    重试的场景: 1、服务timeout超时异常 2、服务不存在,配置问题,服务流控 3、对error错误不重试,如无权限、参数错误 重试的策略: 1、数据库中保存重试需要的上下文,目前通过json来保存,指定最大重试次数、当前重试次数,下次运行时间 重试需要注意的地方: 1、服务幂等性,在重试时需证调用服务的幂等性 2、重试数据的监控,邮件,短信及时通知 3、重试数据的结转,防止表数据量过大

    2018-05-25

  • 顾海 👍(11) 💬(0)

    视情况不同,重试策略可能不同 1.被调用方是集群,例如微服务调用,当一次调用失败时,一般不会采用backoff策略,而是会换一台被调用机器立即自动发起一次重试。不采用backoff的原因是,RPC调用通常对响应时间比较敏感。 2.被调用方是单机(或者是集群,但是请求会打到master一台机器时)而且对超时时间不敏感的调用,通常会采用backoff策略。在这种情况下,由于被调用方只有一台机器,调用超时时马上重试多半还会超时,而且连续重试会进一步加大被调用机器的压力,进一步加大调用失败的可能。

    2020-04-25

  • shufang 👍(11) 💬(2)

    spring真的是只有想不到没有做不到~

    2018-03-13

  • NonStatic 👍(7) 💬(1)

    用过.net core上的Polly:http://www.thepollyproject.org/ 推荐给用C#的兄弟姐妹们。

    2018-03-18

  • 小沫 👍(6) 💬(3)

    你好,对于重试是否可以不让当前线程休眠呢。如果当前线程休眠 此时这个线程的利用率就不高,我觉得应该放到线程池里面是否好一些呢?

    2018-03-13

  • neohope 👍(4) 💬(0)

    有一个很小白的错误,我记得n年前一个同事写过一个很简单的服务,轮询需要处理的数据,每次取出m条,然后处理。测试时发现,有数据的时候,没任何问题,一旦数据处理完毕,系统CPU负载就飙升。最后看了一下,当没有重试数据的时候,就不断的轮询,不断的轮询,导致CPU飙升。后面对于批量处理数据的代码,都要重点看下有没有必要的延时。。。 另外,对于很特殊的数据,比如会引起服务挂掉的特殊数据(本文中的SERVER_ERROR),必须要特殊处理一下,不要继续重试,否则就滚雪球直接崩盘了。

    2018-06-21

  • 道道 👍(3) 💬(1)

    之前做的重试策略是:异常发生的时候,数据库记录当前上下文,依据重试次数来确定重试时间,推送给延迟消息队列控制重试

    2018-03-14

  • cash 👍(2) 💬(0)

    终于搞明白了现在架构中的重试机制设计了,原来是直接copy的spring的重试设计,醍醐灌顶。

    2020-03-29

  • 知行合一 👍(2) 💬(0)

    重试依赖被调用方做了良好的幂等设计和接口返回码规范,知道什么情况下应该重试,什么情况下直接报故障。重试也需要做避让设计,防止被调用方压力过大,压垮系统。spring重试项目可以做到注解方式定义重试,防止代码注入。server mess还需要多了解了解。

    2020-01-05

  • Sam_Deep_Thinking 👍(2) 💬(0)

    又一篇好文。感恩。。。

    2018-03-13

  • 文刂 氵共 超 👍(1) 💬(0)

    坚持学习,学习笔记 https://mubu.com/colla/3HCUwf3_nJM

    2019-12-24

  • edisonhuang 👍(1) 💬(0)

    重试策略的设计需要考虑重试的场景,重试的次数自己相应时间的限制。由于单机的服务变成了分布式的微服务,由于网络,流量等等未知原因,重试不可避免,应该考虑在代码设计中。对于事物性的操作,还需要考虑服务调用的幂等性,保证服务最终状态不出错

    2019-07-09

  • 👍(1) 💬(0)

    503 502 都需要重试吧 老师 500 400 403 401 可以不用重试吧

    2018-06-30

  • 👍(1) 💬(0)

    server error不是不应该重试,属于服务端内部错误,不是暂时性的

    2018-04-03

  • escray 👍(0) 💬(0)

    超时可以重试,业务和技术上的错误不需要重试。 指数级退避 Exponential Backoff 看了 Spring 的 重试 Retry 和 退避 Backoff 策略,以后如果有机会可以照猫画虎。 重试的设计重点: 1. 确定什么样的错误重试 2. 根据错误的类型确定重试的时间和次数、退避策略 3. 重试之后的错误处理或者是熔断设计 看上去 Annotation 的方式更好一些。

    2023-03-17