9 反脆弱系统
# 9 反脆弱系统
纳西姆·塔勒布(Nassim Taleb)在《反脆弱》一书中讨论了复杂系统中的行为,并将其分为三类:
- 脆弱的(Fragile):这类系统在承受中等程度的压力时就会崩溃。
- 稳健/有弹性的(Robust/Resilient):这类系统在应对压力方面比脆弱的系统更好,但仍然容易受到低概率故障的影响。
- 反脆弱的(Antifragile):这类系统具有最强的适应性,在压力下反而会变得更强大。人体就是一个例子,当受到适当程度的压力时,肌肉和骨骼会变得更强壮:
(来源:https://developers.redhat.com/blog/2016/07/20/from-fragile-to-antifragile-software/ )
软件系统在日常生活中起着至关重要的作用,消费者期望系统始终保持运行。架构师花费大量精力来确保系统的可靠性和容错性,容错性是指系统在部分组件出现故障时仍能保持运行的能力。如果无法满足这些期望,对企业来说将是沉重的打击。系统每停机一分钟,就会造成收入损失,更不用说给客户留下负面印象了。
故障可能由于各种原因出现,它们不仅可能由编码错误(如内存泄漏)导致,还可能由基础设施问题(如磁盘故障)引起。现代软件系统常常相互依赖,并依赖外部实体,因此设计可靠的系统变得更加困难。
通常情况下,系统在压力下往往无法达到预期性能。本章将探讨如何构建在压力下仍能良好运行的反脆弱系统,我们将涵盖以下主题:
- 可靠性指标
- 可靠性工程——确保高可用性的架构模式
- 可靠性验证——通过单元测试、集成测试、负载测试和混沌测试确保系统的弹性
- 构建依赖项的弹性
- 数据中心的弹性
让我们首先更正式地了解一下可靠性的含义,然后探讨如何使服务具备弹性,接着研究一组系统及其依赖项,以及如何在整个产品中设计反脆弱性。
# 可靠性指标
电气与电子工程师协会(IEEE)将软件可靠性定义为在给定环境中,软件在特定时间段内无故障运行的概率。要在软件中构建可靠性,首先需要定义衡量可靠性的指标,这样我们才能了解当前状态,并衡量任何变更的效果。对于软件系统中的可靠性,不同的人有不同的观点:
- 符合需求
- 系统是否真正实现其目标
- 用户满意度
在考虑系统的可靠性以及如何提升可靠性时,综合考虑这些观点是很有帮助的。有多种指标可用于衡量软件系统的可靠性,大致可分为动态指标和静态指标。
# 动态指标
动态指标具有以下特点:
- 平均故障间隔时间(Mean Time To Failure,MTTF):MTTF被定义为连续两次故障之间的时间间隔。MTTF为200意味着预计每200个时间单位会出现1次故障。
- 可用性(Availability):衡量服务无故障运行的时长。可用性为0.995意味着在每1000个时间单位中,系统可能有995个时间单位处于可用状态。它是考虑计划内和计划外停机时间后,系统可供使用的时间百分比。如果一个系统在100小时的运行时间内平均停机4小时,那么它的可用性就是96%。
- 服务水平协议(Service-level agreements,SLA):这些是对系统性能的定义。例如API响应延迟,客户期望对各种API的响应时间上限有一个保证。
- 稳健性(Robustness):系统对意外输入、场景和问题的容忍程度。
- 一致性和精确性(Consistency and precision):软件的一致性程度以及给出精确结果的能力。
- 客户满意度/净推荐值(Customer satisfaction/Net Promoter Score,NPS):这是所有指标中最重要的一个,它定义了系统实现其目标的程度。通常通过各种客户调查方法,采用5分制或10分制来获取客户对系统整体可靠性和质量的满意度。
# 静态指标
这些指标通常用于衡量代码质量,并反映代码的可靠性。一般来说,静态指标包括以下内容:
- 圈复杂度(Cyclomatic complexity):这是对程序复杂度的一种量化度量。它是根据程序源代码中线性独立路径的数量得出的。通常,复杂的程序更难保证其可靠性。
- 缺陷数量和缺陷率(Defect amount and rate):生产系统中存在多少未解决的缺陷?每100行代码会产生多少个错误?
- 评审/质量控制拒绝次数(Review/QC rejects):这个指标定义了代码评审人员或质量控制人员拒绝代码签入的次数。它是衡量开发人员编写代码质量的一个很好的指标。
- 可测试性(Testability):测试系统并确保其执行预期功能所需的工作量。
- 可维护性(Maintainability):在常规维护过程中定位和修复错误所需的工作量。
在了解了这些背景知识后,让我们来看看现代微服务架构中可靠性的多个方面。
# 可靠性工程
回顾一下第5章 “走向分布式”,我们了解到微服务之间通过网络使用API或消息传递进行交互。其基本思想是,微服务使用特定协议,以标准化格式在网络上交换数据,从而实现宏观行为并满足需求。但在这个过程中,有很多地方可能会出错,如下图所示:
上图描述如下:
- 服务可能在处理客户端请求期间或空闲时出现故障。服务故障可能是由于机器故障(硬件/管理程序错误),也可能是代码中出现未捕获的异常。
- 存储持久数据的数据库可能会出现故障。持久存储可能会损坏,数据库甚至可能在事务进行过程中崩溃!
- 服务可能会生成一个内存中的任务,向客户端回复OK,然后出现故障,导致任务的所有引用丢失。
- 服务可能从消息代理中消费了一条消息,但在处理之前就崩溃了。
- 两个服务之间的网络连接可能会中断或变慢。
- 依赖的外部服务可能开始变慢或抛出错误。
系统的可靠性是在多个层面构建的:
- 单个服务按照规范构建并能正确运行。
- 服务部署在高可用性设置中,以便备份/备用实例可以替代出现故障的实例。
- 架构使单个服务的组合具备容错能力和稳健性。
我们将在 “依赖项和依赖项弹性” 部分讨论依赖项管理。对于其余部分,我们将在以下小节中介绍可靠性工程。
# 稳健的服务
弹性架构的基石是服务本身。如果服务的构建不能保证其持久性,那么其他方面的努力也收效甚微。构建稳健的服务涉及两个方面:
- 服务按照预期规范构建。这部分内容将在 “可靠性验证” 部分介绍。
- 服务不包含任何本地状态。
正如我们在第4章 “应用程序的扩展” 中看到的,无状态计算是实现可扩展性的关键。从构建系统弹性的角度来看,不具有本地状态也非常重要。无状态应用程序可以部署在由冗余服务实例组成的集群中,任何请求都可以由任何可用实例处理。另一方面,如果服务设计为具有本地状态,那么一个实例的故障可能会导致系统级的中断(尽管可能只影响部分客户)。本地状态常见的产生方式是在内存中存储缓存或用户会话数据。为了实现弹性,这种状态应该外部化,比如存储在像Redis这样的存储中(第8章 “数据建模” 详细介绍了Redis的弹性)。
在对单个服务有了这些期望之后,架构师可以从系统全局的角度确保系统由多个服务组成,并且每个服务的多个实例本身都是具有弹性的。
# 高可用性
你会让服务仅在一台机器上运行并投入使用吗?当然不会!机器故障或服务器磁盘故障都可能导致整个服务瘫痪,影响客户使用。这台机器就成为了单点故障(Single Point of Failure,SPOF):
(来源:http://timkellogg.me/blog/2013/06/09/dist-sys-antipatterns )
可以通过设计冗余来消除单点故障,即拥有多个服务/资源实例。冗余可以通过两种模式来构建:
- 主动模式(Active Mode):如在服务级可靠性工程中所述,如果服务是无状态的,通过拥有多个实例很容易实现冗余。如果一个实例发生故障,负载/流量可以转移到另一个健康的实例上。我们将在 “路由和健康检查” 部分了解具体的实现方式。
- 备用模式(Standby Mode):对于有状态的资源(如数据库),仅仅拥有多个实例是不够的。在这种模式下,当一个资源发生故障时,需要通过一个称为故障转移(failover)的过程在备用实例上恢复功能。这个过程通常需要一些时间,以便备用实例获取状态/内容,但在此期间服务会不可用。可以通过预先启动备用资源但使其处于休眠状态,并在活动实例和备用实例之间共享状态/上下文,来尽量缩短这段时间。
当一个系统能够承受单个组件(服务器、磁盘、网络链路)的故障时,就称其具有高可用性。仅仅运行多个实例不足以构建容错能力。系统高可用性的关键在于单个实例的故障不会导致整个系统崩溃。这就要求将请求可靠地路由到健康的实例,避免将生产流量发送到不健康的实例,从而保证整个服务的正常运行。
为了检测故障,首先需要确定一种衡量健康状况的方法。健康状况既与主机(或实例)级别相关,也与整体服务级别相关。通常,一个服务会部署多个实例,这些实例由负载均衡器(Load Balancer,LB)支持的虚拟IP(Virtual IP,VIP)进行管理。负载均衡器应该只将请求路由到健康的服务实例,但它如何知道实例的健康状况呢?通常,会定期向服务上指定的URL(/health)发送健康检查ping请求。如果实例返回正常响应,就表明它是健康的,否则应该将其从负载均衡器为该虚拟IP维护的实例池中移除。这些检查会在后台定期运行。
许多开发人员确实会设计/health URL,但将响应硬编码为200 OK,这并不是一个好主意。理想情况下,服务实例应该收集有关服务中各种操作和错误的指标,健康响应处理程序应该分析这些指标,以衡量实例的健康状况。
网络健康状况通常由网络协议(如IP和TCP)进行监控和维护。它们会找出冗余链路中的最佳路由,并处理数据包丢失、乱序或重复等故障。
本节假设采用服务器端实例发现方式。正如我们在第5章 “走向分布式” 中看到的,客户端服务发现也是可行的。它有自己的高可用性解决方案,但这些超出了本书的范围。
# 消息传递
可靠的消息传递平台是在微服务架构中构建数据管道的关键。一旦消息传递解决方案(代理)接收到消息,它应保证以下几点:
- 单个代理实例的丢失不会影响消费者获取消息的可用性。
- 如果生产者和消费者出现故障/重启,某些传递语义会得到保障。通常,这些语义如下:
- 至少一次传递(At-least-once delivery):消息系统保证消息至少被传递给消费者一次。有可能会收到重复的消息,消费者负责对消息进行去重。
- 至多一次传递(At-most-once delivery):消息传递解决方案保证消息至多被传递一次。有些消息可能会丢失。
- 恰好一次传递(Exactly-once delivery):保证每条消息对每个消费者都恰好传递一次。一般来说,如果没有某种消费者与代理之间的协调机制,很难实现这种保证。
为了构建可靠性,一种关键的消息传递设计模式是竞争消费者模式(competing consumers pattern)。多个并发的消费者可以从同一主题消费消息,从而实现冗余,如下图所示:
除了具备弹性和可用性优势外,这种模式还能使系统以更高的吞吐量运行,并提高可扩展性(因为可以根据需求增加或减少消费者的数量)。
基于消息的交互的另一个优点是队列所带来的缓冲作用。在基于API的交互中,消费者必须以生产者发送请求的相同速率来消费请求。在一个复杂的架构中,要实现所有生产者和消费者之间的这种速率匹配可能很困难。消息队列在生产者和消费者之间起到缓冲作用,这样消费者就可以按照自己的节奏工作。队列还可以缓解间歇性的高负载,否则这些高负载可能会导致故障。
第6章“消息传递”详细介绍了消息传递架构和相关的弹性模式。
# 异步计算模式
考虑一个典型的API流程序列:
- 客户端调用服务:POST /dosomework。
- 服务启动一个协程来处理API请求。
- API处理过程较为复杂,需要一些时间。处理程序还需要调用外部依赖(DependencyI)来完成工作。
- 客户端等待服务完成工作。
这里可能会出现什么问题呢?可能会有很多问题!例如:
- 客户端与服务之间的互连网络可能会出现中断。客户端的套接字将被关闭,并且很可能会重试。如果通信是通过互联网/广域网进行的,这种情况尤其常见。
- 如果客户端重试,服务可能已经在处理 /dosomework 的过程中取得了进展。可能已经创建了数据库条目、预订了酒店等等。服务需要确保这种处理是幂等的!
- DependencyI可能出现故障,或者更糟糕的是,响应时间很长。在这种情况下,如果客户端重试,DependencyI也需要是幂等的。
- 由于 /dosomework 需要一些时间,并且客户端在等待响应,因此在操作进行期间,处理请求的Web服务需要专门分配资源。
机器和网络经常会出现故障。软件架构对这类故障具备弹性,并提供效率和一致性保证非常重要。解决这个问题的一种方法是采用异步架构。服务可以将“某某客户端需要 /dosomework” 记录(在持久存储中)下来,并返回一个作业ID。然后,一组后台工作者可以获取这个作业并完成它。客户端可以通过一个单独的URL来查看作业的进度。这种架构模式如下图所示:
像Kafka(第6章“消息传递”有详细介绍)这样的消息传递系统非常适合实现这种记录作业的模式。
杰夫·巴尔(Jeff Barr)在其白皮书中描述的用于AWS的“grep-the-web”示例架构(https://aws.amazon.com/blogs/aws/white-paper-on/),就是这种架构的一个详细示例:
(来源:https://aws.amazon.com/blogs/aws/white-paper-on/)
这个问题的目标是构建一个解决方案,对来自网络的数百万文档运行正则表达式,并返回匹配查询的结果。该操作旨在长期运行,并涉及多个阶段。此外,假设部署在云端是弹性的,即机器(虚拟机(VMs))可能在无人察觉的情况下出现故障。如上图所示,该解决方案架构包括以下部分:
- 启动控制器(Launch Controller):这个服务接收一个grep作业,并启动/监控管道中的其他服务。实际的grep操作由使用Hadoop的MapReduce作业完成。
- 监控控制器(Monitoring Controller):它监控MapReduce作业,更新状态数据库中的状态,并写入最终输出。
- 状态数据库(Status DB):所有服务在这个数据库中更新每个作业的当前阶段、状态和指标。
- 计费控制器(Billing Controller):一旦作业被调度,它也会通过计费队列和计费控制器进行计费设置。这个服务知道如何为每个作业向客户计费。
- 关闭控制器(Shutdown Controller):一旦作业完成,监控控制器会在关闭队列中放入一条消息,这会触发关闭控制器在作业结束后进行清理工作。
该架构的一些显著特点如下:
- 该架构遵循异步设计模式。
- 系统能够容忍机器故障。作业可以从故障的阶段继续进行。
- 服务(控制器)之间没有耦合。如果需要,可以通过插入新的队列和控制器来扩展功能,而且可以高度确信当前的处理过程不会中断。
- 作业的每个阶段(控制器)都可以独立扩展。
# 编排器模式
“grep-the-web”架构是编排器模式(orchestrator pattern)的一种实现。这种模式通常用于应用程序工作流,其中涉及许多步骤,有些步骤可能很耗时,或者可能需要访问远程服务。各个步骤通常相互独立,但需要通过一些应用程序逻辑进行编排。
该解决方案包含以下角色:
- 调度器(Scheduler):编排器设置要执行的各个步骤的工作流图,并启动处理过程。这个组件负责启动代理(Agent),并为每个代理传递所需的任何参数。
- 代理(Agent):这是运行每个步骤的容器,通常涉及调用外部服务来执行工作流中所需的步骤。它通常采用Hystrix模式(见“依赖弹性”部分),以确保不受外部服务不确定性的影响。通常每个步骤会启动一个代理。
- 监督器(Supervisor):它监控正在执行的各个步骤的状态。它还确保任务运行完成,并协调处理可能出现的任何故障。这个服务会定期记录工作流的状态,比如尚未开始、步骤X正在运行等。它还可以为各个步骤计时,以确保它们在一定时间内完成。如果它检测到任何代理超时或失败,它会安排该代理执行回退操作。需要注意的是,实际的回退操作必须由代理实现,监督器只是请求执行该操作。
通常,这些组件之间通过消息队列进行通信。通常,调度器和监督器的角色在一个编排器组件中实现。
# 补偿事务模式
在这样的场景中会出现一个问题,假设服务希望具有某种事务语义,即要么所有服务都成功完成,要么都不完成。为了简化问题,假设不需要隔离性(即并发操作不会看到中间状态)。这是复杂工作流(比如我们旅游网站上的预订完成流程)中的常见用例,其中需要执行多个操作,并且可能会对不同的数据存储进行修改。在这种场景下,由于环境的分布式特性和规模导致的竞争,提供强一致性语义是不可扩展的,因此架构采用异步模式。在这种最终一致性模型中,在执行这些阶段时,系统状态的总体视图可能是不一致的。然而,当所有阶段完成后,所有服务和数据存储都是一致的。
那么,回到最初的问题——如果一个服务失败了会怎样?其他服务如何回滚它们的更新呢?敏锐的架构师应该会立刻想到以下几点:
- 为了遵循关注点分离原则,唯一能够处理撤销操作的组件是服务本身。
- 撤销架构不应给可扩展架构添加额外的限制。因此,中央撤销服务并不是一个好选择,因为这会违背分布式解决方案可由多个阶段组成的目的。
解决方案是让每个服务实现补偿事务(compensating transactions)。在执行补偿事务时,服务会回滚原始操作的影响。
回滚操作通常也通过队列来实现,但这里的消息是反向的。这种模式与克莱门斯·瓦斯特斯(Clemens Vasters)在其博客(http://vasters.com/clemensv/2012/09/01/Sagas.aspx)中提到的“事务协调模式(Sagas)”类似,如下图所示:
补偿事务消息通常会携带需要回滚的操作的标识符,并满足以下期望:
- 该标识符在所有服务中是通用的。
- 每个服务都知道如何进行撤销操作,并维护一个包含撤销参数的作业数据库。
需要注意的是,补偿事务逻辑不能简单地用操作前的状态替换当前状态,因为这可能会覆盖后续可能发生的其他操作。例如,在“grep-the-web”示例中,如果一个服务要维护请求数量,它不会像下面这样管理数据库:
Job ID | Count |
---|---|
job-id-123 | 44 |
相反,一种更好的存储参数以实现撤销的方式是记录作业所做的增量,在这种情况下可以是:
Job ID | Delta |
---|---|
job-id-123 | +2 |
这就是补偿事务模式中补偿的关键所在。
这种模式假设前滚和回滚步骤都是幂等的。消息传递解决方案通常实现至少一次传递语义,因此经常会出现重复消息。使用这种模式的服务在执行任何操作之前都应该对消息进行去重。
# 管道和过滤器模式
编排器模式的一个简化版本是管道和过滤器模式(pipes and filter pattern)。这种模式将大家熟悉的Unix中“通过智能管道连接简单服务”的范式扩展到了分布式系统。
这种模式将涉及复杂处理的任务分解为一系列单独的过滤器,这些过滤器通过称为管道的消息传递基础设施连接起来。通常,每个过滤器都是一个具有特定契约(关于期望的输入格式)的服务。这些过滤器可以被重用,这样多个任务就可以使用相同的过滤器来执行特定操作。这有助于避免代码重复,并且通过在组织范围内的过滤器库中添加或删除过滤器,很容易处理需求的变化。更多信息,你可以参考https://docs.microsoft.com/en-us/azure/architecture/patterns/pipes-and-filters:
# 热点问题
每个服务都有一个极限值,即其设计的最大负载。如果负载超过这个值,服务可能会变得不可靠。在微服务架构中,请求由多个协同工作的服务来完成。这可能会导致特定的公用服务出现热点问题,因为这些服务会被多个其他服务调用。一个典型的例子是用户账户服务,它是所有用户级数据的存储库。例如,在下图中,多个服务为了获取与用户相关的信息而调用用户账户服务,这使得该服务成为了热点:
解决这个问题的一种方法是在每次服务调用时携带所需的数据,如下所示:
这样做的代价是每次微服务调用时携带的数据量会增加。
# 边车模式(sidecar pattern)
许多服务需要辅助功能,如监控、日志记录或配置管理。一种选择是在主应用程序代码库中实现这些功能,但这样会带来一些问题:
- 违背单一职责原则。与服务职责不直接相关的需求变更会逐渐渗透到应用程序中。
- 任何一个组件出现的错误或崩溃都可能导致服务中断。
- 这些组件必须与主应用程序使用相同的语言和运行时环境。如果有其他形式的现成解决方案,也无法直接复用。
边车模式提出了另一种方案:将这些辅助任务与主服务部署在一起,但将它们托管在各自的进程或容器中,而不是与主应用程序在同一进程内。这个名称源于边车服务与主应用程序的部署方式,类似于摩托车的边车挂载方式。
使用边车模式的优点包括:
- 在运行时和编程语言方面与主应用程序相互独立,便于复用。
- 位置相邻:可以减少通信延迟,并高效共享文件等资源。
- 弹性:任何一个边车服务出现故障都不会导致主应用程序崩溃。
边车模式通常用于基于容器的部署,这些容器通常被称为边车/助手容器(sidecar/sidekick containers)。
# 限流
服务的负载会随着时间(如一天中的不同时段或一年中的不同时期)而变化,这是因为用户行为和活跃用户数量会有所不同。有时,流量可能会出现意外的突发或剧增。每个服务在构建和部署时都预设了特定的负载容量。如果处理的请求超过这个容量,系统就会出现故障。
解决高负载问题有两种方法:
- 当负载是真实需求时,我们增加容量(如服务器数量、服务实例数、网络容量、数据库节点等)以应对增加的流量。
- 当负载并非真实需求或对业务不关键时,分析并控制请求,即进行限流。
一些限流策略如下:
- 拒绝超出分配配额的单个用户的请求(例如,每秒对特定API的请求超过n次)。这要求系统为每个租户的每种资源使用情况进行计量。一种常见的限流实现方式是在负载均衡器层面进行。例如,Nginx使用漏桶算法(Leaky Bucket algorithm)来限制请求速率。限流通过两个主要指令进行配置:
limit_req_zone
和limit_req
。第一个参数定义了我们要限制的资源和限流规则。另一个指令用于location
块中,实际执行限流操作。更多详细信息可查看https://www.nginx.com/blog/rate-limiting-nginx/ 。
漏桶算法的目的是将变化的/突发的输入速率平滑化,以产生稳定的输出速率,从而避免超过目标资源的容量。从高层次来看,其实现可以看作是一个先进先出(FIFO)队列,用于存储传入的请求。在设定的时钟周期内,从队列中取出n个请求并发送到服务进行处理,这里的n是我们期望的目标输出速率。我们可以通过考虑每个请求的工作量估算等因素,对这个基本概念进行优化,而不是在每个时钟周期盲目地取出固定数量的请求。该算法如下图所示:
- 禁用或降低特定功能,以便在服务出现问题时进行优雅降级,而不是整个服务崩溃。例如,对于视频流服务,如果网络连接不佳,可以切换到较低分辨率。
- 使用基于消息的队列来分散负载,使计算异步化(延迟处理)。
# 版本控制
可靠性的最后一个方面是为微服务的持续演进进行设计。通常,服务有多个客户端,并且会受到重构的影响。在此过程中,服务所公布的契约(或规范)会发生变化。然而,并非所有客户端都能同时迁移到新的契约版本。API/契约的不当更新或弃用,是基于微服务架构中常常被忽视的导致不稳定和不可靠的原因。
保护客户端的方法是对API进行适当的版本控制。通常,这通过在API的URL中添加版本号来实现,如下所示:
/v1/hotels/add
/v1/hotels/search
/v1/hotels/<hotel_id>
/v1/hotels/<hotel_id>/book
...
2
3
4
5
当需要部署新的契约时,我们只需升级版本号,并将新的路由有效地部署到Web服务器和负载均衡器(LB)上。所有版本都必须由同一组Web服务器实例提供服务,负载均衡器可以将旧版本的请求路由到旧的部署,这样新代码就无需背负旧代码的包袱。
在下一节中,我们将研究可靠性验证以及其中涉及的测试类型。
# 可靠性验证
正如我们在第1章“使用Go语言构建大型项目”中看到的,服务契约定义了服务提供的各种操作,并对一组预期输入的输出进行了明确的定义。这有时也被称为规范(specification的缩写)。规范可能还包括非功能性需求,例如特定API的预期延迟预算以及服务保证响应时间的预期吞吐量。
下图展示了软件生命周期各个阶段修复缺陷的相对成本图表:
(来源:http://jonkruger.com/blog/2008/11/20/the-relative-cost-of-fixing-defects/ )
随着服务的变更,必须确保始终遵守契约。一般来说,契约可能会随着功能的增加而变化,但理想情况下应该保持向后兼容。以这种方式设计的服务能够构建稳健的架构,同时满足业务对功能迭代速度的要求。
验证服务质量属性的目标是首先确保其公布的所有契约(功能和非功能方面)都未被破坏。这种验证通过回归测试套件来完成。迈克·科恩(Mike Cohen)的测试金字塔图能很好地描述这个测试套件:
(来源:https://www.mountaingoatsoftware.com/blog/the-forgotten-layer-of-the-test-automation-pyramid )
金字塔底部的测试更多地关注代码结构,即确保各个模块按预期工作。而金字塔上层的测试则从用户的角度验证行为。
尽早发现服务中的错误的关键在于拥有一个回归测试套件,该套件可以尽早且频繁地运行,以确保服务质量。
回归测试套件的组成部分包括:
- 单元测试(Unit tests)
- 集成测试(Integration tests)
- 性能测试(Performance tests)
我们将在以下部分详细了解这些测试。
# 单元测试
单元测试的范畴是对服务的各个模块(类/函数)进行测试。通常,这会得到各种框架(大多数是特定于编程语言的框架)的支持。正如我们在第1章“使用Go语言构建大型项目”中所见,Go语言拥有一个非常强大的内置测试框架。此外,尽管Go是一种强类型语言,但诸如reflect
(reflect.DeepEqual
)这样的包,能让人对任意数据进行深度比较,例如比较预期结果和实际得到的结果。
单元测试需要两个辅助框架:
- 模拟/桩模块(Mock/Stub):在测试特定模块时,我们需要其他下游依赖组件来实现相关行为。有时,调用实际的组件可能并不可行(比如通过实际的邮件服务发送电子邮件就不合适,因为我们要避免给客户发送垃圾邮件)。这时,我们应该对依赖模块进行模拟或使用桩模块替代,这样就能通过各种不同的路径来测试代码。模拟和桩模块之间存在细微差别:模拟通常会依据特定输入来设定生成的输出内容;而桩模块只是提供预先设定好的答案。在Go语言中,正如我们在第2章“代码封装”中看到的,可以通过服务模拟或构建标志来实现这些操作。另一种方式是使用
go - mock
包(https://github.com/golang/mock_)。这个包会检查源代码,并为其生成模拟实现。 - 自动化(Automation):单元测试应该实现自动化,这样每次提交代码时都能运行,从而尽早发现并解决问题。Go语言的测试包(https://golang.org/pkg/testing/)为自动化测试提供了全面支持。
单元测试的运行速度应该非常快。在普通开发人员的机器上,几分钟内轻松运行数千个单元测试是可行的。将每个测试聚焦于小段代码并让它们独立运行,这一点很重要。
测试驱动开发(Test - driven development,TDD)鼓励将其提升到更高层次,即在编写代码之前先编写单元测试。单元测试能帮助开发人员在开发过程中发现遗漏的部分。
单元测试存在一个常见问题,即它们与被测试函数的实现紧密耦合。这意味着代码稍有变动,单元测试也需要随之修改。行为驱动开发(Behavior - driven development,BDD)测试试图解决这个问题,它将测试重点放在行为上,而非实现细节。BDD测试使用特定领域语言(Domain - specific language,DSL)编写,与其他类型的测试相比,它更接近英语散文风格。GoConvey(http://goconvey.co/)是一个用于Go语言BDD测试的优秀包。它基于Go语言的原生测试和覆盖率框架构建,增加了一层更具表现力的DSL来编写测试用例。它还提供了一个用户界面,以便更好地可视化测试结果:
# 集成测试
当一个服务在隔离环境中通过验证后,就应该在特定的集成环境或阶段环境中,结合其依赖项一起进行测试。服务很少会单独使用,这些测试旨在验证新构建的服务能否与其他依赖项协同工作。这里的测试用例应该处于系统层面,要确保所有服务共同协作,按照需求实现预期的行为。这种测试设置通常还会使用生产环境数据存储的副本。这是另一个可以在上线前发现错误的环节。
这里的测试边界通常是与用户界面(UI)进行交互的API。测试从调用这个门面(Facade)层的API开始,并验证端到端的行为。
由于这些测试在测试金字塔中处于较高层级,它们更关注与业务相关的交互。所以,单元测试更像是这样: 如果x为3,y为2,addTwo(x,y) 应该返回5
而集成测试则更像是下面这样: 假设用户已登录 并且用户点击酒店搜索列表中的 “x” 项 那么用户应该导航到 “x” 的产品详情页 当用户点击 “预订” 按钮 那么应该与后端进行价格核对 然后用户应该导航到预订页面
# UI测试
许多应用程序都有某种用户界面,通常是网页或移动应用。这组测试按照终端用户的使用方式来验证系统。从某种程度上说,这些测试与集成测试相关,但通常适用于直接与客户端(应用程序、网页等)交互的门面层服务。测试用例源自需求(用户故事),并尝试涵盖用户在实际应用中可能遇到的所有场景。这些测试通常在阶段环境中结合后端服务一起运行。
有许多框架,如Selenium,可帮助实现这些测试的自动化。由于这超出了本书的范围,我们在此不做详细介绍。
# 性能测试
性能测试的目标是确保产品满足非功能性的性能需求。这通常可以转化为以下几点:
- 延迟:应用程序应快速响应,满足针对各种操作的延迟服务水平协议(SLA)中的要求。
- 可扩展性:应用程序应能够处理规定的最大用户量,同时仍保持上述延迟特性。
- 稳定性:应用程序在不同的负载和负载变化情况下应保持稳定。
有多种类型的测试可确保实现上述目标:
- 负载/压力测试:这些测试检查应用程序在不同负载水平(每秒请求数)下的性能表现。其目的是测量在各种负载或压力条件下系统的延迟和稳定性。
- 容量测试:在容量测试中,会向数据库中填充大量数据,并监控整个软件系统的行为。目的是检查软件应用程序在不同数据库容量下的性能。这些测试可以发现数据库建模方面的问题,例如在经常查询的列上未创建索引。
- 可扩展性测试:可扩展性测试的目的是确定软件应用程序在扩展以支持增加的用户负载方面的有效性。它有助于规划软件系统的扩展。这些测试旨在识别扩展瓶颈,通常会测量处理请求的时间与负载的关系,如下图所示:
- 耐久性测试:进行此项测试是为了确保软件能够在较长时间内处理预期的负载。
- 尖峰测试:它测试软件在负载突然大幅增加时的行为。通常,急剧的负载变化可能会暴露出与耐久性测试(恒定负载)不同的问题。
下图展示了我们刚刚讨论的各种测试:
# 混沌工程
混沌工程(Chaos-engineering)是一种用于评估系统脆弱性,并构建相应机制帮助系统在混乱情况下生存的技术。它不主张等待问题在最糟糕的时候出现,而是主动注入或制造故障,以此来评估系统在这些场景下的表现。因此,灾难并非罕见事件,而是每天都可能发生!其目的是在弱点表现为意外异常行为之前识别出它们。这些弱点可能包括以下方面:
- 错误的回退设置(见“依赖弹性”部分)
- 因设置不当的超时时间导致的重试惊群问题
- 缺乏弹性的依赖关系
- 单点故障
- 级联故障
一旦通过适当的监测手段识别出这些弱点,就可以在它们影响生产环境中的客户之前进行修复。对实际灾难进行预演可以极大地增强对生产系统的信心。
网飞(Netflix)是混沌工程的先驱,其“猿猴军团”(Simian Army)工具套件提供了实现所需混沌场景的方法。这有助于推进以下混沌工程流程:
- 通过一组指标定义正常行为,这些指标可以是CPU/内存利用率、响应时间或错误率。
- 准备一个对照组(无混沌情况)和一个实验组(“猿猴军团”发挥作用的组)。假设是在两组中都能观察到正常行为。
- 通过模拟故障事件(如服务器崩溃、磁盘故障或网络分区)引入混沌。
- 验证对照组和实验组均表现出正常行为这一假设。
“猿猴军团”中的一些工具(“猴子”)如下:
- 混沌猴子(Chaos Monkey):随机停用生产实例,以确保冗余和容错机制按预期工作。
- 延迟猴子(Latency Monkey):在客户端服务之间以及服务之间引入人为延迟和网络分区。
- 合规猴子(Conformity Monkey):找出不符合最佳实践的实例并将其关闭。
- 医生猴子(Doctor Monkey):对每个实例进行健康检查,并回收不健康的实例。
- 管理员猴子(Janitor Monkey):搜索未使用的资源并进行处理,从而减少混乱。
- 安全猴子(Security Monkey):作为合规猴子的扩展,它会查找安全违规或漏洞(如配置不当的AWS安全组),并终止不符合规定的实例。该组件的另一个重要作用是确保所有SSL证书有效且无需立即更新。
- 混沌大猩猩(Chaos Gorilla):与混沌猴子类似,但它模拟整个亚马逊可用区的故障,从而验证跨地域的高可用性。更多详细信息可查看:https://github.com/Netflix/SimianArmy/。
# 依赖关系
服务很少孤立存在,它们存在上游和下游的依赖关系。每台服务器都会接收来自客户端(无论是用户界面还是其他服务)的请求,这些客户端期望服务遵守所宣传的契约并达到其服务水平协议。每个服务也有一系列下游依赖(其他服务),它依赖这些服务来完成工作。
为了理解依赖关系对整个系统可靠性的影响,让我们考虑正在构建的旅游网站中的酒店搜索服务。假设我们已经将其构建到了非常高的可靠性水平(如前一节所述),并且对客户端的正常运行时间要求为四个九(99.99% 的可用性)。现在,酒店搜索服务依赖于其他几个微服务,如定价引擎、目录服务和钱包服务,以显示酒店搜索结果。当酒店搜索服务收到请求时,它会调用所有这些下游依赖服务来获取满足请求所需的数据。而每个依赖服务反过来可能又有其他依赖,因此服务的依赖关系图可能很快就会变得很复杂。
在大型软件系统中,这样的依赖关系图可能会变得相当复杂,如下图所示:
图片来源:Appcentrica
在这样的系统中构建弹性意味着我们需要为依赖故障制定计划和缓解方案,以便能够应对这些故障。
# 故障扩散
依赖并非没有代价。即使所有单个服务都构建到了非常高的质量水平,整个系统的可靠性也会低于单个服务的可靠性。例如,如果酒店搜索服务依赖于其他5个服务(为简单起见,这里简化了依赖关系图),且自身的可用性为99.99%,那么整个酒店搜索功能的实际可用性为99.99%的5次方,约为99.94%。为了了解其影响,让我们看看99.99% 的正常运行时间在可用性方面意味着什么:
- 每天:8.6秒
- 每周:1分钟0.5秒
- 每月:4分钟23.0秒
- 每年:52分钟35.7秒
而可用性为99.94%时,情况如下:
- 每天:51.8秒
- 每周:6分钟2.9秒
- 每月:26分钟17.8秒
- 每年:5小时15分钟34.2秒
这意味着,仅仅有5个高质量的依赖服务,整个系统每年就会额外增加5小时的停机时间。
考虑到这种影响,让我们来看看缓解策略。但在此之前,服务依赖还会引发另一个有趣的问题。
# 级联故障
大多数软件系统开始时都很简单。它们被构建为一个单体应用程序,各个模块封装了不同的代码组件。所有包都链接在一个大的二进制文件中。一个典型的早期版本系统如下图所示:
它接收请求,并使用三个组件(模块/构建块)执行有价值的操作。这些交互如下图所示,图中的数字描述了满足请求时发生的事情的顺序。
然而,随着系统的发展和功能的增加,有时我们需要调用外部服务(一个依赖服务)。现在,这个外部服务可能由于多种我们无法控制的原因而失败,显然这会导致我们的应用程序请求失败:
但是,如果外部服务只是响应缓慢会发生什么呢?此时,客户端和原始服务中的所有资源都在等待请求完成,这将影响新的请求,而这些新请求可能甚至不需要这个出现问题的服务。这在Java或Tomcat等语言/运行时环境中最为明显,在这些环境中,每个请求实际上都会分配一个线程,如果客户端因请求缓慢而超时并重试,我们很快就会陷入如下情况:
随着复杂性的增加和功能需求的增多,团队决定将单体应用分解为微服务。但这却放大了问题!如下图所示:
如今的系统比以往任何时候都更加相互关联,随着微服务的出现,新的服务会定期涌现。这意味着整个系统将始终处于不断发展的状态,即持续变化中。此外,在当今快节奏的开发周期中,每天都会添加新功能,并且一天内会进行多次部署。然而,这种快速发展带来了更大的出错风险,特定服务中的一个故障可能会级联影响到依赖服务,导致系统的多个其他部分出现问题。
为了防范这种灾难,并构建反脆弱系统,架构师在设计这样的系统时需要应用特定的设计模式。下一节将详细介绍在这样的分布式系统中实现弹性的方法。
# 依赖弹性
构建弹性系统的关键在于明白,故障无法完全预防,我们应当从设计上做好应对故障的准备。保障系统的服务水平协议(SLAs)意味着要对依赖项可能出现的故障进行隔离。在设计过程中,我们需要做到以下几点:
- 对故障服务保持“友好”:如果依赖的服务出现故障,我们不应向其发送过多请求,以便它有时间恢复。
- 优雅降级:我们的客户端应当收到清晰、及时的错误信息。
- 提供监控和警报:我们应当像监控自身系统一样,监控依赖项的健康状况。
尽管这听起来颇具挑战性,但Netflix的优秀团队设计出了一套全面的解决方案,使应用程序能够具备这种弹性。它被称为Hystrix,接下来我们就对其展开讨论。
# Hystrix简介
Netflix注意到了上述问题,其工程团队开发了一系列设计模式(并以Java实现),命名为Hystrix,以此来解决这些问题。
其核心思想是将对依赖项的调用封装在Netflix提供的命令对象中。在Java中,这些命令会在主请求处理线程之外执行,并被委托给特定依赖项的线程池。这就实现了对请求的“舱壁隔离”,即如果依赖项X出现故障,只会阻塞分配给依赖项X的线程池中的所有线程。其他资源会得到隔离,不涉及依赖项X的其他请求仍可继续处理。
如下图所示:
(来源:Netflix博客)
一个响应缓慢或延迟不稳定的服务,比快速失败的服务更糟糕,因为前者在等待过程中会占用大量资源。为避免这种情况,Hystrix的一系列模式引入了“超时”概念。每个依赖项都会配置一个超时时间(通常是预期响应延迟的99.5%分位数 ),这样就为故障服务阻塞资源的时间设定了上限。如果在服务响应之前超时,就认定该依赖项出现故障。
接下来的部分将详细介绍Hystrix。
# Hystrix - 回退机制
当服务出现故障(返回错误或超时)时该怎么办?这是一个值得思考的问题。显然,原本所需的功能无法执行。Hystrix建议为每个依赖项配置回退机制。当然,回退机制的具体操作会因情况而异,取决于实际需求。不过,有一些通用策略:
- 备用服务:如果主服务端点出现故障,可以调用备用服务。例如,如果谷歌搜索服务不可用,或许我们可以调用必应搜索服务。
- 队列:如果操作需要依赖项执行一些工作,且工作的输出对于请求的完成并非必需,那么可以将请求放入持久队列,以便稍后重试。例如预订时发送电子邮件,如果电子邮件服务不可用,我们可以将发送邮件的请求放入类似Kafka的队列中。
- 缓存:如果需要依赖项的响应,另一种策略是缓存之前响应的数据。这种缓存方式称为防御性缓存。缓存键通常包含依赖服务标识符、该依赖服务上的API以及该API的参数。为防止缓存占用过多空间,可以采用最近最少使用(LRU,Least-Recently Used )等策略。当缓存空间不足时,会回收最近最少使用的缓存项。
# Hystrix - 断路器
Hystrix中的另一个重要模式是断路器。其核心思想是,当依赖资源不可用时,服务能够快速失败,而不是在依赖资源故障期间每次调用服务时都等待超时或报错。这个名称源于常见的电路在高负载下断开以保护内部资源的模式。当对某个依赖服务的请求开始出现故障时,Hystrix库会统计在一个时间窗口内的故障次数。如果故障次数超过阈值(在给定时间内的故障请求数量达到n次,或者请求耗时过长),针对该依赖项的断路器就会切换到“打开”状态。在这种状态下,所有请求都会失败。每隔一段配置好的时间,会放行一个请求(“半开”状态)。如果该请求失败,断路器会返回“打开”状态;如果请求成功,断路器会切换到“关闭”状态,操作恢复正常。简而言之,断路器可以处于以下状态:
- 关闭:涉及依赖项的操作可以正常进行。
- 打开:一旦检测到故障,断路器就会打开,确保服务对涉及该依赖项的请求进行短路处理,并立即响应。
- 半开:断路器会定期放行一个请求,以此来评估依赖服务的健康状况。
断路器的工作流程和回退交互如下图所示:
(参考:Netflix博客)
# Hystrix在Golang中的应用
在Go语言中,Java/Tomcat世界里请求占用线程的问题并不是太受关注,因为Web框架使用协程(goroutine)来处理请求,而不是专用线程。协程比线程轻量得多,因此“舱壁模式”所解决的问题在Go语言中没那么严重。不过,Hystrix的解决方案对于实现快速失败和断路器行为仍然至关重要。
在撰写本文时,Go语言中使用最广泛的Hystrix库是https://github.com/afex/hystrix-go/ 。下面是使用该库的“Hello World”示例代码:
import "github.com/afex/hystrix-go/hystrix"
errors := hystrix.Go("a_command", func() error {
// 与其他依赖服务进行交互
// 如果发生错误,返回该错误
return nil
}, func(err error) error {
// 在此处执行回退操作
return nil
})
2
3
4
5
6
7
8
9
10
调用hystrix.Go
类似于启动一个协程,在上述代码片段中,它会返回一个通道或错误(errors
)。这里,第一个闭包函数是命令对象,用于执行与依赖服务的实际交互。如果出现错误,会调用第二个闭包函数,并传入第一个命令函数返回的错误代码。
可以通过通道从命令中返回值,如下所示:
out := make(chan string, 1)
errors := hystrix.Go("a_command", func() error {
// 与其他依赖服务进行交互
// 如果发生错误,返回该错误
output <- "a good response"
return nil
}, func(err error) error {
// 执行回退操作
output <- "fallback here"
return nil
})
2
3
4
5
6
7
8
9
10
11
然后,客户端代码可以对output
和errors
进行select
操作,如下所示:
select {
case ret := <-out:
// 成功
// 处理返回值
case err := <-errors:
// 失败
// 处理失败情况
}
2
3
4
5
6
7
8
可以使用单独的API为每个命令配置设置,如超时时间和最大并发请求数,如下所示:
hystrix.ConfigureCommand("a_command", hystrix.CommandConfig{
Timeout: 1000,
MaxConcurrentRequests: 100,
ErrorPercentThreshold: 25,
})
2
3
4
5
CommandConfig
结构体定义如下:
type CommandConfig struct {
Timeout int `json:"timeout"`
MaxConcurrentRequests int `json:"max_concurrent_requests"`
RequestVolumeThreshold int `json:"request_volume_threshold"`
SleepWindow int `json:"sleep_window"`
ErrorPercentThreshold int `json:"error_percent_threshold"`
}
2
3
4
5
6
7
各种可调整的参数如下:
可调整参数 | 描述 |
---|---|
Timeout | 等待命令完成的时间,单位为毫秒。 |
MaxConcurrentRequests | 该命令可以同时运行的实例数量。 |
RequestVolumeThreshold | 在因健康状况触发断路器之前,必须发生的最小请求数。 |
SleepWindow | 断路器打开后,进入半开状态(测试恢复情况)之前等待的时间。 |
ErrorPercentThreshold | 导致断路器打开的错误数量百分比阈值。 |
# Hystrix监控
Hystrix提供了一套全面的解决方案,用于聚合每个服务的指标流,并对相关统计信息进行监控和可视化。有一些配套项目,如Turbine,它支持事件流聚合和仪表板功能,可在一个视图中监控各种命令,如下图所示:
(来源:https://github.com/Netflix/turbine/wiki)
Turbine的架构如下图所示:
使用hystrix-go
,很容易设置发送服务器发送事件(SSE,Server-Sent Events )JSON流的管道,这些JSON流用于传输统计信息。你只需要在一个端口上注册事件流HTTP处理器,并在一个协程中启动它。然后需要配置Turbine连接到这个端点,随后让Hystrix仪表板指向Turbine实例。完成这些设置后,就会开始显示如上图所示的图表和统计信息。下面是启动事件流处理器的示例代码:
hystrixStreamHandler := hystrix.NewStreamHandler()
hystrixStreamHandler.Start()
go http.ListenAndServe(net.JoinHostPort("", "81"), hystrixStreamHandler)
2
3
# 数据库级别的可靠性
如果持久化数据不能以弹性且一致的方式存储,那么系统的用处就不大。数据库有不同级别的一致性,第8章“数据建模”详细介绍了这个主题。
# 数据中心级别的可靠性
如果整个数据中心瘫痪会怎样呢?为应对这种可能出现的情况,你需要在多个数据中心运行应用程序集群,并确保两个部署的数据保持同步。构建这样的架构通常属于业务连续性规划(BCP,Business Continuity Planning )和灾难恢复(DR,Disaster Recovery )的范畴。
一种常见的做法是通过DNS在两个数据中心的部署之间进行切换。一个DNS名称,如www.mysite.com
,会解析为具有特定生存时间(TTL,Time-To-Live )的虚拟IP(VIP,Virtual IP )4.4.4.4。这个层级可以设计得很智能,在数据中心发生故障时,将DNS名称重新指向备用VIP,比如5.5.5.5。要实现这一点,需要在两个数据中心都进行部署,并且数据要在这些部署之间进行复制(通常是异步复制)。该方案如下图所示:
接下来的部分将介绍这些系统的一些特性。
# 一致性
在这样的分布式架构中,没有能确保一致性的万全之策。事实上,一致性有多种类型:
- 弱一致性(Weak):这是一种尽力而为的一致性。不保证数据的持久性。缓存存储就是一个例子,它们很少在多个数据中心之间进行复制。适合这种模式的应用程序包括视频流和网络电话(VOIP,Voice over Internet Protocol)。
- 最终一致性(Eventual):在这种情况下,系统最终会达到一致状态。然而,在某些特定时刻,每个数据中心可能会存在旧数据。域名系统(DNS,Domain Name System)更改传播、简单邮件传输协议(SMTP,Simple Mail Transfer Protocol)以及亚马逊S3都是使用这种模式的应用程序示例。采用这种一致性模型,无法保证写入后立即进行的读取操作能看到新值,但最终所有部署都会获取到新值,并且读取操作将在各个地方保持一致。
- 强一致性(Strong):这是最高级别的一致性。无论在何处进行读取操作,都能立即反映已提交的写入内容。事务系统通常需要这些保证。
在设计解决方案时,我们还需要考虑三个关键的设计目标:
- 恢复时间目标(Recovery-Time Objective,RTO):灾难发生后恢复操作应花费的最长时间目标。这是特定功能系统停机时,从业务损失角度来看可接受的时间。
- 恢复点目标(Recovery-Point Objective,RPO):灾难发生后可接受的数据丢失量。在大多数灾难恢复(DR,Disaster Recovery)情况下,数据丢失并非完全可以避免,但这个针对特定功能的指标表明了数据丢失对该功能的业务影响。
- 操作延迟(Operation latency):某个功能中各种操作可接受的延迟时间。
为了权衡这些目标之间的关系,我们考虑一个简单的功能,即API存储将更新内容写入数据库中的用户配置文件。为了实现灾难恢复弹性,这个更新需要复制到备份数据中心或其他数据中心。这里有几种选择:
- 仅当在主数据中心和远程数据中心都完成写入操作后,才向客户端返回“成功”(OK)。这会带来强一致性行为,并将恢复点目标(RPO)降至最低。然而,跨数据中心写入可能需要很长时间,如果采用这种方式,操作延迟将显著增加。
- 当在主数据中心写入成功时,就向客户端返回“成功”(OK)。使用诸如MySQL二进制日志复制等机制,确保异步复制到远程数据中心。在这种情况下,写入操作完成得很快,但是有可能在异步写入在远程数据中心生效之前,数据中心就发生故障了。
- 系统可以不在远程数据中心进行完整写入,而是快速记录写入信息(并记录在事务日志中),然后向客户端返回“成功”(OK)。这种方式下,操作延迟不像选项A那样受到很大影响,恢复点目标(RPO)的保证也不错。但是,系统在恢复为主用状态时,可能需要更多时间来重放日志以达到一致状态,从而增加了恢复时间目标(RTO)。
因此,需要单独审视每个资源、功能或服务,并提出问题,以确定恢复点目标(RPO)、恢复时间目标(RTO)和操作时间之间的相对重要性。这些问题可能采用以下形式:
- 客户能容忍10分钟前的数据吗?
- 在我们构建数据一致性时,服务停机5分钟可以接受吗?
根据答案,设计会朝着提高性能或一致性的方向发展。
# 路由和切换
当灾难发生时,我们需要将流量路由到备份数据中心。通过负载均衡器和多个实例进行健康检查的服务级别可靠性模式在此处并不适用。一种更具可扩展性的选择是使用基于域名系统(DNS)的故障转移。快速回顾一下,域名系统(DNS)通过其记录将统一资源定位符(URL,Uniform Resource Locator)映射到虚拟IP(VIP,Virtual IP,用于负载均衡器)。一个URL对应多个虚拟IP(VIP)是可行且常见的,通常会采用诸如轮询路由等策略;域名系统(DNS)名称服务器每次收到解析请求时会提供不同的虚拟IP(VIP)。这种方式可以进一步扩展,使域名系统(DNS)服务监控每个数据中心虚拟IP(VIP)的健康状况,如果某个实例被判定为不健康,则将其从记录集中移除。如果系统部署在云环境(如亚马逊网络服务AWS,Amazon Web Services)中,这种方法效果特别好,因为AWS有内置的域名系统(DNS)服务器,如Route 53。它不仅可以从“/health”端点监控健康状况,还可以从各个服务的日志和指标进行监控。
我们将在第11章“部署规划”中更详细地探讨部署拓扑。
# 总结
在本章中,我们探讨了构建弹性系统的各个方面,从强化单个服务到构建高可用性。所有这些方面需要协同工作,才能使系统在压力下正常运行。
在下一章中,我们将研究一个案例,并构建一个端到端的旅游网站!