最难不过二叉树

服务容错策略

2024-09-05

容错性设计是架构设计中的一个核心原则,在微服务框架下,调用链路复杂导致,一旦某一个服务崩溃了,导致所有使用到这个服务的其他服务都会无法正常工作,一个点的错误经过层层传递,最终会波及调用链上的与此相关的所有服务。因此,服务容错性设计在架构设计中就显得十分重要了。

容错策略

常见的容错策略有以下几种:

  • 故障转移(Failover):我们的集群的高可用方案一般是通过主备来实现,比如数据库,我们会配置一主一备。故障转移是指一旦某个服务器发生故障无法继续提供服务时,系统不会立即向调用者返回失败信息,而是系统自动切换到其他服务的副本,尝试调用副本服务来返回结果,从而保证服务的可用性。这里的故障转移说得很简单,其实实际操作起来一点都不轻松。比如,“系统的自动切换”就是个难点:如何正确判断主服务器挂掉了?主服务器挂掉后又恢复了,整个主备逻辑是怎么样的?我们以为主服务挂掉没有处理好请求,所以又转发请求给备服务器了去处理了,那么此时是不是会出现请求重复处理的情况?这些问题都是直击要害,主备模式来做故障自动转移使系统的复杂度大大提升了。
  • 快速失败(Failfast):有些业务时不允许做故障转移的,因为故障转移实施的前提是服务需要具有幂等性。对于非幂等性的服务,重试导致的重复调用带来的麻烦远大于单次调用失败,对于这类场景,快速失败才是最佳实践。比如支付场景中,需要调用银行扣款接口,如果该接口超时无返回,此时怎么做?因为程序不清楚此时扣款请求在银行侧是否成功执行,因此不能重试,而应抛出异常,让调用者自行处理。
  • 安全失败(Failsafe):调用链路中分主路和旁路,换句话说,并不是每个服务都是不可或缺的,有些服务调用失败不影响核心业务的正确性。我这边的业务有这个场景:用户登录时,会去请求黑名单服务,如果用户位于黑名单,那么不让用户登录。这个调用链中,用户登录是主路,请求黑名单服务是旁路,如果黑名单服务挂了,我们的用户登录是否也不能进行了?显然不是。用户登录请求黑名单服务失败了,不阻碍本次登录,登录继续进行,但记录下调用失败的日志,后续黑名单服务修复了,再过一次登录的用户ID,处于黑名单的用户再强制退出。

容错设计模式

熔断模式

熔断模式就是通过代理来一对一地接管服务调用者的远程请求。熔断器会进行监控并统计服务返回的成功、失败、超时等各种结果,当出现故障的次数达到预设的阈值时,它的状态就会变为“OPEN”,后续此熔断器代理的远程访问都将直接返回失败,而不会发出真正的远程服务请求。

通过熔断器对远程服务的熔断,避免因持续的失败而消耗资源,以及因持续的超时而堆积请求请求,最终达到避免雪崩效应的目的。熔断模式本质上看是一种快速失败策略的实现方式。

熔断器的状态分为三个:

  • CLOSED:熔断器关闭,此时远程服务请求正常发送给服务提供者,默认为该状态。
  • OPEN:熔断器打开,此时不会进行远程服务请求,而是直接向服务调用者返回调用失败信息。
  • HALF OPEN:熔断器处于半打开状态,即小部分请求可以请求到远程服务,大部分请求被拦截了直接返回失败,这个做法是为了检查远端的服务是否已经恢复了,如果远端服务已恢复正常,那么可以放更大的请求流量做远程调用,直到所有流量都能成功调用远端服务,那么熔断器转为关闭状态。这样熔断器就具备了故障自动恢复的能力。

重试模式

当一次请求失败后,最主流的做法是重试一次。但是重试有风险,在对一个业务请求进行重试前,需要考虑以下问题:

  • 仅在主路逻辑的关键服务上进行同步的重试。对于非关键的服务,一般不把重试座位首选容错方案,尤其是不能进行同步的重试。
  • 仅对瞬时故障导致的失败进行重试。尽管很难判断一个故障是否属于可自愈的瞬时故障,但我们可以从HTTP状态码上看出一些端倪。比如HTTP状态码为401 Unauthorized,表明服务本身是可用的,只是请求没有授权,因此重试没有任何意义,回复仍是401。因此,重试时要根据HTTP 响应码来做不同的重试策略。
  • 仅对具备幂等性的服务进行重试。
  • 重试必须有明确的中止条件,比如超过一定次数重试就中止;或者是通过查看服务器的返回来停止重试,比如服务器的返回的header里带着Retry-After,那么重试就得中止。

重试不当会引发重试风暴,有放大故障的风险。

image (3).png

假设现在场景是 Backend A 调用 Backend B,Backend B 调用 DB Frontend,均设置重试次数为 3 。如果 Backend B 调用 DB Frontend,请求 3 次都失败了,这时 Backend B 会给 Backend A 返回失败。但是 Backend A 也有重试的逻辑,Backend A 重试 Backend B 三次,每一次 Backend B 都会请求 DB Frontend 3 次,这样算起来,DB Frontend 就会被请求了 9 次,实际是指数级扩大。假设正常访问量是 n,链路一共有 m 层,每层重试次数为 r,则最后一层受到的访问量最大,为 n * r ^ (m - 1) 。这种指数放大的效应很可怕,可能导致链路上多层都被打挂,整个系统雪崩。

重试需要限定重试次数,这是最基本的策略,更好的策略是使用退避策略来进行科学重试。

常见的退避策略有:

  • 线性退避:每次等待固定时间后重试。
  • 随机退避:在一定范围内随机等待一个时间后重试。
  • 指数退避:连续重试时,每次等待时间都是前一次的倍数。

这里展开说一下指数退避,在TCP重传策略中也用到二进制指数退避策略,这个策略同样适用于我们业务层请求的重传。当系统每次调用失败的时候,我们都会产生一个新的集合,集合的内容是0~(2^n)-1,n代表调用失败的次数。具体实施如下:

  • 限定最大重试次数,比如这里限定为6次,集合为{0,1,2,4,8,16}。重试间隔为0.5s
  • 请求第一次失败时,等待0*0.5s=0s后进行重试,即马上进行重试
  • 第一次重试失败时,等待1*0.5s=0.5s后进行重试
  • 第二次重试失败时,等待2*0.5s=1s后进行重试
  • 第三次重试失败时,等待4*0.5s=2s后进行重试
  • 第四次重试失败,等待8*0.5s=4s后进行重试
  • 第五次重试失败时,等待16*0.5s=8s后进行重试
  • 第六次重试失败时,不再进行重试,放弃。