购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

第3章
分布式系统要点

正如第2章所述,扩展系统自然涉及添加多个独立的动态变化部分。我们在多台机器上运行软件组件,在多个存储节点上运行数据库,归根结底都是为了增加系统的处理能力。一言以蔽之,我们的解决方案分布在不同位置的多台机器上,每台机器并行处理事件,且它们之间通过网络交换消息。

分布式系统的基本特征对我们设计、构建和运维解决方案的方式有着深远的影响。本章将介绍分布式系统要点,它们是了解分布式软件系统的问题及其复杂性所需的基本信息,内容包括进行网络通信的硬件和软件、远程方法调用、如何处理通信故障的影响、分布式协作以及分布式系统中棘手的时间问题。

3.1 通信基础

每个分布式系统都包含通过网络进行通信的软件组件。例如,若移动银行应用程序请求用户的当前账户余额,则会出现以下(简化的)通信序列:

1.移动银行应用程序通过蜂窝网络向银行发送请求以检索用户的余额。

2.请求通过Internet路由到银行的Web服务器所在的位置。

3.银行的Web服务器对请求进行身份验证(确认它来自假定的用户)并向数据库服务器发送账户余额请求。

4.数据库服务器从磁盘读取账户余额并返回给Web服务器。

5.Web服务器将余额作为应用程序请求的响应消息返回,消息穿过互联网和蜂窝网络路由,直到余额神奇地出现在移动设备的屏幕上。

上述步骤听起来很简单,但实际操作上,一系列的通信隐藏着巨大的复杂性。我们将在后续内容中探索其中的复杂性。

3.1.1 硬件

上述银行余额请求示例中,必然会遍历多种不同的网络技术和设备。全球互联网就是一台异构机器,由不同类型的网络通信通道和设备组成,它们每秒将数百万条消息通过网络传送到预定目的地。

我们有不同类型的通信通道,最明显的分类是有线的与无线的。每种类别都有多种网络传输硬件技术可以将比特信号从一台机器传输到另一台机器。每种技术都有不同的特性,我们通常关心的是通信速率和范围。

对于有线网络,最常见的两种类型是局域网(LAN)和广域网(WAN)。局域网是可以连接“建筑物规模”以内设备的网络,能够在短距离(例如1~2 km)内传输数据。数据中心内的当代局域网可以每秒传输10~100 Gbps的数据,这便是网络带宽或容量。对于现代局域网技术来说,通过局域网传输消息所花费的时间(网络延迟)是亚毫秒级的。

广域网遍及全球,并且构成了我们统称为互联网的网络。长距离连接是由通过光纤电缆连接城市、国家和大陆的高速数据管道实现的。电缆支持波分复用( https://oreil.ly/H7uwR )网络技术,可以在400个不同的通道上传输高达171 Gbps的数据,对于单个光纤链路,可以提供每秒超过70 Tbps的总带宽。遍布全球的光纤电缆通常包含四股或更多股光纤,每根光纤电缆的带宽容量可达数百Tbps。

广域网的延迟更为复杂。广域网传输数据的距离可达数百乃至数千公里,数据在光纤电缆中传输的最大速度是理论光速。实际上,电缆无法达到光速,但确实非常接近光速,如表3-1所示。

表3-1:广域网速度

实际传输时间将比表3-1中的光纤传输时间慢,因为数据需要经过网络设备,我们称之为路由器。全球互联网是一个复杂的中心辐射型拓扑结构,网络中的节点之间存在许多潜在路径。而路由器负责在物理网络连接上传输数据,以确保数据在互联网内从源头传输到目的地。

路由器是专用高速网络设备,它可以处理数百Gbps的网络流量,从传入连接中提取数据并根据目的地将数据发送到不同的传出网络连接。位于互联网核心的路由器(也称骨干路由器)包含多台设备的机架,可以处理数十到数百Tbps(1 Tbps=1024 Gbps)的流量。这就是你和成千上万的朋友能同时在Netflix上观看稳定视频流的原因。

无线技术具有不同的范围和带宽特性。我们熟悉的家里和办公室中的WiFi路由器都是无线以太网网络,使用802.11协议发送和接收数据。使用最广泛的WiFi协议是802.11ac(WiFi 5),它允许最大(理论)数据速率高达5400 Mbps。最新的802.11ax协议,也称WiFi 6,是由802.11ac技术演变的,声称已将吞吐量提高至9.6 Gbps。WiFi路由器的范围是几十米的数量级,会受到墙壁和地板等物理障碍的影响。

蜂窝无线技术使用无线电波将数据从手机发送到安装在手机信号塔上的路由器,路由器通常通过电线连接到核心互联网来进行消息路由。每种蜂窝技术都引入了改进的带宽和其他性能维度。在撰写本书时最常见的技术是4G LTE无线宽带,比旧的3G技术快10倍左右,能够处理大约10 Mbps的持续下载速度(峰值下载速度接近50 Mbps)和2~5 Mbps的上传速度。

新兴的5G蜂窝网络声称将带宽提高到了现有4G技术的10倍,设备和蜂窝塔之间的延迟为1~2 ms,相比4G延迟(在20~40 ms)有很大改进。代价是覆盖范围,5G基站的最大覆盖范围约为500 m,而4G可在10~15 km内提供可靠的接收。

互联网是一个异构网络,世界各地有许多不同的运营商和各种可以想象到的硬件。不同类型的网络硬件集合汇集在全球互联网中。图3-1显示了构成互联网的主要组件的简化视图。一级网络是全球高速互联网骨干网。大约有20家一级ISP(互联网服务提供商)负责管理和控制全球流量。二级ISP通常是区域性的(例如,一个国家/地区),带宽低于一级ISP,并通过三级ISP向客户提供内容。三级ISP是每月向你收取高额家庭互联网费用的ISP。

图3-1:互联网的简化视图

互联网的运作方式比这里描述的要复杂得多,网络和协议的复杂性不在本章讨论的范围内。从分布式系统软件的角度来看,我们需要更多地了解使这些硬件能够将消息从手机路由到银行并成功返回的“魔力”,这便是 互联网协议 (Internet Protocol,IP)的用武之地!

3.1.2 软件

互联网上的软件系统使用IP(互联网协议)套件( https://oreil.ly/DJf0L )进行通信。IP套件定义了主机寻址、数据传输格式、消息路由和传递特性。它有四个抽象层,每层包含支持该层所需功能的相关协议。从低到高分别是:

1.数据链路层,定义了跨单个网段的数据通信方法。其由设备内部的驱动程序和网卡实现。

2.网络层,指定寻址和路由协议,使流量能够穿越构成互联网的独立管理和控制的网络。它是互联网协议家族中的IP层。

3.传输层,定义了可靠和尽力而为的主机到主机通信的协议。它是著名的TCP(传输控制协议)和UDP(用户数据报协议)所在的层。

4.应用层,其中包含多个应用程序级协议,例如HTTP和SCP(安全复制协议)。

每个高层协议都建立在低层的特性之上。接下来,我将简要介绍用于主机发现和消息路由的IP,以及分布式应用程序可以使用的TCP和UDP。

3.1.2.1 IP

IP定义了如何在互联网上为主机分配地址,以及如何在知道彼此地址的两个主机之间传输消息。

互联网上的每个设备都有自己的地址,也称为IP地址。IP地址的位置可以在互联网范围的目录服务——DNS(域名系统)——中找到。DNS是一个广泛分布的分层数据库,充当着互联网的地址簿。

目前用于分配IP地址的技术称为IPv4(互联网通信协议第4版),它将被后继者IPv6取代。IPv4是32位寻址方案,由于连接到互联网的设备数量不断增加,不久的将来就会用完地址。IPv6是128位方案,提供(几乎)无限数量的IP地址。据统计,2020年7月,Google.com( https://oreil.ly/3ix6W )处理的流量中约有33%来自IPv6。

DNS服务器是分层组织的。少量被高度复制的根DNS服务器是解析IP地址的起点。当互联网浏览器尝试查找网站时,作为本地DNS服务器(由你的雇主或ISP管理)的网络主机将联系根DNS服务器解析请求的主机名。根服务器回复一个所谓的权威DNS服务器的引用,该服务器管理名称解析,在我们的银行示例中,是 .com 的地址。每个顶级互联网域( .com .org .net 等)都有一个权威名称服务器。

接下来,本地DNS服务器将查询 .com DNS服务器, .com DNS服务器返回一个DNS服务器地址,该服务器知道 igbank.com 所管理的所有IP地址。向此DNS服务器查询之后,便可获得我们需要与应用程序通信的实际IP地址。总体流程如图3-2所示。

图3-2: igbank.com 域名解析示例

整个DNS数据库在地理上高度复制,因此没有单点故障,同时请求被分布在多个物理服务器上。本地DNS服务器还会缓存最近联系的主机的IP地址,这是可行的,因为IP地址不会经常更改。这意味着完整的域名解析过程并不会在我们访问所有站点时发生。

有了目标IP地址,主机就可以开始以一系列IP数据包的形式通过网络发送数据。IP根据数据包标头中的IP地址将数据从源主机传送到目标主机。IP定义了一个数据包结构,其中包含要传送的数据以及包括源IP地址和目标IP地址在内的标头数据。应用程序发送的数据被分解成一系列数据包,在互联网上独立传输。

IP也被称为尽力而为的传输协议,它不会补偿数据包传输期间可能发生的各种错误情况。可能的传输错误包括数据损坏、数据包丢失和重复。此外,每个数据包都通过互联网从源地址独立地路由到目标地址。独立处理每个数据包的过程称为数据包交换。网络对不同的情况(如网络链路故障和拥塞)做出动态响应,这是互联网的一个标志性特征。不同的数据包可能会通过不同的网络路径传送到同一目的地,从而导致它们无序地被传送到接收方。

由于尽力而为的设计,IP是不可靠的。如果两台主机需要可靠的数据传输,则需要添加额外的功能。IP协议家族的下一层,即传输层,是时候登场了。

3.1.2.2 TCP

一旦应用程序或浏览器发现了目标服务器的IP地址,它就可以使用传输协议API发送消息。这是使用TCP或UDP来实现的,它们是IP网络协议栈的流行标准传输协议。

分布式应用程序可以选择其中一种协议,通过主流编程语言Java、Python和C++等实现通信。实际上,直接调用协议API不常见,有更高级别的编程抽象实现了相关API,并对大多数应用程序隐藏了细节。实际上,IP协议家族的应用层包含了几个应用级API,包括在主流分布式系统中应用非常广泛的HTTP。

尽管如此,了解TCP、UDP及其区别仍然很重要。互联网上的大多数请求都是使用TCP发送的。TCP是:

●面向连接的。

●面向流的。

●可靠的。

我将在后续内容中逐个解析这些特征,以及它们为何重要。

TCP是面向连接的协议。在应用程序之间交换任何消息之前,TCP使用三步握手在客户端和服务器应用程序之间建立双向连接。连接保持打开状态,直到TCP客户端调用close()来终止与TCP服务器的连接。服务器在连接断开之前通过确认close()请求来进行响应。

建立连接后,客户端将一系列请求以数据流的形式发送到服务器。通过TCP发送数据流时,数据被分解成单个网络数据包,最大数据包大小为65535字节。每个数据包都包含源地址和目标地址,底层IP协议使用这些地址在网络中路由消息。

互联网是一个数据包交换网络,每个数据包都在网络中单独路由。每个数据包经过的路线随网络条件动态变化,例如链路拥塞或故障时,路线将有所变化。这意味着数据包可能不会按照从客户端发出的相同顺序到达服务器。为了解决这个问题,TCP发送方在每个数据包中都包含一个序列号,以便接收方将数据包重新组合成与发送顺序相同的流。

因为网络数据包在发送方和接收方之间的传输过程中可能会丢失或延迟,所以可靠性是必需的。为实现可靠的数据包传送,TCP使用累积确认机制。接收方定期发送一个确认数据包,其中包含在数据包流中无间隙接收到的数据包的最高序列号,这隐性地确认了所有以较低序列号发送的数据包,意味着所有数据包都已成功接收。如果发送方在超时周期内没有收到确认,则重新发送数据包。

TCP还有许多其他功能,例如,用于检查数据包完整性的校验和,以及用于确保发送方不会因发送数据过快而淹没慢速接收方的动态流量控制。连同连接建立和确认,TCP成为一个重量级的协议,它在可靠性和效率之间争取可靠性。

接下来,让我们看看UDP的使用场景。UDP是一种简单的无连接协议,它将用户的程序暴露给不可靠的底层网络。它无法保证消息传递按照规定的顺序进行,甚至消息传递可能根本不会发生。它可以被认为是底层IP协议之上的一层薄薄的单板(层),并在原始性能和可靠性之间争取性能。

然而,它非常适合许多现代应用程序,这些应用程序对异常丢失的数据包不敏感。比如对于流媒体电影、视频会议和游戏,用户不太可能察觉到一个丢失的数据包。

图3-3描述了TCP和UDP之间的主要区别。TCP包含了建立连接时的三次握手(SYN、SYN ACK、ACK)和数据包的送达确认(ACK),协议可以处理任何数据包丢失。图中未显示TCP连接关闭阶段涉及的四次握手。UDP无须建立连接、断开连接、确认和重试。使用UDP的应用程序需要容忍数据包丢失以及客户端或服务器故障(并根据故障做出响应的反应)。

图3-3:比较TCP和UDP

3.2 远程方法调用

使用直接与传输层协议TCP和UDP交互的底层API来编写分布式应用程序是完全可行的。最常见的方法是调用标准化套接字库——请参阅边栏中的概述。这是你永远不希望做的事情,因为套接字很复杂而且容易出错。本质上,套接字在两个节点之间创建了一个双向管道,你可以使用它来发送数据流。幸运的是,有更好的方法来构建分布式通信,我将在本节中描述,这些方法抽象掉了使用套接字的大部分复杂性。然而,套接字仍然是底层实现,了解一些知识是必要的。

套接字概述

套接字是客户端和服务器之间双向网络连接的一个端点。套接字由节点IP地址和端口的组合来标识。端口是唯一的数字标识符,它允许节点支持在该节点上运行的多个应用程序的通信。

每个IP地址都可以支持65535个TCP端口和另外65535个UDP端口。在服务器上,每个 {< IP Address >,< port >} 的组合都可以与一个应用程序相关联。这种组合形成了一个独特的端点,传输层使用该端点将数据传送到所需的服务器。

套接字连接由客户端和服务器IP地址与端口的唯一组合标识,即“<客户端IP地址、客户端端口、服务器IP地址、服务器端口>”。每个唯一连接还在客户端和服务器上分配一个套接字描述符。创建连接后,客户端以流的形式向服务器发送数据,服务器以结果作为响应。套接字库支持两种协议,选项SOCK_STREAM是TCP的,选项SOCK_DGRAM是UDP的。

你可以直接向套接字API写入分布式应用程序,它是操作系统的核心组件。所有主流编程语言都提供套接字API。然而,套接字库是一个底层的、难以使用的API。除非你确实需要编写系统级代码,否则应该避免使用它。

在我们的手机银行示例中,客户端可能会使用套接字来请求用户支票账户的余额。忽略特定的语言问题(和安全性!),客户端可以通过与服务器的连接来发送如下消息负载:

在这条消息中,“balance”代表我们希望服务器执行的操作,“000169990”则是银行账号。在服务器端,我们需要知道消息中第一个字符串是操作标识符,根据这个值可确定操作为“balance”(余额查询),第二个是银行账号。然后服务器根据消息的值查询数据库,检索余额,并返回结果,可能是用账号和当前余额格式化的消息,如下所示:

在一个复杂的系统中,服务器都会支持许多操作。在 igbank.com 中,可能有“login”(登录)、“transfer”(转账)、“address”(查询地址)、“statement”(声明)、“transaction”(交易)等。每个消息后面都会跟着不同的消息负载,服务器需要正确解释消息负载才能满足客户端的请求。

我们目前定义的是一个特定于应用程序的协议。只要我们以正确的顺序发送操作和所需的数据,服务器就能够正确响应。如果有一个错误的客户端不遵守应用程序协议,那么服务器需要彻底地检查错误。套接字库为客户端/服务器通信提供了一种原始的底层方法。它提供了高效的通信,但准确实施略有难度,并且很难直接扩展应用程序协议来处理所有可能性。当然,我们有更好的机制来克服这些问题。

如果用面向对象的语言(例如Java)来定义 igbank.com 服务器接口,那么我们会为它的每个操作都定义一个方法。每个方法都会为相应操作传递适当的参数列表,如下面示例代码所示:

这样的接口有几个优点,例如:

●编译器可以静态检查从客户端到服务器的调用,保证它们具有正确的格式和参数类型。

●服务器接口中的更改(例如,添加新参数)会强制更改客户端代码以遵循新方法签名。

●接口由类明确定义,方便客户端程序开发者直接理解和使用。

在软件工程中,显式接口的好处是众所周知的。面向对象设计的整个学科几乎都是基于如下基础:接口定义了调用者和被调用者之间的契约。与需要遵循的隐式应用协议的套接字相比,其优势显著。

上述事实在创建分布式系统的早期就初见端倪了。自20世纪90年代初以来,技术的发展使我们能够定义显式服务器接口,并使用与在顺序程序中基本相同的语法在网络上调用这些接口。表3-2给出了主要方法的总结,它们统称为远程过程调用(RPC)或远程方法调用(RMI)技术。

尽管这些RPC/RMI技术的语法和语义各不相同,但它们在操作上本质是相同的。让我们继续 igbank.com 的Java示例来查看整个类的方法。Java提供用于构建客户端/服务器应用程序的远程方法调用(RMI)API。

表3-2:主要RPC/RMI技术一览表

使用Java RMI,我们可以轻松地将上面的IGBank接口示例定义成远程接口,如以下代码所示:

java.rmi.Remote接口作为一个标记,通知Java编译器我们正在创建RMI服务器。此外,每个方法都必须抛出java.rmi.RemoteException。这些异常表示两个对象之间的分布式调用可能发生的错误。最常见的异常原因是通信失败或服务器对象崩溃。

然后我们必须在一个类中实现这个远程接口。下面的示例代码摘录了服务器端的实现:

上述代码需要注意的地方是:

●服务器端的实现扩展了UnicastRemoteObject类,提供了实例化远程可调用对象的功能。

●一旦构建了服务器对象,就必须向远程客户端通告其可用性。实现方式是,在称为RMI 注册表 的系统服务中存储对象的引用,并将逻辑名称与引用相关联——在这个例子中,逻辑名称为“IGBankServer”。注册表是一种简单的目录服务,通过它,客户端可以查找位置(网络地址和对象引用),并简单地提供逻辑名称来获取RMI服务器引用,逻辑名称在注册表中已经与服务器的引用相关联。

以下示例摘录了客户端用于连接到服务器的代码。它通过在RMI注册表中执行查找(lookup)操作并指定标识服务器的逻辑名称来获取对远程对象的引用。获得查找操作返回的引用之后,以与调用本地对象相同的方式调用服务器对象。但是,有一点不同的是,客户端必须捕获RemoteException,当无法访问服务器对象时,Java运行时将抛出该异常:

图3-4描述了构成RMI系统的组件之间的调用顺序。Stub和Skeleton是编译器根据RMI接口定义而生成的对象,它们有助于实际的远程通信。Skeleton实际上是一个TCP网络端点(主机、端口),用于侦听对关联服务器的调用。

图3-4:建立RMI连接和调用RMI服务器对象的顺序示意图

操作顺序如下:

1.服务器启动时,其逻辑引用存储在RMI注册表中。相关条目包含可用于对服务器进行远程调用的Java客户端Stub。

2.客户端查询注册表,并返回服务器的Stub。

3.客户端Stub接受来自Java客户端实现的对服务器接口的方法调用。

4.Stub将请求转换为一个或多个网络数据包,发送到服务器主机。转换过程称为编组。

5.Skeleton接收来自客户端的网络请求,并将网络数据包的数据解组为对RMI服务器对象实现的有效调用。解组与编组相反,它接收一系列网络数据包并将它们转换为对服务器对象的调用。

6.Skeleton等待方法返回响应。

7.Skeleton将结果编组为网络应答数据包,返回给客户端。

8.Stub解组数据并将结果传递给Java客户端调用点。

上述Java RMI示例说明了实现所有RPC/RMI机制的基础知识,在Erlang( https://oreil.ly/D5biM )和Go( https://oreil.ly/zD8dS )等现代语言中的实现也是如此。在使用Java Enterprise JavaBeans(EJB)技术时,你最有可能遇到Java RMI。EJB是一种基于RMI的服务器端组件模型,过去20年里在企业系统中被广泛使用。

不管具体实现细节如何,RPC/RMI方法的基本吸引力在于提供一种抽象调用机制,允许客户端以 位置透明 的方式对远程服务器进行远程调用。位置透明性由注册表实现,或者是任何能使客户端通过目录服务定位服务器的机制。这意味着服务器可以在不影响客户端实现的情况下更新其在目录中的网络位置。

RPC/RMI并非完美的。对于复杂的对象参数,编组和解组可能变得低效。跨编程语言的编组(客户端使用一种编程语言,服务器端使用另一种编程语言)可能会导致错误,因为类型在不同语言中的表示方式不同,存在微妙的不兼容性。如果远程方法签名发生变化,所有客户端都需要获得一个新的兼容Stub,这在大型部署中变得麻烦。

考虑到这些原因,大多数现代系统都是围绕基于HTTP并使用JSON表示参数的更简单协议来构建的。HTTP动词(PUT、GET、POST等)具有映射到特定URL的关联语义,而不是操作名称。这种方法起源于Roy Fielding关于REST方法的工作 。REST有一组包含RESTful架构风格的语义,实际上大多数系统并不遵守。我们将在第5章讨论REST和HTTP API机制。

3.3 局部故障

分布式系统的组件通过网络通信。在通信技术术语中,系统通信的共享局域网和广域网统称为 异步 网络。

使用异步网络有以下特点:

●节点可以随时向其他节点发送数据。

●网络是 半双工的 ,这意味着一个节点发送请求必须等待另一个节点的响应。涉及两个独立的通信。

●节点之间传输数据的时长是变化的,受网络拥塞、动态数据包路由和瞬态网络连接故障等因素影响。

●接收节点可能因软件或机器崩溃而无法使用。

●数据可能会丢失。在无线网络中,由于信号弱或信号干扰,数据包可能会被破坏和丢弃。互联网路由器可能会在拥塞期间丢弃数据包。

●节点没有完全一样的内部时钟;节点之间是不同步的。


同步网络则不一样,本质上是全双工的,同时在两个方向传输数据,每个节点的时钟是同步的( https://oreil.ly/SEPCs )。

异步网络对应用程序来说意味着什么呢?简单来说,当客户端向服务器发送请求时,它会等待多长时间才能收到回复?服务器节点的处理速度变慢了吗?网络是否拥塞并且数据包是否被路由器丢弃?如果客户端没有收到回复,应该怎么办?

让我们详细探讨这些场景。核心问题是,客户端是否收到响应,以及何时收到响应,这被称为局部故障处理,常见情形如图3-5所示。

图3-5:局部故障处理

当客户端希望连接到服务器并交换消息时,可能会出现以下结果:

●请求成功并收到快速响应。一切顺利。(实际上,几乎每个请求都会出现这种结果。“几乎”是关键词。)

●查找目标IP地址失败,客户端会迅速收到一条错误消息并采取相应措施。

●目标IP地址有效,但目标节点或目标服务器进程出现故障。发送方收到超时错误消息,并通知用户。

●请求由目标服务器接收,请求处理失败,服务器没有发送任何响应。

●请求由目标服务器接收,但是服务器负载过重,它需要很长时间(例如34 s)来处理请求并响应。

●目标服务器接收请求并响应。但是,由于网络故障,客户端未收到响应。

前面三种情况对于客户端来说很容易处理,因为它很快收到响应。来自服务器的结果或错误消息允许客户端继续工作。而且这些故障可以快速检测,且易于处理。

后面三种情况的结果给客户端带来了困扰。他们没有提供关于未收到回复的原因的任何线索。在客户端看来,这三种结果是一样的。如果客户端不等待(可能要等很久),它便无法知道响应是否会到达;如果客户端一直等下去,它就无法完成工作。

更隐蔽的是,客户端无法知道操作是不是成功了,服务器或网络故障导致结果没有到达,或者请求是否还在途中——仅由于网络/服务器拥塞而延迟。这些故障统称为 崩溃故障 https://oreil.ly/AAc9M )。

客户端处理崩溃故障的典型解决方案是在设定的超时时间后重新发送请求。然而,这是有风险的,如图3-6所示。客户端向服务器发送请求将钱存入银行账户,当它在超时后没有收到响应时,重新发送请求。余额是多少呢?服务器可能已经申请了存款,也可能没有,取决于局部失败的情况。

存款操作有可能发生两次,对客户来说当然是好消息,但银行可经不起这种玩笑。我们需要一种方法来确保服务器的操作,对于来自客户端的重复请求只会被应用一次。这也是维护正确的应用程序语义所必需的。

我们称这种操作属性为 幂等性 。幂等操作执行多次的结果与执行一次的结果一致。对于图3-6中的示例来说,客户端可以根据需要重试请求多次,而账户只会增加100美元。

没有更改持久状态的请求自然是幂等的。这意味着所有读取请求本质上都是安全的,不需要在服务器上执行额外的工作。更新持久状态则是另一回事。系统需要设计一种机制,使得客户端重复的请求不会导致任何状态变化,并且可以被服务器检测到。在API术语中,服务器状态发生变化的端点必须是幂等的。

图3-6:客户端在超时后重试

构建幂等操作的方法如下:

●客户端在所有改变状态的请求中包含一个唯一的 幂等键 。键标识来自特定客户端或事件源的单个操作。它通常是用户标识符(例如会话密钥)和一个唯一值(例如本地时间戳、UUID或序列号)的组合。

●服务器收到请求时,会检查它是否包含数据库中出现过的幂等性键值,而数据库是专门为实现幂等性而设计的。如果幂等键不在数据库中,表明这是一个新的请求。服务器便执行业务逻辑来更新应用程序状态,并将幂等键存储到数据库中,以表示操作已成功执行。

●如果幂等键在数据库中,表明此请求是来自客户端的重试,不应处理。服务器会为该操作返回一个有效的响应,客户端便不会再次重试了。

存储幂等键的数据库可以按如下方式实现:

●单独的数据库表或集合,与应用程序数据一起存储在事务数据库中。

●专用的数据库,提供极低查找延迟,例如简单的键值存储。

与应用程序数据不同,幂等键不必永远保留。一旦客户端收到操作成功的确认,就可以丢弃幂等键了。达成此目的最简单的方法是在特定时间段(例如60 min或24 h)后自动从存储中删除幂等键,具体取决于应用程序需求和请求量。

此外,幂等API实现必须确保应用程序状态已修改和幂等键已存储,两者均已发生API才能成功。如果应用程序状态已被修改,而由于某些故障,幂等键没有被存储,那么客户端重试将导致操作被执行两次。反之,如果存储了幂等键,但受某种原因影响应用程序状态未被修改,那么该操作尚未执行。因为幂等键已存在,重试请求将被过滤掉,更新操作便会丢失。

对应用程序状态的更新以及幂等键的存储必须同时发生,或者两者都不发生。如果熟悉数据库,那么你会认识到这便是对事务语义的要求。我们将在第12章讨论如何实现分布式事务。从本质上讲,事务确保了 严格一次的操作 (exactly-once semantics for operations),保证了所有消息始终只处理一次——这正是我们实现幂等性所需要的。

严格一次并不意味着没有消息传输失败、重试和应用程序崩溃,它们都是不可避免的。重要的是重试最终会成功,结果总是一样的。

我们将在后面的章节中继续讨论通信消息传递可靠性的问题。如图3-7所示,我们有一系列语义,代表不同程度的可靠性和性能特征。 最多一次 (at-most-once)消息传递速度快且不可靠——这是UDP协议提供的。 至少一次 (at-least-once)消息传递是TCP/IP提供的保证,意味着重复是不可避免的。 严格一次 (exactly-once)消息传递需要防止重复,需要在可靠性和较慢的性能之间进行权衡。

图3-7:通信消息传递的可靠性语义

正如我们将看到的,一些高级通信机制可以为我们的应用程序提供严格一次消息传递。但是,由于性能影响,不能在互联网中大规模运行。这很好地解释了我们的应用程序是基于TCP/IP的至少一次消息传递构建的,而我们必须在修改状态的API中实现严格一次消息传递的原因。

3.4 分布式系统中的共识

崩溃故障对我们构建分布式系统的方式有另一个影响。两将军问题( https://oreil.ly/ap5eq )很好地说明了这一点,如图3-8所示。

图3-8:两将军问题

想象一座被两支军队包围的城市。军队分布在城市的两侧,城市周围的地形很难穿越,而且能被城市中的狙击手发现。为了攻下这座城市,两军同时进攻是至关重要的,这将削弱城市的防御并使攻击者更有可能获胜。如果只有一支军队进攻,那么他们很可能会被击退。

鉴于限制,两位将军如何才能就确切的进攻时间达成一致,从而使两位将军都确定协议已达成?他们都需要确定另一支军队会在约定的时间进攻,否则灾难就会接踵而至。

为了协调攻击,第一位将军向另一位将军派遣信使,指示在特定时间发动攻击。由于信使可能被狙击手俘虏或杀死,第一位将军不能确定消息已经到达,除非他们从第二位将军的信使那里得到确认信号。当然,传达确认信号的信使也可能被俘或被杀,所以即使原来的信使顺利完成任务,第一位将军也可能永远不知道。即使确认消息送达,第二位将军又如何知道呢,除非他得到第一位将军的确认。

希望你已经理解这个问题了。由于信使可能被随机捕获或消灭,我们无法保证两位将军会就攻击时间达成共识。事实上,不能保证一定会达成协议是可以证明的。有一些解决方案可以增加达成共识的可能性。例如,以《权力的游戏》的风格,每位将军每次可能会派出100个不同的使者,即使大部分人被杀,也增加了至少有一个人冒险前往另一支友军并成功传递信息的概率。

两将军问题类似于分布式系统中的两个节点希望就某些状态达成一致,例如可以在任一节点更新的数据项的值。局部失败类似于丢失消息和确认。消息可能会丢失或延迟一段不确定的时间——这是异步网络的特征,正如本章前面所述。

事实上,存在崩溃故障的异步网络上,消息可以延迟但不会丢失,在有限时间内不可能达成共识。这便是著名的FLP不可能原理 [1]

幸运的是,这只是一个理论上的限制,即在异步网络上无法保证在无限消息延迟的情况下达成共识。实际上,分布式系统总是会达成共识。这是可能的,尽管我们的网络是异步的,但我们可以在消息延迟上建立合理的界限并在超时后重试。FLP是最坏的情况,我将在第12章讨论在分布式数据库中建立共识的算法。

最后,我们应该注意拜占庭失败的问题。试想一下,将两将军问题扩展为多个将军需要就进攻时间达成一致。在这种情况下,叛徒信使可能会更改攻击时间,或者叛徒将军可能会向其他将军发送虚假信息。

这类 恶意 失败被称为拜占庭故障,在分布式系统中尤为险恶。幸运的是,我们在本书中讨论的系统通常运行在受到良好保护的、安全的企业网络和管理环境之中。这意味着我们实际上可以排除拜占庭故障。现实中存在解决此类恶意行为的算法,如果你对实际案例感兴趣,请查看区块链( https://oreil.ly/r3vQT )和比特币( https://oreil.ly/IPohu )的共识机制。

3.5 分布式系统中的时间

分布式系统中的每个节点都有自己的内部时钟。如果每台机器上的时钟都是完全同步的,我们就可以简单地比较节点间事件的时间戳,确定它们发生的精确顺序,本书讨论的许多分布式系统问题也就会迎刃而解。

理想很丰满,现实很骨感。受温度或电压变化等环境条件影响,各个节点上的时钟会 漂移 。每台机器的时间漂移量各不相同,但每天漂移10~20 s的现象并不少见(我家里的咖啡机,每天大约5 min!)。

如果任其发展,时钟漂移会使节点上的时间变得毫无意义——就像我必须每隔几天更正一次咖啡机上的时间一样。为了解决这个问题,我们有许多 时间服务 。时间服务是准确的时间源,例如GPS或原子钟,可用于定期重置节点上的时钟以校正分组交换、可变延迟的数据网络上的漂移。

使用最广泛的时间服务是NTP(网络时间协议; http://www.ntp.org ),它提供了跨越全球的分层组织时间服务器集合。全球约有300台根服务器,它们的时间是最准确的。下一级时间服务器(大约20000个)定期(几毫秒内)与根服务器同步,以此类推,整个层次结构最多15级。全球有超过175000台NTP服务器。

通过NTP协议,运行NTP客户端的应用程序节点可以同步到NTP服务器。节点上的时间由与一个或多个NTP服务器进行的UDP消息交换来同步。消息带有时间戳,通过消息交换估算消息传输所用的时间。这是NTP算法中的一个因素,用以确定客户端上应该重置的时间。图3-9显示了一个简单的NTP配置。在局域网中,机器可以在几毫秒的精度内同步到NTP服务器。

图3-9:NTP服务使用说明

NTP同步对应用程序的一个有趣影响是,时钟的重置可以将本地节点时间向前或向后移动。这意味着如果我们的应用程序正在计算事件所花费的时间(例如,计算事件响应时间),如果NTP协议已设置本地时间并导致时间倒退,则事件的结束时间可能早于开始时间。

事实上,一个计算节点有两个时钟,分别是:

日历钟

日历钟(time of day clock)是自1970年1月1日午夜以来的毫秒数。在Java中,你可以使用System.currentTimeMillis()获取当前时间。日历钟的时间可以由NTP重置,如果它比NTP时间滞后或提前很长时间,则可能会向前或向后移动。

单调时钟

单调时钟(monotonic clock)是自过去未指定时间点以来的时间量(以秒和纳秒为单位),例如上次系统重新启动的时间。它只会向前发展;然而,它也可能是未经过完全准确测量的时间,因为它在一些事件(如虚拟机挂起等)期间停止。在Java中,你可以使用System.nanoTime()获取当前的单调时钟时间。

应用程序可以使用NTP服务来确保系统中每个节点上的时钟紧密同步。通常会以一小时到一天的时间间隔重新同步时钟,确保时钟的时间值保持接近。不过,如果应用程序确实需要准确了解不同节点上事件发生的顺序,那么时钟漂移将使其充满危险。

相比于NTP,还有提供更高的准确性的其他时间服务。Chrony( https://oreil.ly/ylrw3 )支持NTP协议,但比NTP准确性更高和扩展性更好——这是它被Facebook( https://oreil.ly/JqKHP )采用的原因。Amazon在其数据中心安装GPS和原子钟来构建Amazon Time Sync Service,此服务免费提供给所有AWS客户。

从上述讨论中得出的结论是,我们的应用程序不能依赖不同节点上事件的时间戳来表示事件的实际顺序。即使时钟漂移一两秒也会导致跨节点时间戳无法比较。当我们开始详细讨论分布式数据库时,此结论的含义将变得更加清晰。

3.6 总结和延伸阅读

本章涵盖了很多基础知识,解释了分布式系统中通信和时间的基本特征。这些特征对于应用程序设计人员和开发人员来说非常重要。

本章应引起共鸣的关键问题如下:

1.分布式系统中的通信可以透明地穿过许多不同类型的底层物理网络,包括WiFi、无线网络、广域网和局域网。通信延迟变化很大,受节点之间的物理距离、物理网络属性和瞬态网络拥塞的影响。在大规模应用程序组件之间的延迟应该尽可能地最小化(当然在物理定律范围内)。

2.互联网协议栈通过IP和TCP协议的组合确保跨异构网络的可靠通信。网络通信结构和路由器等引起节点不可用的故障,以及单节点故障,均可导致通信失败。你的代码将经历各种TCP/IP开销,例如,建立连接和处理网络故障发生时的错误。因此,了解IP的基础知识对于设计和调试非常重要。

3.使用RMI/RPC技术构建TCP/IP层,为客户端/服务器通信提供抽象层,采用本地方法/过程调用的方式调用服务器接口。然而,即便是更抽象的编程方法也仍然需要对网络问题(例如故障和重传)具有弹性恢复能力。在服务器中能改变应用程序状态的API最为明显,必须将其设计为幂等的。

4.在异步网络中,存在崩溃故障的情况下,有限时间内不可能在多个节点之间就状态达成一致或达成共识。幸运的是,真实的网络,尤其是局域网,速度很快而且非常可靠,这意味着我们可以设计出达成共识的算法。我将在本书第三部分讨论分布式数据库时介绍这些内容。

5.没有完全可靠的全局时间源可供应用程序中的节点同步其行为。各个节点上的时钟各不相同,不能用于精确的比较。应用程序无法通过比较不同节点上的时钟来确定事件的顺序。

上述问题将贯穿本书其余部分的讨论。分布式系统中许多独特问题和解决方案都源于这些基础知识。

关于分布式系统更详细、更理论化的优秀资源是George Coulouris等人合著的 Distributed Systems:Concepts and Design ,5th ed.(于2001年在Pearson出版)。

关于计算机网络,毫无疑问,James Kurose和Keith Ross合著的 Computer Networking:A Top-Down Approach ,7th ed.(于2017年在Pearson出版)是绝好的资源。

[1] Michael J. Fischer et al., “Impossibility of Distributed Consensus with One Faulty Process, ”Journalof the ACM 32,no. 2(1985):374-82. https://doi.org/10.1145/3149.214121 . pXM9lkrddD28MgNJ4jiSsRLSTOyFSwFmbUCGyHN1zI8sVVaWnf1Nh93WMJrAvIJm

点击中间区域
呼出菜单
上一章
目录
下一章
×