在分布式系统中通过客户端库包提高可用性


在客户端应用程序中设置一个库,我们可以一致地处理故障,从而提高系统的感知可用性。
在开发在我们自己的公司内部或外部使用的 API 时,除了记录和公开端点之外,我们还可以选择交付客户端库。这种方法对用户有很多好处:更容易实现(有时它甚至是单线),更容易迁移(通常只是增加一个依赖版本),并且可能更容易设置安全性。
虽然开发和维护此类客户端库需要付出努力,但它们也可以为 API 开发人员带来巨大的好处,其中包括增加系统的正常运行时间/可用性。当出现瞬态故障时,重试操作通常可以解决问题。这个逻辑可以内置到客户端库中,效果很好。
本文的目的是了解在哪些情况下在普通 API 定义之上添加客户端库可以显着提高正常运行时间。首先,我们将介绍分布式系统架构中的一些最佳实践,这些是从借助客户端库获得的正常运行时间收益中受益的必要先决条件。然后,我们将讨论如何准确地开发客户端库以增加我们系统的正常运行时间,同时又不会花费太多维护成本。在结束本文时,我将提到客户端库如何让 API 开发人员的生活变得更好的其他几种方式。
在开始之前,让我们在本文的上下文中定义可用性/正常运行时间。它是“从客户端的角度来看成功操作的百分比”,意思是在纯 HTTP 实现的情况下,它是没有超时或返回 5xx 响应代码的请求的百分比。在库实现的情况下,它是未导致异常或返回服务器错误的成功方法调用的百分比。
同样,我们试图通过重试来减少客户端感知到的错误数量,而不是网络请求失败或服务重新启动的数量。让我们首先了解一些最重要的先决条件。
 
高可用和DNS
为了获得较高的重试成功率,我们需要没有单点故障的基础设施。如果其中一个物理位置的应用程序停止响应,并且我们有理由相信该位置存在问题,则高可用性设置将允许客户端开始将请求路由到不同物理位置的负载平衡器。
如果应用程序完全托管在单个物理位置上,客户端重试仍然可以在临时网络故障的情况下提供帮助,但在物理位置出现故障的情况下整个应用程序将变得不可用。
要使用高可用性设置,请确保您的 DNS 解析为多个 IP 地址供客户端选择(例如,每个负载均衡器一个)。有趣的事实:大多数浏览器实际上都有内置机制来尝试与 DNS 响应不同的 IP 地址,以防他们一直使用的 IP 地址没有响应。
 
负载均衡器配置
负载均衡器通过将请求路由到集群中的可用实例,在提高软件系统的可靠性方面发挥着至关重要的作用。它们使用运行状况检查确定哪些实例可用,并维护一个运行状况良好且能够服务请求的实例的活动列表。
如果负载均衡器没有收到来自认为健康的实例的预期响应(或任何响应),它应该将该实例标记为不健康,并确保在健康检查成功之前没有进一步的请求被路由到该实例。负载均衡器检测失败实例的速度越快,失败的请求就越少,后续重试被路由到另一个健康实例的机会就越大。
虽然负载均衡器通常可以检测到故障(L4 和 L7 负载均衡器在这里的工作方式略有不同,如果您不熟悉,我强烈推荐Hussein Nasser 的解释),它不会为我们重试对不同实例的相同请求——这个责任仍然在客户端应用程序中。
 
超时
在为我们的客户端公开 API 时,我们指定最大服务响应时间,即我们的客户端超时。这就是为什么以后端总最大超时小于客户端超时的方式设计系统很重要的原因。
请记住:我们正在使用客户端库通过重试失败的操作来提高系统的感知可用性。理想情况下,为了让客户端库能够在放弃并通知客户端应用程序失败之前“在后台”重试一次,差异应该至少是 2 倍,这意味着后端超时必须小于 客户端超时的一半。换句话说,当承诺某些最大响应时间时,我们需要考虑至少一次重试的时间。
通常,用例中涉及的顺序操作越多,每个单独的超时时间应该越短。我在关于 API 故障的文章中从不同的角度提到了超时,并在我[url=https://betterprogramming.pub/architecting-distributed-systems-random-code-8db0cd9b87d1]关于随机数的文章[/url]中解释了为什么应该在超时中添加一些抖动。
 
幂等性
最后但同样重要的是,如果我们第一次没有得到预期的结果,我们希望能够在客户端安全地重试相同的操作。请求可能已经超时,但操作实际上可能已经成功,在这种情况下重试必须返回初始操作的结果,而不是再次执行。换句话说,如果我们不知道我们的第一次尝试是否成功,我们希望能够安全地重试操作。
 
客户端库实现
当涉及到客户端库的实现时,首先要了解需求是很重要的。我通常会问两个问题:

  1. 我们的集成重读还是写?
  2. 我们可以在发送之前在客户端可靠地保存记录吗?

如果客户端应用程序主要将数据写入我们的系统,并且这些客户端可以访问非易失性内存,我会鼓励使用内存来缓冲服务器尚未成功接收的数据。这是物联网用例中的常见模式,如果服务器暂时无法接收从设备发送的数据,我们不希望丢失任何遥测数据。一旦服务器确认它们成功摄取,就可以删除本地存储的记录。
然后,我问第三个问题:客户端是否需要先离线?由于这不是一个常见的要求,而且出了名的很难做到正确,我假设我们的客户大部分时间都可以访问互联网。
由于大多数时候,客户端的工作不是写繁重的,它们不需要离线优先支持,并且是无状态的,我将在进一步的解释中重点关注这个场景。
根据最新的行业标准,API 要么作为HTTP端点公开,要么使用gRPC框架。对于 HTTP API,事实来源是OpenAPI规范文件,而如果您要公开 gRPC API,那将是带有服务和消息定义的.proto文件。OpenAPI 和 gRPC 都带有代码生成器生态系统,它们是我们客户端库的完美起点。事实上,我们可能想要做的是使用 OpenAPI 和 gRPC 附带的久经考验的生成器,并编写我们自己的带有额外重试逻辑的插件。
基本上,我们采用的是这样的东西,它将由默认编译器(伪代码)生成:
function getWeather(city) {
  return transport.callGetWeather(city); // throws Error
}


并将其包装在我们自己的错误处理逻辑中,例如:

function getWeather(city) {
  try {
    return transport.callGetWeather(city);
  } catch (error) {
    if error.code in (500, 502, 503, 504)
      // perhaps try a different load balancer
      return transport.callGetWeather(city, retry = true);
    else
      throw error;
  }
}


在后台,客户端可以决定使用与先前执行的 DNS 解析不同的 IP 地址,以避免将请求重试到相同的物理位置。
不幸的是,我们需要在插件中为客户端使用的每种支持的编程语言复制此代码生成逻辑。幸运的是,如果操作正确,只需为所有服务执行一次。Google 倾向于使用与生成的库相同的语言编写这些插件,即 Java 生成器插件是用 Java 编写的,允许语言社区为项目做出贡献。
如果某些操作不能自动重试,例如因为它们不是幂等的,则可以在您的 API 规范文件中将它们标记为这样。然后,代码生成器插件可以考虑此信息并有条件地将代码包装在附加重试逻辑中。由于我们可以控制规范文件和代码生成器,因此我们可以根据具体用例获得所需的灵活性。
 
好处: 
除了屏蔽故障使应用程序感觉更可用之外,客户端库还有其他优点,我想在这里列出其中的一些优点。如果您希望我在单独的文章中扩展这些要点中的一个或多个,请告诉我。

  1. 产品质量。您可以提供有史以来最快、最可靠的服务,但如果客户犯了集成错误并且没有获得广告宣传的 99.95% 可用性,那么从客户的角度来看,整体质量仍然会下降。拥有一个库而不是普通的 API 文档可以减少集成错误的机会,并使整个产品看起来更好。此外,代码执行的时间越长(想象成百上千的客户端运行它),发现和修复错误的速度就越快。
  2. 故障排除。如果客户可以接受数据共享,则可以将错误日志直接从客户端库发送到您的系统,这样您就可以全面了解所发生的情况,同时拥有客户端和服务器日志。
  3. 安全与迁移。我将这两个分组是因为它们对客户意味着同样的事情:简单。实现安全性可能并非易事,而库可以隐藏这种复杂性的很大一部分。在迁移方面,对依赖项进行简单的更新通常就足够了,并且在发生重大更改的情况下,不同的方法签名通常比文档更容易理解。
  4. 升级。零停机升级或重新启动可能很难做到正确,并且开发人员通常倾向于在较小的停机时间而不是额外的复杂性上进行权衡。通过在客户端屏蔽错误,您可以让升级感觉像是零停机时间,而无需背后的实际工程。