分布式计算是一个复杂的领域,面临着众多挑战,了解与之相关的谬误对于构建健壮且可靠的分布式系统至关重要。以下是分布式计算的八个谬误及其意义:
1. 网络可靠:假设网络连接始终可用且可靠,当网络中断发生时,即使网络中断是暂时的,也可能会导致系统故障。设计能够通过冗余和容错机制妥善处理网络故障的系统至关重要。
2. 延迟为零:高估分布式组件之间的通信速度可能会导致系统缓慢且无响应。承认网络延迟并对其进行优化对于提供高效的用户体验至关重要。
3. 带宽是无限的:认为网络带宽是无限的可能会导致网络过载并导致拥塞。高效的数据传输和带宽管理对于避免性能瓶颈至关重要。
4. 网络是安全的:假设网络本质上是安全的,可能会导致漏洞和数据泄露。实施强大的安全措施(包括加密和身份验证)对于保护分布式系统中的敏感信息是必要的。
5. 拓扑不会改变:网络不断发展,假设静态拓扑可能会导致配置错误和系统不稳定。系统的设计应能够适应不断变化的网络条件和配置。
6. 只有一个管理员:相信单个管理员控制整个分布式系统可能会导致协调问题和冲突。现实中,分布式系统往往涉及多个管理员,需要明确的治理和协调机制。
7. 传输成本为零:忽视与数据传输相关的成本可能会导致资源利用效率低下并增加运营费用。优化数据传输并考虑相关成本对于经济高效的分布式计算至关重要。
8. 网络是同质的:假设所有网络组件和节点具有相同的特征可能会导致兼容性问题和性能差异。系统的设计应能够处理异构性并适应各种类型的设备和平台。
理解这些谬论至关重要,因为它们强调了分布式计算的挑战和复杂性。如果不考虑这些谬误,可能会导致系统故障、安全漏洞和运营成本增加。构建可靠、高效和安全的分布式系统需要深入了解这些谬论,并实施适当的软件设计和架构以及 IT 运营策略来解决这些问题。
不可靠的网络
在这篇博文中,我们将讨论第一个谬论、它对微服务架构的影响,以及如何规避这一限制。假设我们使用 spring-boot 来编写微服务,它使用 MongoDB 作为后端,在 Kubernetes 中部署为 StatefulSet。并在 EKS 上运行它。您可能还会质疑,为我们提供可靠的网络是您的云提供商的工作,而我们却为高可用性而向他们付费。虽然这种期望可能没有错,但不幸的是,当您通过云租用硬件时,它并不总是按预期工作。假设您的云提供商承诺 99.99% 的可用性,这令人印象深刻,对吗?不,不是这样!我会解释如何做。99.99% 的可用性可能会导致。
- 10,000 个请求中的每个请求都失败。
- 1,00,000 个请求中每 10 个请求都会失败。
您可能会说您的系统无法获得这种流量!很公平,但这是云提供商的可用性数据,而不是您的服务实例,这意味着如果该云在其网络内收到十亿个网络请求,则有 1,00,000 个将失败!让事情变得更复杂的是,您不能指望他们使用其硬件将这些故障分布到所有帐户;根据您的运气,您可能会遇到许多此类失败。这里的问题是,您是否只想在这些中断不会影响到您的情况下经营一家企业?我希望不是!这是对分布式计算的第一个(也是最关键的)谬误的基本描述。
网络故障的影响
我们以电子商务系统为例;我们通常会从产品微服务中看到产品目录;但是,在构建产品目录响应时,可能会从另一个微服务获取 SKU 可用性。不过,有人可能会说,我可以通过 Choreography 将 SKU 信息复制到产品目录中,但就本示例的范围而言,我们假设这还没有到位。因此,产品服务正在对 SKU 服务进行 REST API 调用。当此调用失败时会发生什么?您如何向最终用户传达他们正在查看的产品是否可用?
可怕的东西,是吗?嗯,没那么可怕;作为工程师,我们喜欢在更艰难的领域勇敢地面对,因此我们有一些技巧。
容错和弹性编码
这个话题本身就值得写一本书,而不是一篇博客文章。但我会尽力涵盖所有内容,同时保持简单。我在这里分享的大部分内容都是 NimbleWork 中 SaaS 业务从整体架构过渡到微服务时收集的经验。我希望其他人也觉得它有帮助。
短暂中断的模式
以下模式有助于避免我们通常所说的短暂中断或突发事件。基本假设是,此类中断的生命周期最多为一到两秒。
重试
最简单的事情之一是将网络调用包装在重试逻辑中,以便在调用服务最终放弃之前进行多次尝试。这里的想法是,来自云提供商的临时网络障碍不会持续比获取数据的重试时间更长的时间。几乎所有常见编程语言中的微服务库和框架都提供了此功能。退休人员本身必须细致入微或离散;例如,在收到 400 时重试不会更改输出,直到请求签名发生更改。以下是使用 Spring WebFlux WebClient 进行 REST API 调用时使用重试的示例。
scss
webClient.get().uri(uri)
.headers(headers -> headers.addAll(httpHeaders))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
})
.log(this.getClass().getName(), Level.FINE)
.retryWhen(
Retry.backoff(3, Duration.ofSeconds(2))
.jitter(0.7)
.filter(throwable -> throwable instanceof RuntimeException ||
(throwable instanceof WebClientResponseException &&
(((WebClientResponseException) throwable).getStatusCode() == HttpStatus.GATEWAY_TIMEOUT || ((WebClientResponseException) throwable).getStatusCode() == HttpStatus.SERVICE_UNAVAILABLE || ((WebClientResponseException) throwable).getStatusCode() == HttpStatus.BAD_GATEWAY)))
.onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> {
log.error("Service at {} failed to respond, after max attempts of: {}", uri, retrySignal.totalRetries());
return retrySignal.failure();
}))
.onErrorResume(WebClientResponseException.class, ex -> ex.getStatusCode().is4xxClientError() ? Mono.empty() : Mono.error(ex));
以下是我们试图通过这段代码实现的目标的摘要:
- 两秒内最多重试 3 次。
- 根据抖动随机间隔重试之间的时间。
- 仅当上游服务提供 HTTP 504、503 或 502 状态时才重试。
- 记录错误并在最大尝试次数耗尽时将其传递给下游。
- 为客户端错误包装一个空响应,或者将上一步中的错误传递到下游。
这些重试可以帮助您从预计不会持续很长时间的突发事件或障碍中恢复过来。如果我们调用的上游服务由于某种原因重新启动,这也可能是一个很好的机制。
注意:使用滚动更新策略在 Kubernetes 中运行副本集有助于减少此类事件,从而减少重试。
虽然这是使用 Spring 中的 Reactor 项目实现的示例,但所有主要框架和语言都提供了替代方案。
- 如果您使用 Spring 框架但不使用响应式编程,请使用Spring Retry 。
- 当您使用 Akka 和 Scala 或 Java 时的主管策略。
scala.util.{Failure, Try}
如果您在没有任何框架的情况下使用 Scala。- 在 Python 中重试装饰器。
- JavaScript 中的获取重试。
我确信这不是一份详尽的清单。这种模式可以解决短暂的网络故障。但如果持续停电怎么办?比本文后面的内容更多。
最后已知的好版本
如果被调用的服务持续崩溃并且各个客户端的所有重试都耗尽怎么办?我更喜欢回退到最后一个已知的好版本。有几种策略可以在基础设施和客户端上启用"最后已知的良好版本"策略。我们将简要介绍其中的每一个。
部署: 从基础设施的角度来看,最简单的选择是重新部署到服务的最后一个已知的稳定版本。这是假设下游应用程序仍然兼容调用这个旧版本。在 Kubernetes 中更容易做到这一点,它保存了之前的部署修订。
在下游缓存:另一种方法是客户端保存最后一个成功的响应,以便在服务出现故障时可以依靠;在浏览器或移动用户界面上向最终用户显示与过时数据相关的提示是一个不错的选择。
下游缓存
浏览器或任何客户端都会不断地将数据写入内存存储,直到收到来自上游的心跳。该机制为通过 gRPC 或 REST 进行服务调用的 UI 和无头客户端提供了各种实现。无论客户类型如何,这里都总结了应该做什么。
- 客户端在其第一个 API 调用上进行注册,以便服务进行跟踪。
- 对客户端的后续更新作为从服务到客户端的推送进行管理。
- 客户端在本地保留状态,浏览器上的 Redux,或无头客户端的 Redis、Memcached(如果您的灵魂允许的话,也可以使用 LinkedHashMap)。
- 如果您的规模不足以承担推送的费用,您可以使用 ReactJS 的 RTK 和 Angular 的 NgRx 存储等工具,并不断拉取状态更新。当您收到任何 5XX 状态错误时,请务必告知最终用户他们可能会看到过时的数据。
持续中断的模式
如果任何分布式架构都是一个只有点的系统,那我们就很幸运了,但事实并非如此。因此,我们必须构建我们的系统来处理长期中断。以下是一些在这方面有帮助的模式。
隔离壁
隔离壁是为了应对由上游服务的缓慢导致的故障。虽然理想的解决方案是解决上游问题,但这并不总是可行的。设想一个场景,你调用的服务(X)依赖于另一个表现出缓慢响应时间的服务(Y)。如果服务(X)遭受大量的流入流量,那么它的很大一部分线程可能会等待较慢的上游服务(Y)响应。这种等待不仅会减慢服务(X)的速度,而且还会增加请求丢失的速度,导致客户端更多的重试并加剧瓶颈现象。
为了减轻这个问题,一个有效的方法是局部化失败的影响。例如,您可以为调用较慢的服务创建一个有限线程数的专用线程池。这样做可以将缓慢和超时的影响局限于特定的API调用,从而提高整体服务的吞吐量。
断路器
可以轻松避免断路器;我们必须编写永远不会宕机的服务!然而,现实情况是我们的应用程序经常依赖于其他人开发的外部服务。在这些情况下,断路器作为一种模式就变得非常有价值。它通过代理在服务之间路由所有流量,一旦达到定义的故障阈值,代理就会立即开始拒绝请求。事实证明,这种模式在外部服务长时间网络中断期间特别有用,否则可能会导致呼叫服务中断。尽管如此,确保在此类场景中提供无缝的用户体验至关重要,我们发现两种有效的方法:
- 通知用户受影响区域发生中断,同时使他们能够使用系统的其他部分。
- 允许客户端缓存用户事务,提供"202 Accepted"响应,而不是像往常一样的"200"或"201",并在上游服务再次可用时恢复这些事务。
结论
尽管云提供商致力于高可用性,但由于这些网络的规模庞大且不可预测,网络故障仍然不可避免,这凸显了对弹性系统的迫切需求。这段旅程让我们沉浸在分布式计算领域,挑战我们作为工程师,用容错和弹性策略武装自己。采用重试、最后已知的良好版本策略等技术,以及开发两端都有状态管理的独立客户端-服务器架构,使我们能够应对网络中断的不可预测性。
当我们探索错综复杂的分布式系统时,采用这些策略对于确保流畅的用户体验和系统稳定性至关重要。欢迎来到云中微服务的世界,这里的挑战激发创新,而弹性是我们应对不可靠网络的基石。
作者:Anadi Misra
更多技术干货请关注公号【云原生数据库】
squids.cn,云数据库RDS,迁移工具DBMotion,云备份DBTwin等数据库生态工具。