架构设计容错篇之重试

avatar

概述

在微服务架构中,服务之间的调用并不像方法之间的调用那么稳定,它可能会因网络、磁盘、内存、CPU等硬件原因导致调用失败,也有可能是服务自身问题诸如调用超时、服务繁忙、服务不可用导致调用失败。
调用失败的原因各种各样,有些已经超出了我们的控制范围如网络抖动,丢包,也有些是我们提高程序处理能力可以有效减少的如超时,还有一些是短暂的、临时的失败不久之后可能会马上恢复诸如:网络抖动、服务繁忙、CPU高负载等等。
尽管有些失败不可控,但为了提高系统的稳定性,有些短暂的失败还是可以通过重试使其恢复正常的。

问题

在有些重要的服务调用中,如:支付服务调用订单服务通知其订单支付成功,如果服务调用失败且没有任何的补救措施,直接造成的影响就是用户付款了但收不到商品。
而失败的原因很可能是由于订单服务正在进行GC垃圾回收导致的请求超时,亦或者是订单服务负载过高本请求被拒绝处理。
不管怎么说,订单服务很可能在几秒之内恢复到正常状态,能够继续处理外部服务的请求。
所以,当失败是暂时的不久之后可恢复的,那么我们在失败之后进行重试,显然是一个可行的方案。

方案

针对可重试的失败,通常我们有下面几种常用的重试策略。

立即重试

如果失败的原因是瞬时的、偶发的,那么我们可以在调用失败后立即重试进行重试,以最快的速度自动恢复,从而降低系统的错误率。
比如,因网络瞬时抖动、数据库更新被锁定产生的调用失败,只要我们立即重试大概率就会成功。
但是,重试的次数不宜过多,因为立即重试会占用当前线程,线程得不到即时释放会增加服务自身的负载。

延迟重试

相反,如果原因不是瞬时而需要一小段时间系统才能恢复正常,那么我们可以在调用失败后每间隔一段时间重试一次,从而提高重试的成功率。
比如,服务提供方繁忙请求被拒、服务发版导致的短暂不可用,这些基本上可以通过延迟重试来使调用恢复正常。
既然,无法确定错误恢复的时长,那应该如何设置重试间隔时间?这就涉及到退避策略的选择了,常见的退避策略有下面这几种:

线性退避:调用失败后等待固定的时间后进行重试,直到重试次数耗尽;

随机退避:重试间隔时间不是固定的,每次间隔可能都不一样;

指数退避:调用失败后等待 2^x * n(n为重试间隔,x为重试次数)后,进行重试操作,这种退避策略可以有效的减少无效的重试。

但是,延迟重试如果延迟总时长过长,一般都需要进行异步处理,这种策略比较适合不依赖返回值的情况,如上面的订单通知。

异常分类重试

这是一种组合策略。为了更恰当的处理失败的调用,我们可以对引发失败的原因即程序异常进行分类,明确那些异常是可以重试的,那些是没有必要重试的,以及那些是可以立即重试的,那些是需要延迟重试的。
区分好后,就可以根据异常的重试特征选择要不要重试,如果是可重试的那么再进一步根据是否能立即重试从上面两个策略中选择一个合适的策略。

影响

虽然重试可以减少服务的错误率,但如果使用不当也会产生负面的影响,下面是在微服务架构中,使用重试常见的问题。

嵌套的重试

假设我们有三个服务分别是A、B、C,它们之间的依赖关系是A依赖B,B依赖C,A,B服务在调用下游服务的时候都使用了重试机制,并且重试次数都设置了3次。
如果很不幸C处于繁忙状态,导致所有的重试最终都以失败告终,即A第一次重试时,B重试三次都是失败;A第二次重试时,B重试三次也失败;A最后一次重试时,B重试三次还是失败。
那么这样的重试的次数便是指数级别的,位于最后一层的服务C被调用的次数便是其它服务重试次数的乘积,即9=3x3。
上面调用链上的重试机制是嵌套的,这种嵌套重试在最后一层服务不可用或繁忙时,其影响是灾难性的,会瞬间引发雪崩效应。
我们可以假设每秒的访问量是y,链路层级是l,每个服务的重试次数是n,那么重试的次数就可以通过该公式算出:y*n^(l-1)。比如,某个系统的每秒访问量是100,服务之间的链路层级是5层,每个服务的重试次数是3次,那么最后一层不可用的服务每秒的访问量便是8100次。

大多数公司都会使用第三方的微服务框架,那么可以通过框架的协议自下而上的透彻一个字段如retry-not-allowed,来标示上层服务不能进行重试。
也就是说,下层服务重试失败后,通过协议将该字段传递给调用它的服务消费者,服务消费者收到该字段就取消自己的重试机制,如此这般直至最上层的服务。
这样就可以避免嵌套重试的潜在风险,但是还有一个问题要注意,那就是超时的问题。
如果上层服务的调用的超时时间低于下层服务的调用超时时间,那么会造成下层服务的重试还在执行中,而上层服务就因为调用超时开始了下一次的重试。
这个问题同样也可以通过协议来解决,最上层的服务将自己的超时时间传递给下层服务,下层服务的超时时间只能在上层服务的超时范围之内,当然要减掉一些消耗时间如网络传输时间。

但是,如果下游服务长时间不可用,这样不停的重试也是会增加系统的负载的,访问量大的时候还是有引发雪崩效应的风险——如果线程资源都被重试的请求占用了其它请求得不要线程资源。
这个问题可以通过熔断、隔离、限流来解决,这里不再详述。

确保幂等

在对某个操作进行重试前,我们应该确保该同一操作无论发起多少次请求对数据的影响都是一致的,不会因为多次请求而产生副作用。
一般数据查询类操作是天然幂等的,而数据增、删、改则是非幂等的,需要做好幂等处理。

总结

重试是一把双刃剑,它既能帮助我们降低调用的失败率来提高系统的稳定性,也能在极端情况下引发系统雪崩,造成极大的损失。
因此,有些公司禁止程序员使用重试机制。我们不评价其规定是否合理,但可以看出如果滥用重试确实会造成极其负面的影响。
所以,在微服务的架构中,虽然重试可以有些提高系统的稳定性,但也要谨慎的使用重试以免造成不必要的损失。

扩展阅读

架构设计思维篇之结构

架构设计思维篇之概念

架构设计容错篇之重试

架构设计容错篇之熔断

架构设计容错篇之限流

架构设计事务篇之Mysql事务原理

架构设计事务篇之CAP定理

架构设计事务篇之分布式事务

架构设计消息篇之消息丢失

架构设计消息篇之保证消息顺序性

推荐阅读更多精彩内容