发生严重故障会阻止服务产生有用的结果。例如,在某个电子商务网站中,如果查询产品信息的数据库出现故障,则该网站将无法成功显示产品页面。Amazon 服务必须处理大多数严重故障,才能确保可靠性。处理严重故障的策略分为四大类:

重试:立即或在一定延迟后再次执行失败的活动。
主动重试:多次并行执行活动,并利用第一个活动完成。
故障转移:针对不同的终端节点副本再次执行活动,或者最好执行活动的多个并行副本,以提高其中至少一个副本成功的几率。
回退:使用不同的机制来获得相同的结果。

本文将介绍回退策略,以及我们几乎从未在 Amazon 上使用这些策略的原因。对于这种情况,您可能会感到惊讶。毕竟,工程师们通常将现实世界作为设计的出发点。在现实世界中,必须提前计划回退策略,并在必要时使用。假设机场的展示板无法显示内容。必须制定应急计划(如手动在白板上书写飞行信息)来应对这种情况,因为乘客仍需要找到登机口。但请考虑一下应急计划会有多糟糕:阅读手写白板内容的难度、保持白板信息最新的难度以及人为添加不正确信息的风险。白板回退策略很有必要,但它存在各种问题。

在分布式系统领域,回退策略是最难应对的挑战之一,对于时间敏感的服务来说尤其如此。更糟糕的是,不良的回退策略可能需要很长时间(甚至数年)才能产生影响,而优质策略与不良策略之间的差异并不明显。本文将重点讲述回退策略如何导致更多问题,且问题的出现速度比修复速度更快。我们将提供一些示例,说明回退策略给 Amazon 哪些方面造成了问题。最后,我们将讨论我们在 Amazon 使用的回退备用方案。

分析服务的回退策略并不直观,而且在分布式系统中很难预见到其波及效应,因此我们先来看看单台计算机应用程序的回退策略。

单台计算机回退

假设存在以下 C 代码段,该代码段说明了处理许多应用程序中内存分配故障的常见模式。该代码使用 malloc() 函数分配内存,然后在执行某种转换时将图像缓冲区复制到该内存中:
pixel_ranges = malloc(image_size); // allocates memory
if (pixel_ranges == NULL) {
  // On error, malloc returns NULL
  exit(1);
}
for (i = 0; i < image_size; i++) {
  pixel_ranges[i] = xform(original_image[i]);
}

当 malloc 失败时,代码无法从中正常恢复。实际上,对 malloc 的调用很少失败,因此开发人员经常忽略它在代码中的失败。为什么这种策略如此普遍? 原因是,在单台计算机上,如果 malloc 失败,则该计算机可能内存不足。因此,存在比一个 malloc 调用失败更大的问题 – 计算机可能即将崩溃。大多数情况下,在单台计算机上,这听起来很合理。许多应用程序并不那么重要,不值得花费力气去解决如此棘手的问题。但如果您确实想处理错误该怎么办? 在这种情况下,尝试做一些有用的事情颇为棘手。假设我们实施了另一种名为 malloc2 的方法,该方法以不同的方式分配内存,如果默认的 malloc 实施失败,则调用 malloc2:

pixel_ranges = malloc(image_size);
if (pixel_ranges == NULL) {
  pixel_ranges = malloc2(image_size);
}

初看起来,这段代码似乎可以正常工作,但它存在一些相较其他代码不太明显的问题。首先,回退逻辑难以测试。我们可以拦截对 malloc 的调用并注入故障,但这可能无法准确模拟生产环境中会发生的情况。在生产环境中,如果 malloc 失败,则计算机很可能内存不足或内存较低。您如何模拟这些广泛存在的内存问题? 即使您可以生成一个低内存环境来运行测试(例如在 Docker 容器中),您如何将低内存条件与 malloc2 回退代码的执行保持一致?

另一个问题是,回退本身可能会失败。以前的回退代码无法处理 malloc2 失败,因此,该计划并没有提供您可能认为的那么多优点。回退策略可能会降低彻底失败的可能性,但是仍有可能出现彻底失败。在 Amazon,我们发现将工程资源用于提升主要(非回退)代码的可靠性通常比投资于不常使用的回退策略更能提高我们的成功几率。

此外,如果可用性是我们的最高优先目标,则回退策略可能不值得去冒这种风险。如果 malloc2 的成功几率更高,为什么还要再三考虑 malloc? 从逻辑上讲,malloc2 必须做出权衡,以换取更高的可用性。它可能会在延迟较高但容量较大且基于 SSD 的存储中分配内存。那么问题来了,为什么 malloc2 可以做出这种权衡? 让我们考虑一下此回退策略可能会发生的潜在事件序列。首先,客户正在使用该应用程序。突然(因为 malloc 失败),malloc2 启动,应用程序速度变慢。这很糟糕:慢一点真的没关系吗? 问题远不止于此。假设计算机的内存很可能不足(或非常低)。客户现在遇到两个问题(应用程序速度变慢和计算机速度变慢),而不是一个问题。切换到 malloc2 的副作用甚至可能会使整个问题更加严重。例如,其他子系统也可能正在争夺相同的基于 SSD 的存储。

回退逻辑还可能在系统上放置不可预测的负载。即使是简单的常见逻辑(例如将错误消息写入带有堆栈跟踪的日志),表面上也是无害的,但是,如果突然发生变化,导致该错误发生的几率较高,则受 CPU 限制的应用程序可能会突然转变为受 I/O 限制的应用程序。如果磁盘未预置为以该速率处理写入或存储该数量的数据,则可能会降低应用程序的速度或使其崩溃。

回退策略不仅可能会使问题更加严重,而且可能会作为潜在错误发生。在生产环境中很少触发的回退策略很容易制定。一个客户的计算机可能也需要数年时间在正确的时间真正耗尽内存,来触发特定的代码行,并回退到前面所示的 malloc2。如果回退逻辑中存在错误或某种副作用,使整个问题更加严重,编写代码的工程师很可能已忘记它的工作原理,代码将很难修复。对于单台计算机应用程序,这可能是一个可以接受的业务权衡,但在分布式系统中,后果要严重得多,我们稍后将对此进行讨论。

所有这些问题都很棘手,但根据我们的经验,在单台计算机应用程序中,它们通常可以被安全地忽略。最常见的解决方案是前面提到的解决方案:只需让内存分配错误使应用程序崩溃。分配内存的代码与计算机的其余部分命运共担,在这种情况下,计算机的其余部分很可能会发生故障。即使它们并不共担命运,应用程序现在也会处于无法预测的状态,而快速失败是一个较好的策略。商业权衡是合理的。

对于在内存分配失败时必须工作的关键单台计算机应用程序,一个解决方案是在启动时预分配所有堆内存,并且即使在出错情况下也不会再次依赖 malloc。Amazon 已多次在守护程序中实施此策略,例如,监控生产服务器上运行的守护程序和监控客户 CPU 突发的 Amazon Elastic Compute Cloud (Amazon EC2) 守护程序。

分布式回退

在 Amazon,我们不允许分布式系统,特别是那些需要实时响应的系统,与单台计算机应用程序进行相同的权衡。这样做的原因之一是我们未与客户共担命运。我们可以假定应用程序正在客户面前的计算机上运行。如果应用程序内存不足,客户可能不希望它继续运行。服务不直接在客户使用的计算机上运行,因此期望有所不同。除此之外,客户通常能够准确地使用服务,因为这比在单个服务器上运行应用程序的可用性更高,因此我们需要这样做。理论上,这将促使我们实施回退,以提高服务可靠性。遗憾的是,分布式回退在涉及严重系统故障时会出现所有相同的问题,甚至更多的问题。

分布式回退策略更难测试。服务回退比单台计算机应用程序更复杂,因为多台计算机和下游服务在故障中起着重要作用。故障模式本身(如过载方案)很难在测试中复制,即使多台计算机的测试编排随时可用。组合还会增加要测试的案例的数量,因此您需要更多测试,而这些测试的设置会更加困难。

分布式回退策略本身可能会失败。 虽然回退策略似乎可以保证成功,但根据我们的经验,它们通常只能提高成功的几率。

分布式回退策略通常会使中断情况更糟糕。根据我们的经验,回退策略会扩大故障的影响范围,并增加恢复时间。

分布式回退策略通常不值得冒险。与 malloc2 一样,回退策略通常要做出某种权衡;否则,我们会一直使用它。为什么在已经出现问题的情况下还要使用更糟糕的回退策略?

分布式回退策略通常具有潜在的错误,这些错误仅在发生了一组不太可能的巧合时才会出现,可能是在发生后的几个月或几年。
Amazon 零售网站中的回退机制触发的实际重大中断说明了所有这些问题。中断发生在 2001 年左右,是由一项新功能导致的,这项新功能为网站上显示的所有产品提供最新的配送速度。新功能如下所示:

当时,网站架构只有两层,由于这些数据存储在供应链数据库中,因此 Web 服务器需要直接查询数据库。但数据库无法与网站中的请求量保持同步。网站流量很大,有些页面会显示 25 个或更多产品,且内联显示每个产品的配送速度。因此,我们添加了一个缓存层,在每个 Web 服务器上作为单独的进程运行(类似于 Memcached):

这种方法效果不错,但该团队还尝试处理缓存(一个单独的进程)由于某种原因而失败的情况。在此场景中,Web 服务器恢复到直接查询数据库的状态。在伪代码中,我们编写了如下类似内容:

if (cache_healthy) {
  shipping_speed = get_speed_via_cache(sku);
} else {
  shipping_speed = get_speed_from_database(sku);
}

回退到直接数据库查询是一种直观的解决方案,在几个月内,它确实发挥了一定作用。但最终结果,缓存全部在同一时间发生故障,这意味着每个 Web 服务器都直接访问数据库。这样就产生了足够的负载来完全锁定数据库。由于数据库中的所有 Web 服务器进程都被阻止,因此整个网站都陷入瘫痪。另外,这种供应链数据库对于物流中心也至关重要,因此故障进一步蔓延,全球所有物流中心都暂停服务,直到问题得到解决。

我们在单台计算机的案例中遇到的所有问题都存在于分布式案例中,而且其后果更为严重。分布式回退案例很难测试;即使我们模拟了缓存故障,我们也无法发现问题,这需要多台计算机发生故障才能触发。在这种情况下,回退策略本身放大了问题,比根本没有回退策略更糟糕。回退将部分网站故障(无法显示配送速度)变为全站故障(完全无法加载页面),并在后端关闭整个 Amazon 物流网络。

在这种情况下,我们的回退策略背后的想法不合逻辑。如果直接访问数据库比通过高速缓存更可靠,为什么还要首先使用高速缓存呢? 我们担心不使用高速缓存会导致数据库过载,但如果回退代码具有潜在的危害,为什么还要考虑使用回退代码? 我们可能早就注意到自己的错误,但这是一个潜在错误,引发故障的情况在发布几个月后才出现。

Amazon 如何避免回退

考虑到我们在分布式回退中遇到的这些陷阱,我们现在几乎总是倾向于选择回退的替代方案。下面概述了这些方案。

提高非回退案例的可靠性

如上所述,回退策略只会降低完全失败的可能性。如果主(非回退)代码的可靠性得到提高,服务的可用性就可能会更高。例如,团队可以投资使用具有更高内在可用性的数据库(例如 Amazon DynamoDB),而不是在两个不同的数据存储之间实施回退逻辑。此策略在 Amazon 中经常使用且屡屡奏效。例如,此讲座介绍了如何使用 DynamoDB 在 Prime Day 2017 这天为 amazon.com 提供支持。

让调用者处理错误

严重系统故障的一个解决方案不是回退,而是让调用系统处理故障(例如,通过重试)。这是 AWS 服务的首选策略,我们的 CLI 和开发工具包已经具有内置的重试逻辑。在可能的情况下,我们更青睐这种策略,尤其是在已做出足够努力来共担命运并降低主案例失败可能性(而且回退逻辑根本不可能改善可用性)的情况下。 

主动推送数据

我们用来避免回退的另一种方法是在响应请求时减少移动部件的数量。例如,如果服务需要数据来满足请求,而这些数据已在本地存在(不需要提取),则不需要故障转移策略。这种方法的一个成功示例是实施适用于 Amazon EC2 的 AWS Identity and Access Management (IAM) 角色。IAM 服务需要为 EC2 实例上运行的代码提供经过签名和轮换的凭证。为了避免产生回退需要,系统会主动将凭证推送到每个实例,并在许多小时内保持有效。这意味着由于推送机制发生中断的可能性较小,IAM 角色相关请求可持续工作。 

将回退转换为故障转移

关于回退,最糟糕的其中一件事情是,它不会定期执行,并且在故障期间触发时,可能会失败或扩大影响范围。触发回退的情况可能几个月甚至几年都不会自然发生! 要解决回退策略的潜在失败问题,需在生产环境中定期实施这一策略。服务必须连续运行回退和非回退逻辑。它不能仅运行回退案例,还必须将其视为同等有效的数据源。例如,服务可能会在回退和非回退响应之间随机选择(当同时获得两个响应时),以确保二者都可正常运行。但此时,该策略不能再被视为回退策略,而是完全属于故障转移类别。

确保重试和超时不会成为回退

重试和超时已在超时、重试和抖动回退一文中讨论。文章中提到,重试是一种强大的机制,可在出现瞬态和随机错误时提供高可用性。换言之,重试和超时可为因虚假包丢失、不相关的单台计算机故障等小问题而偶尔出现的故障提供保障。但是,重试和超时很容易用错。服务通常持续数月或更长时间,无需多次重试,而在您的团队从未测试过的场景中,这些重试最终可能会投入使用。出于此原因,我们需维护监控总体重试率以及在重试频繁发生时用以提醒团队的警报的指标。

避免让重试变为回退的另一种方法是始终使用主动重试执行操作(也称为对冲或并行请求)。这种技术固有地内置在执行法定读取或写入的系统中,在这种系统中,可能需要三台服务器中的两台服务器提供应答才能响应。主动重试遵循持续工作的设计模式。由于总是发出冗余请求,因此随着对冗余请求的需求增加,不会向系统添加重试产生的额外负载。

结论

在 Amazon,我们会避免在系统中使用回退,因为它很难证明,而且有效性也很难测试。回退策略会引入一种操作模式,系统只会在最混乱的时刻进入该模式,而此时事物开始出现问题,切换到该模式只会增加混乱。在实施回退策略和在生产环境中遇到回退策略之间,通常会有很长的延迟。

我们倾向于在生产环境中连续而不是很少使用的代码路径。我们专注于提高主系统的可用性,方法是使用将数据推送到需要数据的系统等模式,而不是在关键时刻冒着失败的风险提取远程调用。最后,我们会注意代码中的微妙行为,这些行为可能会将其转换为类似回退的操作模式,例如执行过多的重试。

如果回退在系统中至关重要,我们会在生产环境中尽可能频繁地执行回退,这样回退就像主要操作模式一样可靠且可预测。


关于作者

Jacob Gabrielson 是 Amazon Web Services 的高级首席工程师。他已在 Amazon 工作长达 17 年,主要负责内部微服务平台。在过去的 8 年中,他一直致力于 EC2 和 ECS 相关工作,包括软件部署系统、控制平面服务、Spot 市场、Lightsail 以及最近新涉足的容器。Jacob 的爱好是系统编程、编程语言和分布式计算。他最不喜欢双模式系统行为,尤其是在故障条件下。他毕业于西雅图的华盛顿大学,拥有计算机科学学士学位。

超时、重试和抖动回退 缓存挑战和策略