容错性设计是"Microservices: a definition of this new architectural term"中微服务的九个核心的业务与技术特征之一。如果没有容错性的设计,系统很容易就会因为一两个服务的崩溃带来的雪崩效应而被淹没。
面对高可用"杀手":雪崩效应 ,可使用容错策略 和容错设计模式来避免服务集群中,因某个节点的故障导致整个系统发生雪崩效应。
Q:如何理解容错策略和容错设计模式?
容错策略,指的是 "面对故障,我们该做些什么";而容错设计模式,指的是 "要实现某种容错策略,我们该如何去做"。
容错策略
- 故障转移(Failover)
- 前提:下游服务具有幂等性。
- 快速失败(Fail fast)
- 适用场景:在一些业务不允许故障转移(或不支持幂等操作)时,可以考虑把快速失败作为首选的容错策略。
- 例如:调用银行的扣款接口(如果没做幂等),因很难判断到底是扣款指令发送给银行时出现的网络异常,还是银行扣款后给服务返回结果时出现的网络异常,所以为了避免重复扣款应当尽快让服务报错并抛出异常。
- 安全失败(Fail safe) :即使调用失败了,也当作正确来返回,如果需要返回值的话,系统就自动返回一个符合要求的数据类型的对应零值,然后自动记录一条服务调用出错的日志备查即可。
- 适用场景:旁路逻辑
- 例如:统计日志、调试信息
- 沉默失败(Fail silent) :当请求失败后,就默认服务提供者一定时间内无法再对外提供服务,不再向它分配请求流量,并将错误隔离开来,避免对系统其他部分产生影响。
- 适用场景:请求需要长时间处理的远程服务,很容易因为某个远程服务的请求堆积而消耗大量的线程、内存、网络等资源,进而影响到整个系统的稳定性。
- 故障恢复(Fail back) :当服务调用出错了以后,将该次调用失败的信息存入一个消息队列中,然后由系统自动开始异步重试调用。
- 前提:下游服务具有幂等性。
- 适用场景:作为其他容错策略的补充措施,一般用于对实时性要求不高的主路逻辑,也适合处理那些不需要返回值的旁路逻辑。
- 并行调用(Forking) :同时向多个服务副本发起调用,只要有其中任何一个返回成功,那调用便宣告成功。
- 适用场景:使用更高的执行成本换取时间和成功概率的情况。
- 广播调用(Broadcast) :同时发起多个调用,所有的请求全部都成功,才算是成功。
- 适用场景:批量操作。
- 例如:刷新分布式缓存。
Q:如果在一个业务逻辑中,需要调用远程服务的多个接口,如果接口中大多数返回成功就认为成功,该如何设计?
根据不同目标,选择不同的实现策略:
- 如果追求响应速度,可使用并行调用策略实现;
- 如果尽可能少地消耗资源,可使用快速失败,先对这些接口的出错概率进行排序,依次、优先调用错误率高的接口,如果失败次数过半,则不执行后续操作;
- 如果尽可能高概率完成,那就使用故障转移策略,先对出错概率排序,优先调用出错概率小的接口。
容错设计模式
断路器模式(熔断)
通过断路器对远程服务进行熔断,就可以避免因为持续的失败或拒绝而消耗资源,因为持续的超时而堆积请求,最终可以避免雪崩效应的出现。
策略:快速失败、安全失败(主动对旁路逻辑进行降级,可以算作安全失败策略的体现)。
基本原理
通过代理(断路器对象)来一对一(一个远程服务对应一个断路器对象)地接管服务调用者的远程请求,持续监控并统计服务返回的成功、失败、超时、拒绝等各种结果,当出现故障(失败、超时、拒绝)的次数达到断路器的阈值时,它的状态就自动变为 "OPEN" 。之后这个断路器代理的远程访问都将直接返回调用失败,而不会发出真正的远程服务请求。
断路器一般有 CLOSE、OPEN 和 HALF OPEN 三种状态,从状态变化的角度来看,断路器就是一种有限状态机,断路器模式就是根据自身的状态变化,自动调整代理请求策略的过程。
舱壁隔离模式(隔离)
舱壁隔离模式,是常用的实现服务隔离的设计模式。所谓"服务隔离",就是避免某一个远程服务的局部失败影响到全局,而设置的一种止损方案。
策略:静默失败。
适用场景
在调用外部服务的故障中,"超时" 引起的故障是很容易给调用者带来全局性的风险。这是因为目前主流的网络访问大多是基于 TPR 并发模型(Thread per Request) 来实现的,只要请求一直不结束(无论是以成功结束还是以失败结束),就要一直占用着某个线程不能释放。
当使用全局资源池时,在高流量的访问下,如果外部服务长时间不结束,将会导致线全局线程池被占满。这时,从外部来看,系统的所有服务已经全面瘫痪。
要解决这类问题,本质上就是要控制单个服务的最大连接数。一种可行的解决办法是为每个服务单独设立线程池,这些线程池默认不预置活动线程,只用来控制单个服务的最大连接数。
根据 Netflix 官方给出的数据,一旦启用 Hystrix 线程池来进行服务隔离,每次服务调用大概会增加 3~10 毫秒的延时。如果调用链中有 20 次远程服务调用的话,那每次请求就要多付出 60 毫秒至 200 毫秒的代价,来换取服务隔离的安全保障。
对于这种情况,可以使用更轻量的信号量机制(Semaphore) 来控制服务最大连接数。
重试模式
故障转移与故障恢复策略都需要对服务进行重复调用,其区别在于:
- 重复调用方式可能是同步的,也可能是后台异步进行;
- 调用的服务实例有可能同一个服务,也可能会调用服务的其他副本。
适用场景
重试模式适合解决系统中的瞬时故障,简单地说就是有可能自己恢复(Resilient,称为自愈,也叫做回弹性)的临时性失灵,比如:
- 网络抖动
- 服务的临时过载(比如,503 Bad Gateway 错误)
注意事项
使用重试模式面临的最大风险就是滥用!
在应用中,判断是否应该且是否能够对一个服务进行重试时,要看是否同时满足下面 4 个条件:
- 仅在主路逻辑的关键服务上进行同步的重试。
- 仅对由瞬时故障导致的失败进行重试:至少可以从 HTTP 的状态码上获得一些初步的结论。比如,当发出的请求收到了 401 Unauthorized 响应时就不要再进行重试了。
- 仅对具备幂等性的服务进行重试。
- 重试必须有明确的终止条件:超时终止、次数终止。
另外,重试模式可以在网络链路的各个环节中实现,要警惕重试风暴。
如:同时在 Zuul、Feign 和 Ribbon 上都打开了重试功能,且不考虑重试被超时终止的话,那总重试次数就相当于它们的重试次数的乘积。假设按它们都重试 4 次,且 Ribbon 可以转移 4 个服务副本来计算的话,理论上最多会产生高达 4×4×4×4=256 次调用请求。