3.2 使用分布式系统

当我们开始构建由微服务组成的分布式系统时,我们还会遇到在开发单体应用时通常不会遇到的非功能性要求。有时,使用物理定律就可以解决这些问题,例如一致性、延迟和网络分区问题。然而,脆弱性和易控性的问题通常可以使用相当通用的模式来解决。在本节中,我们将介绍帮助我们解决这些问题的方法。

这些方法来自于 Spring Cloud 项目和 Netflix OSS 系列项目的组合。

版本化和分布式配置

在“12 因素应用“中我们讨论过通过操作系统级环境变量为应用注入对应的配置,强调了这种配置管理方式的重要性。这种方式特别适合简单的系统,但是,当系统扩大后,有时我们还需要附加的配置能力:

  • 为调试一个生产上的问题而变更运行的应用程序日志级别
  • 更改 message broker 中接收消息的线程数
  • 报告所有对生产系统配置所做的更改以支持审计监管
  • 运行中的应用切换功能开关
  • 保护配置中的机密信息(如密码)

为了支持这些特性,我们需要配置具有以下特性的配置管理方法:

  • 版本控制
  • 可审计
  • 加密
  • 在线刷新

Spring Cloud 项目中包含的一个可提供这些功能的配置服务器。此配置服务器通过 Git 本地仓库支持的 REST API 呈现了应用程序及应用程序配置文件(例如,可用开 / 关切换的一组配置作为一组,如“deployment”和“staging”配置)(图 3 -1)。

Spring Cloud Config Server
Spring Cloud Config Server

例 3-1 是示例配置服务器的默认配置文件:

Example 3-1
Example 3-1

  1. 该配置中指定了后端 Git 仓库中的 application.yml 文件。

  2. greeting 当前被设置为 ohai。

例 3-1 中的配置是自动生成的,无需手动编码。我们可以看到,通过检查它的 /env 端点(例 3-2),greeting 的值被分发到 Spring 应用中。

Example 3-2
Example 3-2

  1. 该应用接收到来自配置服务器的 greeting 的值:ohai。

现在我们就可以无需重启客户端应用就可以更新 greeting 的值。该功能由 Spring Cloud 项目中的一个名为 Spring Cloud Bus 的组件提供。该项目将分布式系统的节点与轻量级消息代理进行链接,然后可以用于广播状态更改,如我们所需的配置更改(图 3-2)。该项目将分布式系统的节点与轻量级消息代理进行链接,然后可以用于广播状态更改,如我们所需的配置更改(图 3-2)。

只需通过对参与总线的任何应用程序的 /bus/refresh 端点执行 HTTP POST(这显然应该进行适当的安全性保护),指示总线上的所有应用程序使用配置服务器中的最新的可用值刷新其配置。

Figure 3-2
Figure 3-2

服务注册发现

当我们创建分布式系统时,代码的依赖不再是一个方法调用。相反,消费它们必须通过网络调用。我们该如何布线,才能使组合系统中的所有微服务彼此通信?

云中的(图 3-3)的同样架构模式是有一个前端(应用程序)和后端(业务)服务。后端服务往往不能直接从互联网访问,而是通过前端服务访问。服务注册提供的所有服务的列表,使它们可以通过一个客户端库到达前端服务(路由和负载均衡),客户端库执行负载均衡和路由到后端服务。

Figure 3-3
Figure 3-3

在使用服务定位器和依赖注入模式的各种形式之前,我们已经解决了这个问题,面向服务的架构长期以来一直使用各种形式的服务注册表。我们将采用类似的解决方案,利用 Eureka,这是一个 Netflix OSS 项目,可用于定位服务,以实现中间层服务的负载平衡和故障转移。为了使用 Netflix OSS 服务,Spring Cloud Netflix 项目提供了基于注释的配置模型,这大大简化了开发人员在开发 Spring 应用程序时对 Eureka 的心力耗费。

在例 3-3 中,只需简单得在代码中添加 @EnableDiscoveryClient 注释,应用程序就可以进行服务注册和发现。

Example 3-3
Example 3-3

  1. @EnableDiscoveryClient 开启应用程序的服务注册发现。

该应用程序就能够通过利用 DiscoveryClient 与它的依赖组件通信。例 3-4 是应用程序查找名为 PRODUCER 的注册服务的一个实例,获得其 URL,然后利用 Spring 的 RestTemplate 与之通信。

example-3-4
example-3-4

  1. 开启的 DiscoveryClient 通过 Spring 注入。
  2. getNextServerFromEureka 方法使用 round-robin 算法提供服务实例的位置。

路由和负载均衡

基本的 round-robin 负载平衡在许多情况下是有效的,但云环境中的分布式系统通常需要更高级的路由和负载均衡行为。这些通常由各种外部集中式负载均衡解决方案提供。然而,这种解决方案通常不具有足够的信息或上下文,以便在给定的应用程序尝试与其依赖进行通信时做出最佳选择。此外,如果这种外部解决方案故障,这些故障可以跨越整个架构。

云原生的解决方案通常将路由和负载均衡的职责放在客户端。Ribbon Netflix OSS 项目就是其中的一种。(图 3-4)

Figure 3-4
Figure 3-4

Ribbon 提供一组丰富的功能集:

  • 多种内建的负载均衡规则:

    • Round-robin 轮询负载均衡
    • 平均加权响应时间负载均衡
    • 随机负载均衡
    • 可用性过滤负载均衡(避免跳闸线路和高并发链接数)
    • 自定义负载均衡插件系统
  • 与服务发现解决方案的可拔插集成(包括 Eureka)

  • 云原生智能,例如可用区亲和性和不健康区规避

  • 内建的故障恢复能力

跟 Eureka 一样,Spring Cloud Netflix 项目也大大简化了 Spring 应用程序开发人员使用 Ribbon 的心力耗费。开发人员可以注入一个 LoadBalancerClient 的实例,然后使用它来解析应用程序依赖关系的一个实例(例 3-5),而不是注入 DiscoveryClient 的实例(用于直接从 Eureka 中消费)。

Example 3-5-1
Example 3-5-1

Example-3-5-2
Example-3-5-2

  1. 由 Spring 注入的 LoadBalancerClient。
  2. choose 方法使用当前负载均衡算法提供了服务的一个示例地址。

Spring Cloud Netflix 通过创建可以注入到 Bean 中的 Ribbon-enabled 的 RestTemplate bean 来进一步简化 Ribbon 的配置。RestTemplate 的这个实例被配置为使用 Ribbon(示例 3-6)自动将实例的逻辑服务名称解析为 instanceURI。

Example 3-6
Example 3-6

  1. 注入的是 RestTemplate 而不是 LoadBalancerClient。
  2. 注入的 RestTemplate 自动将 http://producer 解析为实际的服务实例的 URI。

容错

分布式系统比起单体架构来说有更多潜在的故障模式。由于传入系统中的每一个请求都可能触及几十甚至上百个不同的微服务,因此这些依赖中的某些故障实质上是不可避免的。

如果不进行容错,30 个依赖,每个都是 99.99% 的正常运行时间,每个月将导致 2 个小时的停机时间(99.99%^30=99.7% 的正常运行时间 = 2 小时以上的停机时间)。

——Ben Christensen,Netflix 工程师

如何避免这类故障导致级联故障,给我们的系统可用性数据带来负面影响?Mike Nygard 在他的 Pragmatic Programmers 中提出了几个可以觉得该问题的几个模式,包括:

熔断器

当服务的依赖被确定为不健康时,使用熔断器来阻绝该服务与其依赖的远程调用,就像电路熔断器可以防止电力使用过度,防止房子被烧毁一样。熔断器实现为状态机(图 3-5)。当其处于关闭状态时,服务调用将直接传递给依赖关系。如果任何一个调用失败,则计入这次失败。当故障计数在指定时间内达到指定的阈值时,熔断器进入打开状态。在熔断器为打开状态时,所有调用都会失败。在预定时间段之后,线路转变为“半开”状态。在这种状态下,调用再次尝试远程依赖组件。成功的调用将熔断器转换回关闭状态,而失败的调用将熔断器返回到打开状态。

Figure 3-5
Figure 3-5

隔板

隔板将服务分区,以便限制错误影响的区域,并防止整个服务由于某个区域中的故障而失败。这些分区就像将船舶划分成多个水密舱室一样,使用隔板将不同的舱室分区。这可以防止当船只受损时造成整艘船沉没(例如,当被鱼雷击中时)。软件系统中可以用许多方式利用隔板。简单地将系统分为微服务是我们的第一道防线。将应用程序进程分区为 Linux 容器,以便使用单个进程无法接管整个计算机。另一个例子是将并行工作划分为不同的线程池。

Netflix 的 Hystrix 应用了这些和更多的模式,并提供了强大的容错功能。为了包含熔断器的代码,Hystrix 允许代码被包含到 HystrixCommand 对象中。

Example 3-7
Example 3-7

  1. run 方法中封装了熔断器

Spring Cloud Netflix 通过在 Spring Boot 应用程序中添加 @EnableCircuitBreaker 注解来启用 Hystrix 运行时组件。然后通过另一组注解,使得基于 Spring 和 Hystrix 的编程与我们先前描述的集成一样简单(例 3-8)。

Example 3-8
Example 3-8

  1. 使用 @HystrixCommand 注解的方法封装了一个熔断器。
  2. 当线路处于打开或者半开状态时,注解中引用的 getProducerFallback 方法,提供了一个优雅的回调操作。

Hystrix 相较于其他熔断器来说是独一无二的,因为它还通过在其自己的线程池中操作每个熔断器来提供隔板。它还收集了许多关于熔断器状态的有用指标,其中包括:

  • 流量

  • 请求率

  • 错误百分比

  • 主机报告

  • 延迟百分点

  • 成功、失败和拒绝

这些 metric 会被发送到事件流中,然后被 Netflix OSS 项目中的另一个叫做 Turbine 的组聚合。每个单独的和聚合后的 metric 流都可以在强大的 Hystrix Dashboard(图 3-6)中以可视化的方式呈现,该页面提供了很好的分布式系统总体健康状态的可视化效果。

Figure 3-6
Figure 3-6

API 网关 / 边缘服务

在“移动应用和客户端多样性”中我们探讨过服务器端聚合与微服务生态系统。为什么有这个必要?

延迟

移动设备通常运行在比我们家用设备更低速的网络上。即使是在家用或企业网络上,为了满足单个应用屏幕的需求,需要连接数十(或者上百)个微服务,这样的延迟也将变得不可接受。很明显,应用程序需要使用并发的方式来访问这些服务。在服务端一次性捕获和实行这些并发模式,会比在每一个设备平台上做相同的事情,来得更廉价、更不容易出错。

延迟的另一个来源是响应数据的大小。在 Web 服务开发领域,近年来一直趋向于“返回一切可能有用的数据”的做法,这将导致响应的返回数据越来愈大,远远超出了单一的移动设备屏幕的需求。移动设备开发者更倾向于通过仅检索必要的信息而忽略其他不重要的信息,来减少等待时间。

往返通信

即使网速不成问题,与大量的微服务通信依然会给移动应用开发者造成困扰。移动设备的电池消耗主要是因为网络开销造成的。移动应用开发者尽可能通过最少的服务端调用来减少网络的开销,并提供预期的用户体验。

设备多样性

移动设备生态系统中设备多样性是十分巨大的。企业必须应对不断增长的客户群体差异,包括如下这些:

  • 制造商

  • 设备类型

  • 形式因素

  • 设备尺寸

  • 编程语言

  • 操作系统

  • 运行时环境

  • 并发模型

  • 支持的网络协议

这种多样性甚至扩大到超出了移动设备生态系统,开发者目前可能还会关注家用消费电子设备不断增长的生态系统,包括智能电视和机顶盒。

API 网关模式(图 3-7)旨在将客户端的这些需求负担从设备开发者转移到服务器端。API 网关仅仅是一类特殊的满足单个客户端应用程序的微服务(如特定的 iPhone App),并为其提供一个到后端的入口。每个请求同时访问数十(或数百)个微服务,汇总响应并转化,以满足客户应用的需求。在必要时,它们还进行协议转换(例如,HTTP 到 AMQP)。

Figure 3-7
Figure 3-7

API 网关可以使用任何支持 web 编程和并发模式的语言、运行时、框架,和能够目标微服务进行通信的协议来实现。热门的选择包括 Node.js(由于其反应式编程模型)和 Go 编程语言(由于其简单的并发模型)。

在这次讨论中,我们将坚持使用 Java,并给出一个 RxJava 例子,一个 Netflix 开发的 Reactive Extensions 的 JVM 实现例子。如果仅使用 Java 语言所提供的原生方法来组成多个工作或数据流是一个很大的挑战,而 RxJava 是一种致力于缓解这种复杂性的技术(还包括 Reactor)技术之一。

在这个例子中我们将创建一个类似 Netfilx 的网站,它可以在页面上展现一个电影目录,用户可以为这些电影创建评分并进行评论。而且,当用户在浏览某一个标题的页面时,页面上会给予用户相关推荐。为了提供这些能力,需要开发 3 个微服务:

  • 目录服务
  • 查看服务
  • 推荐服务

我们期望的该移动应用的服务的响应如例 3-9 所示:

Example 3-9
Example 3-9

例 3-10 中的代码利用了 RxJava 的 Observable.zip 方法来并发访问每个服务。在接到三个响应后,代码将它们传递给 Java 8 的 Lambada 表达式处理并生成一个 MovieDetails 实例。该实例可以被序列化并产生如例 3-9 中的响应。

figure 3-10
figure 3-10

这个例子仅涉及了 RxJava 所有可用功能的一些皮毛,读者可以在 RxJava 的 wiki 上查看进一步信息。