Fail at Scale 是 Facebook 2015 年在 acm queue 上发表的一篇文章。主要写了常见的线上故障和应对方法,内容还是比较实在的。
"What Would You Do If You Weren't Afraid?" 和 "Fortune Favors the Bold." 是 FB 公司信条,挂墙上那种。
为了能在快速变更的系统中使 FB 的系统稳定,工程师们对系统故障进行了一些总结和抽象,为了能够建立可靠的系统必须要理解故障。而为了理解故障,工程师们建立了一些工具来诊断问题,并建立了对事故进行复盘,以避免未来再次发生的文化。
事故的发生可以被分为三大类。
为什么会发生故障
单机故障
通常情况下,单机遇到的孤立故障不会影响到基础设施的其他部分。这里的单机故障指的是:一台机器的硬盘出现了故障,或者某台机器上的服务遇到了代码中的错误,如内存损坏或死锁。
避免单个机器故障的关键是自动化。通过总结已知的故障模式,并与探究未知的故障症状相结合。在发现未知故障的症状(如响应缓慢)时,将机器摘除,线下分析,并将其总结至已知故障中。
工作负载变化
现实世界的重大事件会对 Facebook 网站基础设施带来压力,比如:
- 奥巴马当选总统,他的 Facebook 页面经历了创纪录的活动水平。
- 重大体育赛事的高潮部分,如超级碗或世界杯,会导致极高的帖子数量。
- 负载测试,以及新功能发布时的引流阶段会不向用户展示但引入流量。
这些事件中收集到的统计数据会为系统设计提供独特的视角。重大事件会导致用户行为变化,这些变化能为系统后续的决策提供数据依据。
人为错误
图 1a 显示了周六和周日发生的事故是如何大幅减少的,尽管网站的流量在整个一周内保持一致。图 1b 显示了在六个月的时间里,只有两个星期没有发生事故:圣诞节的那一周和员工要为对方写同行评论的那一周。
上图说明大部分故障都是人为原因,因为事件的统计与人的活动规律是一致的。
故障的三个原因
故障的原因很多,不过有三种最为常见。这里列出的每一种都给出了预防措施。
快速部署的配置变更
配置系统往往被设计为在全球范围内快速复制变化。快速配置变更是强大的工具,然而,快速配置变更可能导致部署问题配置时发生事故。下面是一些防止配置变更故障的手段。
所有人公用同一套配置系统。 使用一个共同的配置系统可以确保程序和工具适用于所有类型的配置。
配置变化静态检查。 许多配置系统允许松散类型的配置,如 JSON 结构。这些类型的配置使工程师很容易打错字段的名称,在需要使用整数的地方使用字符串,或犯其他简单的错误。这类简单的错误最好用静态验证来捕捉。结构化格式(Facebook 使用 Thrift)可以提供最基本的验证。然而,编写程序来对配置进行更详细的业务层面的验证也是应该的。
金丝雀部署。 将配置先部署到小范围,防止变更造成灾难性后果。金丝雀可以有多种形式。最简单的是 A/B 测试,如只对 1% 的用户启用新的配置。多个 A/B 测试可以同时进行,可以使用一段时间的数据来跟踪指标。
就可靠性而言,A/B 测试并不能满足所有需求。
- 一个被部署到少数用户的变更,如果导致相关服务器崩溃或内存耗尽,显然会产生超出测试中有限用户的影响。
- A/B 测试也很耗费时间。工程师们经常希望在不使用 A/B 测试的情况下推送小的变化。
为了避免配置导致的明显问题,Facebook 的基础设施会自动在一小部分服务器上测试新版本的配置。
例如,如果我们希望向 1% 的用户部署一个新的 A/B 测试,会首先向 1% 的用户部署测试,保证这些用户的请求落在少量服务器上,并对这些服务器进行短时间的监控,以确保不会因为配置更新出现非常明显的崩溃问题。
坚持可以运行的配置。 配置系统设计方案保证在发生失败时,保留原有的配置。开发人员一般倾向于配置发生问题时系统直接崩溃,不过 Facebook 的基础设施开发人员认为用老的配置,使模块能运行比向用户返回错误好得多。(注:我只能说这个真的要看场景)
配置有问题时回滚要快速。 有时,尽管尽了最大努力,有问题的配置还是上了线。迅速回滚是解决这类问题的关键。配置内容在版本管理系统中进行管理,保证能够回滚。
强依赖核心服务
开发人员倾向于认为,核心服务:如配置管理、服务发现或存储系统,永远不会失败。在这种假设下,这些核心服务的短暂故障,会变成大规模故障。
缓存核心服务的数据。 可以在服务本地缓存一部分数据,这样可以降低对缓存服务的依赖。 提供专门 SDK 来使用核心服务。 核心服务最好提供专门的 SDK,这样保证大家在使用核心服务时都能遵循相同的最佳实践。同时在 SDK 中可以考虑好缓存管理和故障处理,使用户一劳永逸。 进行演练。 只要不进行演练,就没法知道如果依赖的服务挂了自己是不是真的会挂,所以通过演练来进行故障注入是必须的。
延迟增加和资源耗尽
有些故障会导致延迟增加,这个影响可以很小(例如,导致 CPU 使用量微微增加),也可以很大(服务响应的线程死锁)。
少量的额外延迟可以由 Facebook 的基础设施轻松处理,但大量的延迟会导致级联故障。几乎所有的服务都有一个未处理请求数量的限制。这个限制可能是因为请求响应类服务的线程数量有限,也可能是由于基于事件的服务内存有限。如果一个服务遇到大量的额外延迟,那么调用它的服务将耗尽它的资源。这种故障会层层传播,造成大故障。
资源耗尽是特别具有破坏性的故障模式,它会使请求子集所使用的服务的故障引起所有请求的故障:
一个服务调用一个新的实验性服务,该服务只向 1% 的用户推出。通常情况下,对这个实验性服务的请求需要 1 毫秒,但由于新服务的失败,请求需要 1 秒。使用这个新服务的 1% 的用户的请求可能会消耗很多线程,以至于其他 99% 的用户的请求都无法被执行。
下面的手段可以避免请求堆积:
- 延迟控制。在分析过去涉及延迟的事件时,工程师发现大量请求都是堆积在队列中等待处理。服务一般会有线程数或内存使用上的限制。由于服务响应速度 < 请求传入速度,队列会越来越大,直到达到阈值。想要在不影响正常运行可靠性的前提下限制队列大小,FB 工程师研究了 bufferbloat 问题,这里的问题和 bufferbloat 问题很类似,都是在拥塞的时候不引起过度延迟。这里实现了 CoDel(controlled delay 缩写)算法的一个变种:
注:虽然里面写着 M 和 N,但其实 M 和 N 是定值,N = 100ms,M = 5ms
onNewRequest(req, queue): if queue.lastEmptyTime() < (now - N seconds) { timeout = M ms } else { timeout = N seconds; } queue.enqueue(req, timeout)
在这个算法中,如果队列在过去的 100ms 内没有被清空,那么在队列中花费的时间被限制在 5ms。如果服务在过去的 100ms 能够清空队列,那么在队列中花费的时间被限制为 100 ms。这种算法可以减少排队(因为 lastEmptyTime 是在遥远的过去,导致 5ms 的排队超时),同时允许短时间的排队以达到可靠性的目的。虽然让请求有这么短的超时似乎有悖常理,但这个过程允许请求被快速丢弃,而不是在系统无法跟上传入请求的速度时堆积起来。较短的超时时间可以确保服务器接受的工作总是比它实际能处理的多一点,所以它永远不会闲置。
前面也说了,这里的 M 和 N 基本上不需要按场景调整。其他解决排队问题的方法,如对队列中的项目数量设置限制或为队列设置超时,需要按场景做 tuning。M 固定 5 毫秒,N 值为 100 毫秒,在大多场景下都能很好地工作。Facebook 的开源 Wangle 库和 Thrift 使用了这种算法。
- 自适应 LIFO(后进先出)。大多数服务以 FIFO 的顺序处理队列。然而,在大量排队期间,先入的请求往往已经等了很久,用户可能已经放弃了请求相关的行为。这时候处理先入队的请求,会将资源耗费在一个不太可能使用户受益的请求上。FB 的服务使用自适应后进先出的算法来处理请求。在正常的操作条件下,请求是 FIFO 处理模式,但是当队列开始任务堆积时,则切换至 LIFO 模式。如图 2 所示,自适应 LIFO 和 CoDel 可以很好地配合。CoDel 设置了较短的超时时间,防止了形成长队列,而自适应 LIFO 模式将新的请求放在队列的前面,最大限度地提高了它们满足 CoDel 设置的最后期限的机会。HHVM 实现了这个后进先出算法。
- 并发控制。CoDel 和自适应 LIFO 都在服务器端运行。服务端是降低延迟的最佳场所--server 为大量的 client 服务,且有比 client 端更多的信息。不过有些故障较严重,可能导致服务端的控制无法启动。为了兜底 FB 还在客户端实施了一个策略:每个客户端都会在每个服务的基础上跟踪未完成的出站请求的数量。当新的请求被发送时,如果对该服务的未决请求的数量超过可配置的数量,该请求将立即被标记为错误(注:应该类似熔断)。这种机制可以防止单一服务垄断其客户的所有资源。
帮助诊断故障的工具
尽管有最好的预防措施,故障还是会发生。故障期间,使用正确的工具可以迅速找到根本原因,最大限度地减少故障的持续时间。
使用 Cubism 的高密度仪表板在处理事故时,快速获取信息很重要。好的仪表盘可以让工程师快速评估可能出现异常的指标类型,然后利用这些信息来推测根本原因。然而仪表盘会越来越大,直到难以快速浏览,这些仪表盘上显示的图表有太多线条,无法一目了然,如图3所示。
为了解决这个问题,我们使用 Cubism 构建了我们的顶级仪表盘,这是一个用于创建地平线图表的框架--该图表使用颜色对信息进行更密集的编码,允许对多个类似的数据曲线进行轻松比较。例如,我们使用 Cubism 来比较不同数据中心的指标。我们围绕 Cubism 的工具允许简单的键盘导航,工程师可以快速查看多个指标。图 4 显示了使用面积图和水平线图的不同高度的同一数据集。在面积图版本中,30像素高的版本很难阅读。同一高度下地平线图非常容易找到峰值。
最近发生了什么变更?
由于失败的首要原因之一是人为错误,调试失败的最有效方法之一是寻找人类最近的变化。我们在一个叫做 OpsStream 的工具中收集关于最近的变化的信息,从配置变化到新版本的部署。然而,我们发现随着时间的推移,这个数据源已经变得非常嘈杂。由于数以千计的工程师在进行改变,在一个事件中往往有太多的改变需要评估。
为了解决这个问题,我们的工具试图将故障与其相关的变化联系起来。例如,当一个异常被抛出时,除了输出堆栈跟踪外,我们还输出该请求所读取的任何配置的值最近发生的变化。通常,产生许多错误堆栈的问题的原因就是这些配置值之一。然后,我们可以迅速地对这个问题做出反应。例如,让上线该配置的工程师赶紧回滚配置。
从失败中学习
失败发生后,我们的事件审查过程有助于我们从这些事件中学习。
事件审查过程的目的不是为了指责。没有人因为他或她造成的事件被审查而被解雇。审查的目的是为了了解发生了什么,补救使事件发生的情况,并建立安全机制以减少未来事件的影响。
审查事件的方法 Facebook 发明了一种名为 DERP(detection, escalation, remediation, and prevention)的方法,以帮助进行富有成效的事件审查。
- 检测 detection。如何检测问题--报警、仪表板、用户报告?
- 升级 escalation。正确的人是否迅速介入?这些人是否可以通过警报被拉进故障处理流程,而不是人为拉进来?
- 补救 remediation。采取了哪些步骤来解决这个问题?这些步骤是否可以自动化?
- 预防 prevention。哪些改进可以避免同类型的故障再次发生?你如何能优雅地失败,或更快地失败,以减少这次故障的影响?
在这种模式的帮助下,即使不能防止同类型的事件再次发生,也至少能够在下一次更快恢复。
一把梭的时候少出故障
"move fast" 的心态不一定与可靠性冲突。为了使这些理念兼容,Facebook 的基础设施提供了安全阀:
- 配置系统可以防止不良配置的快速部署;
- 核心服务为客户提供加固 SDK 以防止故障;
- 核心稳定性库可以防止在延迟发生的情况下资源耗尽。
为了处理漏网之鱼,还建立了易于使用的仪表盘和工具,以帮助找到与故障关联的最近的变更。
最重要的是,在事件发生后,通过复盘学到的经验将使基础设施更加可靠。
References
- CoDel (controlled delay) algorithm; http://queue.acm.org/detail.cfm?id=2209336.
- Cubism; https://square.github.io/cubism/.
- HipHop Virtual Machine (HHVM); https://github.com/facebook/hhvm/blob/43c20856239cedf842b2560fd768038f52b501db/hphp/util/job-queue.h#L75.
- Thrift framework; https://github.com/facebook/fbthrift.
- Wangle library; https://github.com/facebook/wangle/blob/master/wangle/concurrent/Codel.cpp.
- https://github.com/facebook/folly/blob/bd600cd4e88f664f285489c76b6ad835d8367cd2/folly/executors/Codel.cpp