learn-tech/专栏/左耳听风/047弹力设计篇之“重试设计”.md
2024-10-16 06:37:41 +08:00

142 lines
9.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
047 弹力设计篇之“重试设计”
关于重试,这个模式应该是一个很普遍的设计模式了。当我们把单体应用服务化,尤其是微服务化掉,本来在一个进程内的函数调用就成了远程调用,这样就会涉及到网络上的问题。
网络上有很多的各式各样的组件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 是专门的一个项目https://github.com/spring-projects/spring-retry 其中把 Spring 封装成了一个组件是以 AOP 的方式通过 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 个参数 openTimeoutresetTimeout delegate关于熔断会在后面描述
CompositeRetryPolicy组合重试策略有两种组合方式乐观组合重试策略是指只要有一个策略允许重试即可以悲观组合重试策略是指只要有一个策略不允许重试即不可以但不管哪种组合方式组合中的每一个策略都会执行
关于 Backoff 的策略如下
NoBackOffPolicy无退避算法策略即当重试时是立即重试
FixedBackOffPolicy固定时间的退避策略需设置参数 sleeper backOffPeriodsleeper 指定等待策略默认是 Thread.sleep即线程休眠backOffPeriod 指定休眠时间默认 1
UniformRandomBackOffPolicy随机时间退避策略需设置 sleeperminBackOffPeriod maxBackOffPeriod该策略在 [minBackOffPeriod, maxBackOffPeriod] 之间取一个随机休眠时间minBackOffPeriod 默认为 500 毫秒maxBackOffPeriod 默认为 1500 毫秒
ExponentialBackOffPolicy指数退避策略需设置参数 sleeperinitialIntervalmaxInterval multiplierinitialInterval 指定初始休眠时间默认为 100 毫秒maxInterval 指定最大休眠时间默认为 30 multiplier 指定乘数即下一次休眠时间为当前休眠时间 *multiplier
ExponentialRandomBackOffPolicy随机指数退避策略引入随机乘数之前说过固定乘数可能会引起很多服务同时重试导致 DDos使用随机休眠时间来避免这种情况
重试设计的重点
重试的设计重点主要如下
要确定什么样的错误下需要重试
重试的时间和重试的次数这种在不同的情况下要有不同的考量有时候而对一些不是很重要的问题时我们应该更快失败而不是重试一段时间若干次比如一个前端的交互需要用到后端的服务这种情况下在面对错误的时候应该快速度失败报错比如网络错误请重试)。而面对其它的一些错误比如流控那么应该使用指数退避的方式以避免造成更多的流量
如果超过重试次数或是一段时间那么重试就没有意义了这个时候说明这个错误不是一个短暂的错误那么我们对于新来的请求就没有必要再进行重试了这个时候对新的请求直接返回错误就好了但是这样一来如果后端恢复了我们怎么知道呢此时需要使用我们的熔断设计了这个在后面会说
重试还需要考虑被调用方是否有幂等的设计如果没有那么重试是不安全的可能会导致一个相同的操作被执行多次
重试的代码比较简单也比较通用完全可以不用侵入到业务代码中这里有两个模式一个是代码级的 Java 那样可以使用 Annotation 的方式 Spring 中你可以用到这样的注解如果没有注解也可以包装在底层库或是 SDK 库中不需要让上层业务感知到另外一种是走 Service Mesh 的方式关于 Service Mesh 的方式会在后面的文章中介绍)。
对于有事务相关的操作我们可能会希望能重试成功而不至于走业务补偿那样的复杂的回退流程对此我们可能需要一个比较长的时间来做重试但是我们需要保存住请求的上下文这可能对程序的运行有比较大的开销因此有一些设计会先把这样的上下文暂存在本机或是数据库中然后腾出资源来去做别的事过一会再回来把之前的请求从存储中捞出来重试
小结
好了我们来总结一下今天分享的主要内容首先我讲了重试的场景比如流控但并不是所有的失败场景都适合重试接着我讲了重试的策略包括简单的指数退避策略 Spring 实现的多种策略
这些策略可以用 Java Annotation 来实现或者用 Server Mesh 的方式从而不必写在业务逻辑里最后我总结了重试设计的重点下篇文章中我们讲述熔断设计希望对你有帮助
也欢迎你分享一下你实现过哪些场景下的重试所采用的策略是什么实现的过程中遇到过哪些坑