为什么微服务重试机制很重要?
当我们单体应用时,所有的逻辑计算都在单一的进程中,除了进程断电外几乎不可能有处理失败的情况。然而,当我们把单体应用拆分为一个个细分的子服务后,服务间的互相调用无论是RPC还是HTTP,都是依赖于网络。
网络是脆弱的,不时请求会出现抖动失败。例如我们的 Server1 调用 Server2 进行下单时,可能网络超时了,这个时候 Server1 就需要返回给用户提示「网络错误」,这样我们的服务质量就下降了,可能会收到用户的投诉吐槽,降低产品竞争力。
这也是为什么很多产品内部都建设接口维度的 SLA 指标,当成功率低于一定程度时需要和负责人绩效挂钩以此来推进产品的稳定性。
对于网络抖动这种情况,解决的最简单办法之一就是重试。
重试机制
重试机制:同步 、异步模式
常见的重试主要有两种模式:原地重试、异步重试。
原地重试很好理解,就是程序在调用下游服务失败的时候重新发起一次;异步重试是将请求信息丢到某个 mq 中,后续有一个程序消费到这个事件进行重试。
总的来说,原地重试实现简单,能解决大部分网络抖动问题,但是如果是服务追求强一致性,并且希望在下游故障的时候不影响正常服务计算,这个时候可以考虑用异步重试,上游服务可快速响应用户请求由异步消费者去完成重试。
重试算法
无论是异步还是同步模式,重试都有固定的几个算法:
- 线性退避:每次失败固定等待固定的时间
- 随机退避:每次失败等待随机的时间重试
- 指数退避:连续重试时,每次等待的时间都是前一次等待时间的倍数
- 综合退避:结合多种方式,比如线性 + 随机抖动、指数 + 随机抖动。加上随机抖动可以打散众多服务失败时对下游的重试请求,防止雪崩
为什么需要等待下再重试?
因为网络抖动或者下游负载高,马上重试成功的概率必然远远小于稍等一会再重试,相当于是让下游先喘一口气。
重试风暴
在微服务架构中,务必要注意避免重试风暴的产生。那么,什么是重试风暴呢?
如图所示,数据库出现了负载过高的情况,这个时候 Server 3 对它的请求会失败。但是因为配置了重试机制,Server 3 最多对数据库发起了3次请求。然而,这个时候荒唐的事情就出现了,为了避免抖动上游的每个服务都设置了超时重试3次的机制,这样明明是一次业务请求,在上述中由于有3个环节存在变成了对数据库的 27 (3 ^(n)) 次请求!这对原本就要崩溃的数据库,更是雪上加霜。
微服务架构通常一次请求会经过数个甚至数百个服务处理,如果每个都这样重试,数据库压力稍微彪高一点本身没啥问题,但是很可能就因为重试导致雪崩。
如何防止重试风暴
单实例限流
首先,我们接受请求的是单个实例(进程)中的线程,所以可以以单进程的粒度进行限流。
关于限流,我们常用的是令牌桶或者滑动窗口两种实现,这里简单实用滑动窗口实现。如下图所示,每秒会产生一个Bucket,我们在Bucket里记录这一秒内对下游某个接口的成功、失败数量。进而可以统计出每秒的失败率,结合失败率及失败请求数判断是否需要重试,每个 Bucket 在一定时间后过期。
如果下游大面积失败,这种时候是不适合重试的,我们可以配置一个比如失败率超过10%不重试的策略,这样在单机层面就可以避免很多不必要的重试。
- 规范重试状态码
链路层面防止重试的最好做法是只在最下游重试(我们上面图的 Server3),Google SRE中指出了Google内部使用特殊错误码的方式来实现:
- 约定一个特殊的业务状态码,它表示失败了,但是别重试
- 任何一个环节收到下游这个错误,不会重试,继续透传给上游
通过这个模式,如果是数据库抖动情况下,只有最下游的三个重试请求,上游服务判断状态码知道不可重试不再重试。除此之外,在一些业务异常情况下也可通过状态码区分出无需重试的状态。
这个方法可以有效避免重试风暴,但是缺陷是需要业务方强耦合上这个状态码的逻辑,一般需要公司层面做框架上的约束。
超时优化
在重试中,最头疼的莫过于超时这种场景。我们知道网络超时,有可能请求压根没到下游服务就产生了,也可能是已经到达下游并且被处理了,只是来不及返回,一个典型的两军问题。
关于超时的情况,显然无法通过错误码识别,例如 A -> B -> C -> D 情况,如果C故障了,B可以获取到错误码,并返回给 A,但是因为 A 请求 B 超时了,所以是获取不到错误码的,这个时候 A 又会发起重试。那么针对超时的情况有没什么办法做优化,避免无必要的重试呢?
我认为有几个地方是可以做的:
- 上游重试的请求不重试
超时导致的重试请求,在请求中带一个 Flag 标记。如果下游发现上游是因为超时而发起的请求,自己在请求下游时如果再超时出错,不再重试。例如 A -> B -> C 时,A 请求 B 超时重试,那么重试时会带上 Flag,B 发现 A 的重试请求中的 Flag,如果这个时候请求 C 失败,那么也不再重试请求,这样就避免了重试被放大。
- 合理设置各个环节超时时间
A -> B -> C,B -> C 加上超时最多是 1s 时间,那么 A -> B 的超时时间要 >= 1秒,否则可能 B 对 C 的重试还没结束, A 就发起重试请求了。这类问题,我们可以通过分析离线数据发现环节中存在的不合理配置。
通过上述的优化,我们可以在一定程度上规避超时引发的重试风暴。
降低时延的重试
我们上文主要都在阐述为了保障请求 SLA 的重试以及规避重试风暴的手段,但是其实在实际应用过程中有一些低时延的业务场景也经常使用重试来优化,这个优化措施就是 backupRequest。
比方说用户下单接口,我们希望更低的时延,因为延迟变高了用户可能下单量就减少了,直接影响到公司的盈利。假设我们的接口时延 p95 是 300ms,也就是95%的用户能在 300ms 内完成下单,虽然看起来很美好,但是可能存在 “长尾效应”,这尾部的 5% 对于业务来说也是至关重要的。
对于这种情况,常见的优化方案就是 backupRequest,简单来说策略就是这样的:
如果正常请求的超时时间是1s,那么当超时时间超过x ms(eg. 500ms)不等超时时间直接再发起一个相同的请求,如果旧的请求超时,新的请求正常落在300ms以内,那么我们这次请求不会超时且会在超时时间内完成。
这个机制对于时延敏感的业务非常有效,但是必须要保证请求是可重试的。
总结
这篇文章到这里就接近尾声了,如果你坚持读到这里,恭喜你已经掌握了微服务的重试机制,相信在工作中遇到的问题也都能游刃有余。下面我简单做下总结:
- 微服务重试很重要,因为可以避免一些网络波动导致的请求失败,提升服务稳定性
- 重试机制分为同步、异步两种模式,各有各的特性,需要结合业务选择
- 常见的重试算法有线性退避、指数退避、随机退避,以及结合其中两种的综合退避
- 重试风暴,在微服务中是一大隐患,我们可以通过单机重试限流以及约定重试状态码来规避
- 超时场景下的重试优化,上游因超时发起的流量,下游收到不再重复重试;合理配置链路超时时间
- 针对时延敏感业务,可使用 backup request 减轻长尾效应