Grab是如何设计弹性系统:断路器


Grab是东南亚(SEA)领先的交通平台,截至2017年5月,Grab平台每天处理230万次乘车。本文重点介绍实现断路器的使用案例,包括与断路配置相关的不同选项。
但正如恶劣天气不可避免且通常难以预测一样,软件和硬件故障也是如此。这就是为什么软件工程师计划和解决故障很重要的原因。
我们将开始介绍和比较两种常用的服务可靠性机制:断路器和重试。在Grab,我们在众多软件系统中广泛使用这两种机制,以确保我们能够应对失败并继续为我们的客户提供他们期望的服务。但这两种机制是否相同?我们在哪里以及如何选择其中一个?
在本系列中,我们将仔细研究这两种方法及其用例,以帮助您在是否以及何时应用每种方法时做出明智的决定。但让我们首先看看失败的常见原因。由于我们的服务与众多外部资源进行通信,因此可能会导致故障:

  • 网络问题
  • 系统过载
  • 资源饥饿(例如内存不足)
  • 糟糕的部署/配置
  • 错误请求(例如缺少身份验证凭据,缺少请求数据)

不考虑对上游服务的调用可能失败的所有方式,通常更容易考虑成功请求是什么。它应该是及时的,在期望的格式,并且包含预期的数据。如果我们遵循这个定义,那么其他一切都是某种失败,无论是:
  • 反应迟钝
  • 根本没有回应
  • 错误格式的回复
  • 不包含预期数据的响应

在规划失败应对方案时,我们应该努力能够处理这些错误,就像我们应该试图阻止我们的服务发出它们一样。因此,让我们开始研究解决这些错误的不同技术。

(注意:本文中提到的所有示例和工具都在Go中。但是,不需要事先了解Go)

介绍断路器
电气箱中保护您的设备称为断路器。软件断路器以相同的方式工作。软件断路器是一种位于两段代码之间的机制,用于监控流经它的所有内容的健康状况。但是,它不是在发生故障时停电,而是阻止请求。
当服务被请求淹没时,服务可能会中断。一旦服务超载,进行任何进一步的请求可能会导致两个问题。首先,发出请求可能毫无意义,因为我们不会得到有效和/或及时的响应;其次,因为创建了更多请求,就无法让上游服务从不堪重负中恢复,事实上,很可能更多地重创它。
断路器不仅仅是保护我们的上游服务。它们对我们的服务也有好处,我们将在下一节中看到。

回退
断路器,如Hystrix,包括定义回退的能力。
假设您正在编写需要两个位置之间的道路行驶距离的服务。
如果事情按预期工作,我们会称之为“距离计算器服务”,为其提供起点和终点位置,并返回距离。但是,该服务目前正在宕机。因此,在这种情况下合理的回退可能是通过使用一些三角法来估计距离。当然,以这种方式计算距离将是不准确的,但是使用允许我们继续处理用户请求的不准确值远比完全失败请求好得多。

在后备处理中,使用估计值而不是实际值而不是唯一选项,其他常见选项包括:

  • 使用不同的上游服务重试请求
  • 稍后安排请求
  • 从缓存中加载可能过时的数据

当然,有一些情况没有合理的后备。但即使在这些情况下,使用断路器仍然是有益的。

考虑发出和等待最终失败的请求的成本。有CPU,内存和网络资源,都被用于发出请求并等待响应。然后是对用户的延迟响应,这些资源都处于等待之中。
当断路打开时,所有这些成本都被避免,因为没有提出请求,而是立即失败。虽然向用户返回错误并不理想,但返回最快的错误是也是一种选择,不过只是最糟糕的。

断路器应该跟踪所有错误吗?
最简洁的答案是不。我们不应该跟踪由用户引起的错误(即HTTP错误代码400和401),而是跟踪网络或基础设施(即HTTP错误代码503和500)。
如果我们跟踪用户造成的错误,那么一个恶意用户就有可能发送大量错误请求,导致我们的断路打开并造成服务的中断。

断路恢复
我们已经讨论了当出现太多错误时断路器如何打开电路并切断请求。我们还应该知道断路如何再次关闭。
与上面使用的电气示例不同,使用软件断路器,您无需在黑暗中找到保险丝盒并手动关闭断路。软件断路器可以自行闭合断路。
在断路器断开电路后,它将等待一个可配置的周期,称为睡眠窗口,之后它将通过允许一些请求来测试断路。如果服务已恢复,它将关闭断路并恢复正常操作。如果请求仍然返回错误,那么它将重复睡眠/尝试过程直到恢复。

Bulwark堡垒
在Grab,我们使用Hystrix-Go断路器,这个实现包括一个壁垒bulwark。bulwark是一个软件进程,它监视并发请求的数量,并且能够防止超过配置的最大并发请求数。这是一种非常便宜的限速形式。
在我们的例子中,通过打开断路来实现防止太多请求(如上所述)。此过程不计入错误,也不会直接影响其他断路计算。
那为什么这很重要?正如我们之前谈到的那样,当服务收到太多并发请求时,服务可能会变得无响应(甚至崩溃)。

请考虑以下情形:黑客已决定使用DDOS攻击攻击您的服务。突然间,您的服务正在接收通常数量的请求的100倍。然后,您的服务可以向上游提供100倍的请求数量。
如果您的上游没有实现某种形式的速率限制,有了这么多请求,它就会崩溃。通过在服务和上游之间引入一个舷墙,您可以实现两件事:

  • 您不会使上游服务崩溃,因为您限制了无法处理的请求数量。
  • 失败的“额外”请求既具有回退能力,又具有快速失败的能力。

断路器设置
 Hystrix-Go 具有五种设置,它们分别是:

1. 超时:
此持续时间是在被视为错误之前允许请求的最长时间。这考虑到并非所有对上游资源的调用都会立即失败。
有了这个,我们可以通过定义我们愿意等待上游的时间来限制我们处理请求所需的总时间。

2.最大并发请求
这是堡垒设置(如上所述)。
考虑默认值(10)表示同时发出请求而不是“每秒”。因此,如果请求通常很快(在几毫秒内完成),则不需要允许更多。
此外,将此值设置得过高可能会导致您的服务缺少发出请求所需的资源(内存,CPU,端口)。

3.请求阈值
这是在打开断路之前必须在评估(滚动窗口)期间内进行的最小请求数。
此设置用于确保低请求量期间的少量错误不会打开断路。

4.睡眠窗口
这是电路在断路器试图检查请求的健康状况之前等待的持续时间(如上所述)。
将此设置得太低会限制断路器的有效性,因为它经常打开/检查。但是,将此持续时间设置得太高会限制恢复时间。

5.错误百分比阈值
这是在断路打开之前必须失败的请求的百分比。
设置此值时应考虑许多因素,包括:

  • 上游服务中的主机数(下一节中的更多信息)
  • 上游服务的可靠性以及与之的连接
  • 服务对错误的敏感性
  • 个人喜好

断路配置
在接下来的几节中,我们将讨论与断路配置相关的一些不同选项,特别是每个主机和每个服务配置,以及我们作为程序员如何定义断路。

Hystrix-Go中,典型的使用模式如下所示:

hystrix.Go("my_command", func() error {
    // talk to other services
    return nil
}, func(err error) error {
    // do this when services are down
    return nil
})

第一个参数“my_command”是断路名称。这里要注意的第一件事是因为断路名称是一个参数,所以可以向断路器的多个调用提供相同的值。

这有一些有趣的副作用。
假设您的服务调用上游服务的多个端点,称为“列表”,“创建”,“编辑”和“删除”。如果我们想分别跟踪每个端点的错误率,您可以像这样定义断路:

func List() {
   hystrix.Go("my_upstream_list", func() error {
      // call list endpoint
      return nil
   }, nil)
}
func Create() {
   hystrix.Go("my_upstream_create", func() error {
      // call create endpoint
      return nil
   }, nil)
}

func Update() {
   hystrix.Go("my_upstream_update", func() error {
      // call update endpoint
      return nil
   }, nil)
}

func Delete() {
   hystrix.Go("my_upstream_delete", func() error {
      // call delete endpoint
      return nil
   }, nil)
}

您会注意到我已使用“my_upstream_”为所有断路添加前缀,然后附加端点的名称。这为4个端点提供了4个断路。
另一方面,如果我们想要跟踪一个目的地的所有错误,我们可以像这样定义我们的断路:

func List() {
   hystrix.Go("my_upstream", func() error {
      // call list endpoint
      return nil
   }, nil)
}

func Create() {
   hystrix.Go("my_upstream", func() error {
      // call create endpoint
      return nil
   }, nil)
}

func Update() {
   hystrix.Go("my_upstream", func() error {
      // call update endpoint
      return nil
   }, nil)
}

func Delete() {
   hystrix.Go("my_upstream", func() error {
      // call delete endpoint
      return nil
   }, nil)
}

在上面的示例中,所有不同的调用都使用相同的断路名称。

那么我们如何决定选择哪个?在理想情况下,每个上游目的地一个断路就足够了。这是因为所有故障都与基础设施(即网络)相关,并且在这些情况下,当对一个端点的呼叫失败时,所有故障都肯定会失败。这种方法将导致断路在最短的时间内打开,从而降低我们的错误率。
但是,这种方法假设我们的上游服务不会以一种某个端点被破坏而其他端点仍然工作的方式失败。它还假设我们对上游响应的处理是从上游服务返回的错误时也不会发生问题。例如,如果我们不小心跟踪我们的断路器调用中的用户发生的错误,我们很快就会发现自己无法调用上游。
因此,即使每个端点有一个断路导致断路开启稍慢,这也是我推荐的方法。最好尽可能多地提出成功请求,而不是不恰当地打开断路。

每个服务一个断路
我们已经将上游服务视为单个目标,并且在处理数据库或缓存时,它们可能就是这样。但是在上游是API /服务时,就很少会出现这种情况。

为什么这很重要?回想一下我们之前关于服务如何失败的讨论。如果运行上游服务的计算机出现资源问题(内存不足,CPU不足或磁盘已满),则这些问题将本地化到该特定计算机。因此,如果一台机器资源匮乏,这并不意味着支持该服务的所有其他机器都会遇到同样的问题。

当我们有一个断路器用于对特定资源或服务的所有调用时,我们在“按服务”模型中使用断路器。让我们看一些例子来研究它如何影响断路器的行为。

首先,当我们只有1个目的地时,通常是数据库的情况:
如果对单个目标(例如数据库)的所有调用都失败,那么我们的错误率将为100%。
断路肯定会打开,这是可取的,因为数据库无法正确响应,进一步的请求会浪费资源。

现在让我们看看当我们添加负载均衡器和更多主机时会发生什么:

假设一个简单的轮询负载平衡,对一个主机的所有调用都成功,对另一个主机的所有调用都失败。这样得到:1个坏主机/ 2个主机= 50%错误率。

如果我们将误差百分比阈值设置为超过50%,则断路不会打开,我们会看到50%的请求失败。或者,如果我们将误差百分比阈值设置为小于50%,则断路将打开并且所有请求都快捷回退处理或失败。

现在,如果我们要向上游服务添加其他主机比如一共7个,7个前面有一个负载平衡器:
然后,一个坏实例的计算和影响发生了巨大变化。我们的结果变成:1个坏主机/ 6个主机= 16.66%错误率。

我们可以从这个扩展的例子中得到一些东西:

  • 一个不好的实例不会导致断路打开(这会阻止所有请求工作)
  • 设置一个非常低的错误率(例如10%),这将导致断路因一个坏主机而打开,这将是愚蠢的,因为我们有5个其他主机能够为这些请求提供服务
  • 当大多数(或所有)目标主机不健康时,“按服务”配置的断路器应该只有一个开路

每个主机一个断路
如上所述,一个坏主机可能会影响您的断路,因此您可能会考虑为每个上游目标主机使用一个断路。
但是,要实现这一点,我们的服务必须了解上游主机的数量和身份。在前面的示例中,它只知道负载均衡器的存在。因此,如果我们从前面的示例中删除负载均衡器,我们将留下7台主机。
使用此配置,我们的一个坏主机不能影响跟踪其他主机的电路。感觉就像一场胜利。
但是,在删除负载均衡器后,我们的服务现在需要承担其职责并执行客户端负载平衡。
为了能够执行客户端负载平衡,我们的服务必须跟踪上游服务中所有主机的存在和运行状况,并在主机之间平衡请求。在Grab,我们的许多基于gRPC的服务都是以这种方式配置的。(banq注:SpringCloud中是基于erueka注册服务器)
使用我们的新配置,我们遇到了一些额外的复杂性,与客户端负载平衡有关,我们也从1个断路器变为6个。这些额外的5个断路也会产生一些资源(即内存)成本。在这个例子中,它可能看起来不是很多,但随着我们采用额外的上游服务并且这些上游主机的数量增加,成本确实成倍增加。
我们应该考虑的最后一件事是这种配置将如何影响我们满足请求的能力。当主机首次出现故障时,我们的请求错误率将与之前相同:1个坏主机/ 6个主机总数= 16.66%错误率
但是,在将断路打开直到坏主机之后发生了足够的错误,将能够避免向该主机再次发出请求,然后会恢复,重新开始只有0%的错误率。

每个服务与每个主机的最终想法
根据上面的讨论,您可能希望将所有断路转换为每个主机。但是,这样做的额外复杂性不应低估。(banq注:微服务是每个服务一个主机,如果是SOA,需要服务和主机的区分)。
此外,我们还应该考虑当坏主机发生故障时,每个服务负载均衡器可能会有什么响应。如果我们的每个服务示例中的负载均衡器配置为监视在每个主机上运行的服务的运行状况(而不仅仅是主机本身的运行状况),那么它能够从负载均衡器中检测并删除该主机并可能替换它有一个新的主机。
可以同时使用每个服务和每个主机(虽然我从未尝试过)。在此配置中,每个服务电路应仅在几乎没有机会存在任何有效主机时打开,并且通过这样做可以节省在重试周期中运行的请求处理时间。其配置必须是: 断路器(每个服务)→重试→断路器(每个主机)。
我的建议是考虑上游服务失败的方式和原因,然后根据您的情况使用最简单的配置。