互联网服务提供商面临快速增长挑战的同时还要管理不断增长的系统分布。尽管服务的可靠运行对像 Google、Amazon 和 Co. 等这样的大公司来说非常重要,但它们的系统还是会一次又一次地出现故障,导致大量中断和带来糟糕的客户体验。
举几个例子,比如深受影响的 Gmail(2012)、AWS DynamoDB(2015)以及最近的 Facebook(2021)。在这种情况下,人们经常会遇到所谓的级联故障,导致超出普通系统故障的不良并发症。但是,考虑到他们的预算和技术知识,即使是在线业务的大玩家,为什么也不能完全避免这种故障呢?你可以为自己的系统使用哪些切实可行的风险缓解方法?
这篇文章是想带你了解如何通过防止故障传播来提高大型分布式系统的弹性。
1. 级联故障
级联故障是由于正反馈循环而随时间增加的故障。典型行为最初由单个节点或子系统故障触发。然后它会将负载分散到其他系统的节点上,这反过来又进一步增加系统故障的可能性,从而导致恶性循环或滚雪球效应。
级联故障的重要性体现在三个方面:首先,它们可以在短时间内导致整个服务停机。其次,受影响的系统不会像处理常见的问题那样恢复正常,而是会逐渐恶化。最终要依赖人为干预才行。最后,在最坏的情况下,级联故障可能会在没有警告的情况下突然发生,因为负载分布和故障会迅速发生。
这篇文章主要关注点是分布式计算环境中的级联故障,但它们也可能发生在其他各种领域,例如,电力传输、金融、生物学以及生态系统。因此,它们是一种相当普遍的现象,与自然界中发现的模式有些相似。为了更好地了解计算机科学中的级联故障是什么样的,让我们看一个具体的案例。
2. 案例研究:2015 年的 AWS DynamoDB 中断
AWS DynamoDB 是一种高可扩展的非关系数据库服务,分布在多个数据中心,提供高度一致的读取操作和 ACID 事务。它被 Netflix、Airbnb 和 IMDb 等多个知名的互联网公司所使用。
我们要研究的级联故障示例的事件发生在 2015 年 9 月 20 日,当时 DynamoDB 在美国东部地区超过四个小时不可用。涉及两个子系统:存储服务器和元数据服务,两者都在多个数据中心有副本。存储服务器向元数据服务请求其数据分区分配的所谓成员资格。如图 1 所示。
图 1:存储服务器和元数据服务
对于成员资格(也用于数据分区的分配)的请求,存在相应的超时时间。如果超时了,则相应的存储服务器会重试并将其自身排除在服务之外。
该事件的一个不幸的先决条件是 DynamoDB 引入的一个新特性,称为全球二级索引(GSI)。这使客户可以更好地访问他们的数据,但缺点是会显著增加元数据表的大小。因此,导致处理时间变得很长。同时不幸的是元数据服务的容量和成员请求的超时时间没有做出相应的调整。
真正的问题是由于一个短暂的网络问题导致一些存储服务器(处理非常大的元数据表)成员资格请求超时,这些服务器变得不可用并且还不断的重试它们的请求。
这就导致元数据服务超负荷运转,进而减慢响应速度并导致更多服务器重新提交其成员资格请求,因为它们也达到了超时时间。结果,元数据服务的状态进一步恶化。尽管多次尝试增加资源,系统仍然陷入故障循环数小时。最终,问题只能通过中断对元数据服务的请求来解决,即服务基本上离线。
结果是美国东部地区发生了广泛的 DynamoDB 中断,这是一个典型的级联故障例子。但是,陷入这种错误循环的系统的底层概念和模式是什么?
3. 级联故障的原因
首先,要说的是级联故障的触发点看起来是多种多样的。例如,可能是新推出的特性、维护、流量流失、cron 作业、分布式拒绝服务(DDoS)、限流等。它们的共同点是它们在一组有限资源的上下文中工作,这意味着可能会出现服务器过载、资源耗尽和服务不可用等影响 。让我们详细看看这些:
服务器过载
最常见的原因是服务器过载。发生这种情况时,系统性能下降通常会影响系统的其他区域。如图 2 所示,在初始场景(左)中,来自两个反向代理的负载分布在集群 A 和 B 之间,因此集群 A 以每秒 1000 个请求的假设最大容量运行。
在第二种情况(右)中,集群 B 发生故障,整个负载都到达集群 A,这就会导致集群A过载。集群 A 现在必须每秒处理 1200 个请求并开始出现异常行为,导致性能远远低于所预期的每秒 1000 个请求。
图 2:集群 A 和 B 根据容量(左)接收负载,如果集群 B 发生故障,集群 A 接收过载(右)
资源耗尽
服务器的资源是有限的。如果负载增加到某个阈值以上,服务器的性能指标(例如,延迟或错误率)就会恶化,这意味着更高的崩溃风险。随后的影响取决于导致瓶颈的资源类型,例如:
如果 CPU 不足,可能会出现各种问题,包括请求速度较慢、排队效应过多或线程不足。
如果内存/RAM 被过度使用,任务可能会崩溃,或者缓存命中率会降低。
此外,线程饥饿可能直接导致错误或导致健康检查失败。
在这种情况下对主要原因进行故障排除通常很痛苦。这是因为所涉及的组件是相互依赖的,并且根本原因可能隐藏在复杂的事件链之后。例如,假设可用于缓存的内存较少,导致缓存命中次数减少,因此后端负载较高,以及此类组合。
服务不可用
当资源耗尽导致服务器崩溃时,流量会转到其他服务器,从而增加这些服务器崩溃的可能性。这样一个服务器的崩溃循环就建立了。更坏的情况是这些问题会一直保持在系统中,因为某些机器仍然处于关闭状态或正在重新启动的过程中,而持续增加的流量会阻止它们完全恢复。
一般来说,当我们将流量从不健康节点重新分配到健康节点时,总是存在级联故障的风险。这可能是编排系统、负载均衡器或任务调度系统的情况。为了解决级联故障,我们需要仔细研究所涉及的组件之间的关系。
4. 跳出循环——如何修复级联故障
从 DynamoDB 的案例中可以看出,修复级联故障非常棘手。尤其是从大型科技公司的角度来看,分布式系统增加了很多复杂性,这使得跟踪各种互连变得更加困难。
我们这里使用一种被称为因果循环图(CLD)的方法来描述这些(级联)关系。CLD 是一种建模方法,有助于可视化复杂系统中的反馈回路。图 3 可视化了 AWS DynamoDB 中断的 CLD。
解释如下:箭头表示初始变量和后续变量之间的动态。例如,如果元数据服务的延迟增加,超时次数就会增加,所需的重试次数也会增加。如果系统中的影响是高度不平衡的,即正负的数量在很大程度上不相等,则存在一个加强循环。这意味着系统可能对级联故障很敏感。
图 3:2015 年 AWS DynamoDB 中断的因果循环图
现在,针对级联故障场景,我们有好多种措施可以采用。第一个也是最直观的选择是增加资源。在上图中,可以看到在循环中元数据服务容量引入了减号。如果增加,它会减弱循环的增强,不过,这可能没有用,正如我们在 AWS 中看到的那样。除了增加资源外,还可以采用其他策略:
- 尽量避免健康检查失败,以防止系统因过度健康检查而死亡。
- 如果出现线程阻塞请求或死锁,请重新启动服务器。
- 显著降低流量,然后慢慢增加负载,以便服务器可以逐渐恢复。
- 通过丢弃某些类型的流量切换到降级模式。
- 消除批处理/不良流量,通过减少非关键或错误工作来减轻系统负载。
这个可能会让系统的某些服务不可用并且客户是能够感知到的,因此最好首先避免级联故障。
5. 避免级联故障
有许多方法可以使分布式系统对级联故障具有鲁棒性。
一方面,大型互联网公司已经在思考如何防止系统陷入级联错误,比如通过对错误进行隔离,为此市面上已经开发出来许多工具和框架。例如,Hystrix(来自 Netflix),一个延迟和容错库,或者 Sentinel。对于前者,Netflix 已经做出了进一步的发展,即自适应并发限制(可以在此处阅读更多内容[4])。但总的来说,这些工具都是将外部调用包装成某种数据结构,试图抽象出关键点。
另一方面,就是目前正在发展的技术,有一些复杂的解决方案,例如,实现所谓的sidecar代理,诸如 Istio 这样的服务网格。其他的一些示例比如 Envoy 或 Haproxy。
除了这些解决方案之外,我们还要牢记某些系统设计概念。例如,尝试减少系统中同步调用的数量。通过应用发布-订阅模式设计(比如使用 Kafka)从编排(orchestration)模式转变为协调(choreography)模式。面对不断增加的流量,这种解决方案通常会更健壮。其他方法例如,执行容量规划(取决于用例)也可能有所帮助。这通常意味着实施自动供应和部署、自动扩展和自动修复的解决方案。在这种情况下,对 SLA 和 SLO 的密切监控就显得很重要。
现在,为了更好地理解底层解决方案的方法,我们可以看看分布式系统中的典型反模式,在级联故障的情况下应该避免这些反模式。Laura Nolan 提出了其中的六项,我们会就风险缓解策略方面进行讨论。
反模式 1:接受数量不受限制的请求
队列/线程池中的任务数量应该是受限的。这可以在请求过多的情况下控制服务器何时以及如何慢下来(slow down)。该设置应该在服务器可以达到峰值负载的范围内,但不要太多从而导致它阻塞。在这种情况下,对于系统和用户来说,快速失败总比长时间挂起要好。在代理或负载均衡器方面,通常是通过速率限制策略来实现,例如,用来避免 DDoS 和其他形式的服务器过载。
但是还有其他许多方面要考虑的,例如,在队列管理的上下文中,因为大多数服务器在线程池前面都有一个队列来处理请求。如果数量增加超过队列的容量,请求将被拒绝。队列中等待的大量请求会占用更多内存并增加延迟。如果请求的数量接近恒定,那么一个小队列或不需要队列就可以了。这意味着如果流量增加,请求会被立即拒绝。如果预期会有更大的偏差,则应使用更长的队列。
此外,为了保护服务器免受过度负载的影响,减载和优雅降级的概念是可行的选择。负载脱落用于在过载的情况下尽可能地保持服务器的性能。这是通过简单地返回 HTTP 503(服务不可用)状态码来确定请求优先级的方法丢弃流量来实现的。
一个更复杂的变体是优雅降级,它会逐渐切换到较低质量的查询响应。这些可能会运行得更快或更有效。但是,这一定是一个经过深思熟虑的解决方案,因为它会给系统增加很多复杂性。
反模式 2:危险的(客户端)重试行为
为了减少系统的工作量,确保避免过度的重试行为是很重要的。指数退避是一种合适的方法,它的做法是重试的时间间隔连续增加。还可以使用所谓的抖动(jitter)机制,即在重试间隔中添加随机噪声。这可以防止系统被累积的“负载波”击中,这也称为重试放大(参见图 4)。
图 4:重试放大的典型模式
此外,还有一种称为熔断器的设计模式。熔断器可以被认为是一种开关。在初始状态下,来自上游服务的命令被允许传递给下游服务。如果错误增加,熔断器会切换到打开状态,系统会快速出现故障。这意味着上游服务出错,允许下游服务恢复。一段时间后,请求再次逐渐增加。例如,在 Hystrix(上面已经提到)中,实现了某种熔断器模式。
减轻危险重试行为的另一种方法是设置服务器端重试预算,设置每分钟可以重试请求的数量。超出预算的所有内容都将被丢弃。但是,我们要综合全局来看。一定要避免在软件架构的多个级别上执行重试,因为这可能会呈指数级增长。
最后,需要注意的是,重试应该是幂等的并且没有副作用。无状态调用 在系统复杂性方面也是有益的。
反模式 3:因输入错误而崩溃
系统应确保服务器不会因输入错误而崩溃。此类崩溃与重试行为相结合,可能导致灾难性后果,例如,一台服务器接着一台相继崩溃。在这方面,尤其应仔细检查来自外部的输入。使用模糊测试是检测这些类型问题的好方法。
反模式 4:基于邻近的故障转移
确保不要把所有流量都重定向到最近的数据中心,因为它也可能会过载。此处适用的逻辑与集群中单个服务器的故障相同,也就是一台机器接着一台发生故障。
因此,为了提高系统的弹性,必须在故障转移期间以受控方式重定向负载,这意味着必须考虑每个数据中心的最大容量。基于 IP-Anycast 的 DNS 方式最终会将流量转发到最近的数据中心,这可能会出现问题。
反模式 5:失败引起的工作
故障通常给系统带来额外的工作。特别是,故障发生在少数几个节点上,最终可能会给剩余其他节点带来大量的额外工作(例如,副本)。这可能会带来有害的反馈循环。一种常见的缓解策略是延迟或限制副本数量。
反模式 6:启动时间长
一般而言,在开始时处理过程通常较慢。这是因为实例需要做初始化过程和运行时优化。故障转移后,服务和系统经常由于负载过重而崩溃。为了防止这种情况,我们希望系统可以更快的启动。
此外,缓存在系统启动时通常是空的。这使得查询变得更加昂贵,因为它们必须去原始地方拿数据。因此,崩溃的风险高于系统在稳定模式下运行时的风险,因此请确保保持缓存可用。
除了这六个反模式之外,还有其他系统组件或参数需要检查。
例如,可以查看请求或 RPC 调用的截止日期(deadline)。一般来说,很难设定好的截止日期。但是在级联故障的情况下,经常遇到的一个常见问题是客户端超过了许多设定的截止日期,这意味着资源的大量浪费。
AWS DynamoDB 示例从一开始也是这种情况。通常情况下服务器应该检查请求离截止日期是否还有时间剩余,从而可以避免工作的浪费。一种常见的策略是所谓的期限传播。也就是请求树的顶部有一个绝对的截止日期。再往下的服务器只得到前一个服务器完成计算后剩下的时间值。例如,服务器 A 的期限为 20 秒,计算需要 5 秒,那么服务器 B 的期限为 15 秒,依此类推。
6. 结论
级联故障是分布式系统中一种即可怕又特殊的现象。这是因为有时必须采取违反直觉的路径来避免它们,例如实际上旨在减少错误的定制化工作,比如看似智能的负载平衡,可能会增加完全失败的风险。
有时,最好的策略就是向客户显示一条错误消息,而不是实施复杂的重试逻辑并冒着 DDoS 攻击系统的风险。但是,有时候又不得不做出妥协。测试、容量规划和在系统设计中应用某些模式有助于提高系统的弹性。
毕竟,大型科技公司的经验教训和事后分析为进一步采取行动以避免未来出现级联故障提供了很好的指导。但是,最新技术和趋势也值得关注。