这阵子在看数据密集型应用系统设计书籍,自己把书籍比较重要的内容整理出来,基本一天一更,请感兴趣的朋友多多关注! 整个系列会在这几天都发布出来,可以关注一下
链接: 数据密集型应用系统设计-笔记.
文章目录
- 第八章 分布式系统的麻烦
- 1 故障与部分失效
- 1.1 云计算与超级计算机
- 2 不可靠的网络
- 2.1 检测故障
- 2.2 超时与无穷延迟
- 2.3 同步网络与异步网络
- 3 不可靠的时钟
- 3.1 单调钟与日历时钟
- 3.1.1 日历时钟
- 3.1.2 单调钟(测量持续时间)
- 3.2 时钟同步与准确性
- 3.3 依赖同步时钟
- 3.3.1 有序时间的时间戳
- 3.3.2 时钟读数存在置信区间
- 3.3.3 全局快照的同步时钟
- 3.4 进程暂停
- 3.4.1 响应时间保证
- 3.4.2 限制垃圾收集的影响
- 4 知识、真相与谎言
- 4.1 真相由多数所定义
- 4.1.1 领导者和锁
- 4.1.2 防护令牌
- 4.2 拜占庭故障
- 4.1.1 弱谎言形式
- 4.3 系统模型与现实
- 4.3.1 算法正确性
- 4.3.2 安全性和活性
- 4.3.3 将系统映射到现实世界
- 5 文章小结
第八章 分布式系统的麻烦
使用分布式系统与在一台计算机上编写软件有着根本的区别,主要的区别在于,有许多新颖和刺激的方法可以使事情出错。在这一章中,我们将了解实践中出现的问题,理解我们能够依赖,和不可以依赖的东西。
1 故障与部分失效
在分布式系统中,尽管系统的其他部分工作正常,但系统的某些部分可能会以某种不可预知的方式被破坏。这被称为部分失效(partial failure)。难点在于部分失效是不确定性的(nonderterministic):如果你试图做任何涉及多个节点和网络的事情,它有时可能会工作,有时会出现不可预知的失败。正如我们将要看到的,你甚至不知道是否成功了,因为消息通过网络传播的时间也是不确定的!
1.1 云计算与超级计算机
- 构建大型计算系统有不同的方式
- 一个极端是高性能计算(HPC)领域。具有数千个CPU的超级计算机通常用于计算密集型科学计算任务,如天气预报或分子动力学(模拟原子和分子的运动)。
- 另一个极端是云计算(cloud computing),云计算并不是一个良好定义的概念,但通常与多租户数据中心,连接IP网络(通常是以太网)的商用计算机,弹性/按需资源分配以及计量计费等相关联。
- 传统企业数据中心位于这两个极端之间。
- 因此也有着不同的故障处理方式
- 在超级计算机中,作业通常会不时地会将计算的状态存盘到持久存储中。如果一个节点出现故障,通常的解决方案是简单地停止整个集群的工作负载。故障节点修复后,计算从上一个检查点重新开始。因此,超级计算机更像是一个单节点计算机而不是分布式系统:通过让部分失败升级为完全失败来处理部分失败——如果系统的任何部分发生故障,只是让所有的东西都崩溃(就像单台机器上的内核恐慌一样)
- 超级计算机通常由专用硬件构建而成,每个节点相当可靠,节点通过共享内存和远程直接内存访问(RDMA) 进行通信。另一方面,云服务中的节点是由商用机器构建而成的,由于规模经济,可以以较低的成本提供相同的性能,而且具有较高的故障率。
- 纠错码允许数字数据在通信信道上准确传输,偶尔会出现一些错误,例如由于无线网络上的无线电干扰
- 互联网协议(Internet Protocol, IP) 不可靠:可能丢弃,延迟,重复或重排数据包。 传输控制协议(Transmission Control Protocol, TCP)在互联网协议(IP)之上提供了更可靠的传输层:它确保丢失的数据包被重新传输,消除重复,并且数据包被重新组装成它们被发送的顺序。
2 不可靠的网络
-
为什么需要网络:本书中关注的分布式系统是无共享的系统,即通过网络连接的一堆机器。网络是这些机器可以通信的唯一途径——我们假设每台机器都有自己的内存和磁盘,一台机器不能访问另一台机器的内存或磁盘(除了通过网络向服务器发出请求)。
-
无共享并不是构建系统的唯一方式,但它已经成为构建互联网服务的主要方式,其原因如下:相对便宜,因为它不需要特殊的硬件,可以利用商品化的云计算服务,通过跨多个地理分布的数据中心进行冗余可以实现高可靠性。
-
网络通信过程中可能出现的错误
- 请求可能已经丢失(可能有人拔掉了网线)。
- 请求可能正在排队,稍后将交付(也许网络或接收方过载)。
- 远程节点可能已经失效(可能是崩溃或关机)。
- 远程节点可能暂时停止了响应(可能会遇到长时间的垃圾回收暂停;),但稍后会再次响应。
- 远程节点可能已经处理了请求,但是网络上的响应已经丢失(可能是网络交换机配置错误)。
- 远程节点可能已经处理了请求,但是响应已经被延迟,并且稍后将被传递(可能是网络或者你自己的机器过载)。
-
网络错误的难题:发送者甚至不能分辨数据包是否被发送:唯一的选择是让接收者发送响应消息,这可能会丢失或延迟。这些问题在异步网络中难以区分:您所拥有的唯一信息是,您尚未收到响应。如果您向另一个节点发送请求并且没有收到响应,则不可能判断是什么原因。
-
解决方案:超时(Timeout),在一段时间之后放弃等待,并且认为响应不会到达。但是,当发生超时时,你仍然不知道远程节点是否收到了请求(如果请求仍然在某个地方排队,那么即使发送者已经放弃了该请求,仍然可能会将其发送给接收者)。
2.1 检测故障
网络的不确定性使得很难判断一个节点是否工作。在某些特定的情况下,您可能会收到一些反馈信息,明确告诉您某些事情没有成功:
- 如果可以连接到运行节点的机器,但没有进程正在侦听目标端口(例如,因为进程崩溃),操作系统将通过发送FIN或RST来关闭并重用TCP连接。但是,如果节点在处理请求时发生崩溃,则无法知道远程节点实际处理了多少数据。
- 如果节点进程崩溃(或被管理员杀死),但节点的操作系统仍在运行,则脚本可以通知其他节点有关该崩溃的信息,以便另一个节点可以快速接管,而无需等待超时到期,HBase就是这么做的。
- 如果有权访问数据中心网络交换机的管理界面,则可以通过它们检测硬件级别的链路故障(例如,远程机器是否关闭电源)。如果您通过互联网连接,或者如果您处于共享数据中心而无法访问交换机,或者由于网络问题而无法访问管理界面,则排除此选项。
- 如果路由器确认您尝试连接的IP地址不可用,则可能会使用ICMP目标不可达数据包回复。但是,路由器不具备神奇的故障检测能力——它受到与网络其他参与者相同的限制。
关于远程节点关闭的快速反馈很有用,但是你不能指望它。即使TCP确认已经传送了一个数据包,应用程序在处理之前可能已经崩溃。如果你想确保一个请求是成功的,你需要应用程序本身的正确响应
2.2 超时与无穷延迟
- 超时等待时间:短的超时可以更快地检测到故障,但有更高地风险误将一个节点宣布为失效,而该节点实际上只是暂时地变慢了(例如由于节点或网络上的负载峰值)
- 节点超时后需要做什么:当一个节点被宣告死亡时,它的职责需要转移到其他节点,这会给其他节点和网络带来额外的负担。
- 特殊情况:如果系统已经处于高负荷状态,则过早宣告节点死亡会使问题更严重。特别是如果节点实际上没有死亡,只是由于过载导致其响应缓慢;这时将其负载转移到其他节点可能会导致级联失效(cascading failure)(在极端情况下,所有节点都宣告对方死亡,所有节点都将停止工作)。
- 超时时间的设置2d + r:设想一个虚构的系统,其网络可以保证数据包的最大延迟——每个数据包要么在一段时间内传送,要么丢失,但是传递永远不会比d更长。此外,假设你可以保证一个非故障节点总是在一段时间内处理一个请求r。在这种情况下,您可以保证每个成功的请求在2d + r时间内都能收到响应,如果您在此时间内没有收到响应,则知道网络或远程节点不工作
- 实际情况:异步网络具有无限的延迟(即尽可能快地传送数据包,但数据包到达可能需要的时间没有上限),并且大多数服务器实现并不能保证它们可以在一定的最大时间内处理请求。
2.3 同步网络与异步网络
网络拥堵有以下几种情况:
- 网络拥塞:如果多个不同的节点同时尝试将数据包发送到同一目的地,则网络交换机必须将它们排队并将它们逐个送入目标网络链路。在繁忙的网络链路上,数据包可能需要等待一段时间才能获得一个插槽。如果传入的数据太多,交换机队列填满,数据包将被丢弃,因此需要重新发送数据包 - 即使网络运行良好。
- 操作系统层面排队:当数据包到达目标机器时,如果所有CPU内核当前都处于繁忙状态,则来自网络的传入请求将被操作系统排队,直到应用程序准备好处理它为止。根据机器上的负载,这可能需要一段任意的时间。
- 虚拟机监视器排队:在虚拟化环境中,正在运行的操作系统经常暂停几十毫秒,因为另一个虚拟机正在使用CPU内核。在这段时间内,虚拟机不能从网络中消耗任何数据,所以传入的数据被虚拟机监视器排队(缓冲),进一步增加了网络延迟的可变性。
- TCP执行流量控制(flow control):(也称为拥塞避免(congestion avoidance) 或背压(backpressure)),其中节点会限制自己的发送速率以避免网络链路或接收节点过载。这意味着甚至在数据进入网络之前,在发送者处就需要进行额外的排队。
为什么是互联网使用分组交互,而不是像电话那边使用电路传输,而电路传输有个好处就是能有一个保证的最大往返时间
- 针对突发流量的优化。一个电路适用于音频或视频通话,在通话期间需要每秒传送相当数量的比特。但请求网页,发送电子邮件或传输文件没有任何特定的带宽要求——我们只是希望它尽快完成。
- 如果想通过电路传输文件,你得预测一个带宽分配。猜的太低,传输速度会不必要的太慢,导致网络容量闲置。猜的太高,电路就无法建立(因为如果无法保证其带宽分配,网络不能建立电路)。因此,将电路用于突发数据传输会浪费网络容量,并且使传输不必要地缓慢。相比之下,TCP动态调整数据传输速率以适应可用的网络容量。
我们可以预测网络延迟吗
- 当前部署的技术不允许我们对网络的延迟或可靠性作出任何保证:我们必须假设网络拥塞,排队和无限的延迟总是会发生。因此,超时时间没有“正确”的值——它需要通过实验来确定。
- 可以将延迟变化视为动态资源分区的结果
- 电路传输(静态分区):假设两台电话交换机之间有一条线路,可以同时进行10,000个呼叫。通过此线路切换的每个电路都占用其中一个呼叫插槽。即使你现在是电话上唯一的电话,并且所有其他9,999个插槽都未使用,您的电路仍将分配与导线充分利用时相同的固定数量的带宽。
- 分组传输(动态分区)
- 互联网动态分享网络带宽的方式。发送者互相推挤和争夺,以让他们的数据包尽可能快地通过网络,并且网络交换机决定从一个时刻到另一个时刻发送哪个分组(即,带宽分配)。这种方法有排队的缺点,但其优点是它最大限度地利用了电线。电线固定成本,所以如果你更好地利用它,你通过电线发送的每个字节都会更便宜。
- 类似CPU:如果在多个线程间动态共享每个CPU内核,则一个线程有时必须在操作系统的运行队列里等待,而另一个线程正在运行,这样每个线程都有可能被暂停一个不定的时间长度。但是,与为每个线程分配静态数量的CPU周期相比,这会更好地利用硬件。更好的硬件利用率也是使用虚拟机的重要动机。
- 如果资源是静态分区的(如专用硬件和专用带宽分配),则在某些环境中可以实现延迟保证。但是,这是以降低利用率为代价的,是更昂贵的。另一方面,动态资源分配的多租户提供了更好的利用率,所以它更便宜,但它具有可变延迟的缺点。
3 不可靠的时钟
时钟很重要,在各个应用都起着重要的作用
- 判断请求超时
- 计算响应时间,服务耗时等
- 定时操作
- 判断缓存过期
- 记录操作时间
分布式系统钟,由于通信不是即时的:消息通过网络从一台机器传送到另一台机器需要时间。收到消息的时间总是晚于发送的时间,但是由于网络中的可变延迟,我们不知道晚了多少时间。这个事实导致有时很难确定在涉及多台机器时发生事情的顺序。并且由于每台机器都有自己的时钟,这些设备不是完全准确的,所以每台机器都有自己的时间概念,可能比其他机器稍快或更慢。所以需要找个同步机器的方式,最常用的机制是网络时间协议(NTP),它允许根据一组服务器报告的时间来调整计算机时钟。服务器则从更精确的时间源(如GPS接收机)获取时间。
3.1 单调钟与日历时钟
现代计算机至少有两种不同的时钟:日历时钟(time-of-day clock)和单调钟(monotonic clock)。尽管它们都衡量时间,但区分这两者很重要,因为它们有不同的目的。
3.1.1 日历时钟
定义:日历时钟是根据某个日历(也称为挂钟时间(wall-clock time))返回当前日期和时间。例如,Linux上的clock_gettime(CLOCK_REALTIME)
[^v]和Java中的System.currentTimeMillis()
返回自epoch(UTC时间1970年1月1日午夜)以来的秒数(或毫秒),根据公历(Gregorian)日历,不包括闰秒。有些系统使用其他日期作为参考点。
同步:日历时钟通常与NTP同步,理想情况下使得一台机器的时间戳与另一台机器上的时间戳相同。但是实际情况,如果本地时钟在NTP服务器之前太远,则它可能会被强制重置,看上去好像跳回了先前的时间点。这些跳跃以及他们经常忽略闰秒的事实,使日历时钟不能用于测量经过时间(elapsed time)
NTP原理
-
由RFC1305定义的时间同步协议,使用NTP的目的是对网络内所有具有时钟的设备进行时钟同步,使网络内所有设备的时钟保持一致,从而使设备能够提供基于统一时间的多种应用,用来在分布式时间服务器和客户端之间进行时间同步
-
NTP基于UDP报文进行传输,使用的UDP端口号为123
-
时钟同步过程
- (1)Device A发送一个NTP报文给Device B,该报文带有它离开Device A时的时间戳,该时间戳为10:00:00am(T1)
- (2)当此NTP报文到达Device B时,Device B加上自己的时间戳,该时间戳为11:00:01am(T2)
- (3)当此NTP报文离开Device B时,Device B再加上自己的时间戳,该时间戳为11:00:02am(T3)
- (4)当Device A接收到该响应报文时,Device A的本地时间为10:00:03am(T4)
- (5)至此,Device A已经拥有足够的信息来计算两个重要的参数:NTP报文的往返时延Delay=(T4-T1)-(T3-T2)=2秒
- (6)Device A就能够根据这些信息来设定自己的时钟,使之与Device B的时钟同步
-
NTP报文格式
-
LI(Leap Indicator):长度为2比特,值为“11”时表示告警状态,时钟未被同步。为其他值时NTP本身不做处理。
-
VN(Version Number):长度为3比特,表示NTP的版本号,目前的最新版本为3。
-
Mode:长度为3比特,表示NTP的工作模式。不同的值所表示的含义分别是:0未定义、1表示主动对等体模式、2表示被动对等体模式、3表示客户模式、4表示服务器模式、5表示广播模式或组播模式、6表示此报文为NTP控制报文、7预留给内部使用。
-
客户端/服务器模式:在客户端/服务器模式中,客户端向服务器发送时钟同步报文,报文中的Mode字段设置为3(客户模式)。服务器端收到报文后会自动工作在服务器模式,并发送应答报文,报文中的Mode字段设置为4(服务器模式)。客户端收到应答报文后,进行时钟过滤和选择,并同步到优选的服务器。在该模式下,客户端能同步到服务器,而服务器无法同步到客户端。
-
对等体模式:在对等体模式中,主动对等体和被动对等体之间首先交互Mode字段为3(客户端模式)和4(服务器模式)的NTP报文。之后,主动对等体向被动对等体发送时钟同步报文,报文中的Mode字段设置为1(主动对等体),被动对等体收到报文后自动工作在被动对等体模式,并发送应答报文,报文中的Mode字段设置为2(被动对等体)。经过报文的交互,对等体模式建立起来。主动对等体和被动对等体可以互相同步。如果双方的时钟都已经同步,则以层数小的时钟为准
-
广播模式:在广播模式中,服务器端周期性地向广播地址255.255.255.255发送时钟同步报文,报文中的Mode字段设置为5(广播模式)。客户端侦听来自服务器的广播报文。当客户端接收到第一个广播报文后,客户端与服务器交互Mode字段为3(客户模式)和4(服务器模式)的NTP报文,以获得客户端与服务器间的网络延迟。之后,客户端就进入广播客户端模式,继续侦听广播报文的到来,根据到来的广播报文对系统时钟进行同步。
-
组播模式:在组播模式中,服务器端周期性地向用户配置的组播地址(若用户没有配置组播地址,则使用默认的NTP组播地址224.0.1.1)发送时钟同步报文,报文中的Mode字段设置为5(组播模式)。客户端侦听来自服务器的组播报文。当客户端接收到第一个组播报文后,客户端与服务器交互Mode字段为3(客户模式)和4(服务器模式)的NTP报文,以获得客户端与服务器间的网络延迟。之后,客户端就进入组播客户模式,继续侦听组播报文的到来,根据到来的组播报文对系统时钟进行同步。
-
-
Stratum:系统时钟的层数,取值范围为1~16,它定义了时钟的准确度。层数为1的时钟准确度最高,准确度从1到16依次递减,层数为16的时钟处于未同步状态,不能作为参考时钟。
-
Poll:轮询时间,即两个连续NTP报文之间的时间间隔。
-
Precision:系统时钟的精度。
-
Root Delay:本地到主参考时钟源的往返时间。
-
Root Dispersion:系统时钟相对于主参考时钟的最大误差。
-
Reference Identifier:参考时钟源的标识。
-
Reference Timestamp:系统时钟最后一次被设定或更新的时间。
-
Originate Timestamp:NTP请求报文离开发送端时发送端的本地时间。
-
Receive Timestamp:NTP请求报文到达接收端时接收端的本地时间。
-
Transmit Timestamp:应答报文离开应答者时应答者的本地时间。
-
Authenticator:验证信息。
3.1.2 单调钟(测量持续时间)
作用:单调钟适用于测量持续时间(时间间隔),例如超时或服务的响应时间:Linux上的clock_gettime(CLOCK_MONOTONIC)
,和Java中的System.nanoTime()
都是单调时钟。“单调”指的是时钟总是往前走(而日历时钟可以往回跳)。
应用:可以在某个时间点检查单调钟的值,做一些事情,且稍后再次检查它。这两个值之间的差异告诉你两次检查之间经过了多长时间。
原理:单调钟的绝对值是毫无意义的:它可能是计算机启动以来的纳秒数,或类似的任意值。特别是比较来自两台不同计算机的单调钟的值是没有意义的,因为它们并不是一回事。在具有多个CPU插槽的服务器上,每个CPU可能有一个单独的计时器,但不一定与其他CPU同步。操作系统会补偿所有的差异,并尝试向应用线程表现出单调钟的样子,即使这些线程被调度到不同的CPU上。
3.2 时钟同步与准确性
单调钟不需要同步,但是日历时钟需要根据NTP服务器或其他外部时间源来设置才能有用。不幸的是,我们获取时钟的方法并不像你所希望的那样可靠或准确——硬件时钟和NTP可能会变幻莫测。
- 计算机中的石英钟不够精确:它会漂移(drifts)(运行速度快于或慢于预期)。时钟漂移取决于机器的温度。 Google假设其服务器时钟漂移为200 ppm(百万分之一),相当于每30秒与服务器重新同步一次的时钟漂移为6毫秒,或者每天重新同步的时钟漂移为17秒。即使一切工作正常,此漂移也会限制可以达到的最佳准确度。
- 如果计算机的时钟与NTP服务器的时钟差别太大,可能会拒绝同步,或者本地时钟将被强制重置。任何观察重置前后时间的应用程序都可能会看到时间倒退或突然跳跃。
- 如果某个节点被NTP服务器的防火墙意外阻塞,有可能会持续一段时间都没有人会注意到。有证据表明,这在实践中确实发生过。
- NTP同步只能和网络延迟一样好,所以当您在拥有可变数据包延迟的拥塞网络上时,NTP同步的准确性会受到限制。一个实验表明,当通过互联网同步时,35毫秒的最小误差是可以实现的,尽管偶尔的网络延迟峰值会导致大约一秒的误差。根据配置,较大的网络延迟会导致NTP客户端完全放弃。
- 一些NTP服务器是错误的或者配置错误的,报告的时间可能相差几个小时。
- 闰秒导致一分钟可能有59秒或61秒,这会打破一些在设计之时未考虑闰秒的系统的时序假设。闰秒已经使许多大型系统崩溃的事实说明了,关于时钟的错误假设是多么容易偷偷溜入系统中。处理闰秒的最佳方法可能是让NTP服务器“撒谎”,并在一天中逐渐执行闰秒调整(这被称为拖尾(smearing)),虽然实际的NTP服务器表现各异。
- 在虚拟机中,硬件时钟被虚拟化,这对于需要精确计时的应用程序提出了额外的挑战。当一个CPU核心在虚拟机之间共享时,每个虚拟机都会暂停几十毫秒,与此同时另一个虚拟机正在运行。从应用程序的角度来看,这种停顿表现为时钟突然向前跳跃。
- 如果您在没有完整控制权的设备(例如,移动设备或嵌入式设备)上运行软件,则可能完全不能信任该设备的硬件时钟。一些用户故意将其硬件时钟设置为不正确的日期和时间,例如,为了规避游戏中的时间限制,时钟可能会被设置到很远的过去或将来。
通过GPS接收机,精确时间协议(PTP)以及仔细的部署和监测可以实现这种精确度。然而,这需要很多努力和专业知识,而且有很多东西都会导致时钟同步错误。如果你的NTP守护进程配置错误,或者防火墙阻止了NTP通信,由漂移引起的时钟误差可能很快就会变大。
3.3 依赖同步时钟
时钟的问题在于,虽然它们看起来简单易用,但却具有令人惊讶的缺陷:日历时钟可能会前后跳跃,而一个节点上的时间可能与另一个节点上的时间完全不同。
3.3.1 有序时间的时间戳
例子:依赖时钟,在多个节点上对事件进行排序。 例如,如果两个客户端写入分布式数据库,谁先到达? 哪一个更近?
问题:当一个写入被复制到其他节点时,它会根据发生写入的节点上的日历时钟标记一个时间戳。写入x = 1
的时间戳为42.004秒,但写入x = 2
的时间戳为42.003秒,即使x = 2
在稍后出现。当节点2接收到这两个事件时,会错误地推断出x = 1
是最近的值,而丢弃写入x = 2
。效果上表现为,客户端B的增量操作会丢失。
解决:这种冲突解决策略被称为最后写入胜利(LWW),它在多领导者复制和无领导者数据库。有些实现会在客户端而不是服务器上生成时间戳,但这并不能改变LWW的基本问题:
- 数据库写入可能会神秘地消失:具有滞后时钟的节点无法覆盖之前具有快速时钟的节点写入的值,直到节点之间的时钟偏差消逝。此方案可能导致一定数量的数据被悄悄丢弃,而未向应用报告任何错误。
- LWW无法区分高频顺序写入(上图中客户端B的增量操作一定发生在客户端A的写入之后)和真正并发写入(写入者意识不到其他写入者)。需要额外的因果关系跟踪机制(例如版本向量),以防止违背因果关系
- 两个节点很可能独立地生成具有相同时间戳的写入,特别是在时钟仅具有毫秒分辨率的情况下。为了解决这样的冲突,还需要一个额外的决胜值(tiebreaker)(可以简单地是一个大随机数),但这种方法也可能会导致违背因果关系。
NTP同步是否能足够准确,以至于这种不正确的排序不会发生?也许不能,因为NTP的同步精度本身,除了石英钟漂移这类误差源之外,还受到网络往返时间的限制。为了进行正确的排序,你需要一个比测量对象(即网络延迟)要精确得多的时钟。
所谓的逻辑时钟(logic clock)是基于递增计数器而不是振荡石英晶体,对于排序事件来说是更安全的选择。逻辑时钟不测量一天中的时间或经过的秒数,而仅测量事件的相对顺序(无论一个事件发生在另一个事件之前还是之后)。相反,用来测量实际经过时间的日历时钟和单调钟也被称为物理时钟(physical clock)。
3.3.2 时钟读数存在置信区间
时钟是不准的:即使您每分钟与本地网络上的NTP服务器进行同步,几毫秒的时间漂移也很容易在不精确的石英时钟上发生。使用公共互联网上的NTP服务器,最好的准确度可能达到几十毫秒,而且当网络拥塞时,误差可能会超过100毫秒
将时钟读数视为一个时间点是没有意义的——它更像是一段时间范围:例如,一个系统可能以95%的置信度认为当前时间处于本分钟内的第10.3秒和10.5秒之间,它可能没法比这更精确了。如果我们只知道±100毫秒的时间,那么时间戳中的微秒数字部分基本上是没有意义的。
3.3.3 全局快照的同步时钟
快照隔离在分布式系统上的难题
- 这是数据库中非常有用的功能,需要支持小型快速读写事务和大型长时间运行的只读事务(用于备份或分析)。它允许只读事务看到特定时间点的处于一致状态的数据库,且不会锁定和干扰读写事务。
- 快照隔离最常见的实现需要单调递增的事务ID。如果写入比快照晚(即,写入具有比快照更大的事务ID),则该写入对于快照事务是不可见的。在单节点数据库上,一个简单的计数器就足以生成事务ID。
- 但是当数据库分布在许多机器上,也许可能在多个数据中心中时,由于需要协调,(跨所有分区)全局单调递增的事务ID会很难生成。事务ID必须反映因果关系:如果事务B读取由事务A写入的值,则B必须具有比A更大的事务ID,否则快照就无法保持一致。在有大量的小规模、高频率的事务情景下,在分布式系统中创建事务ID成为一个难以处理的瓶颈。
解决方案:
- 使用同步时钟的时间戳作为事务ID,如果我们能够获得足够好的同步性,那么这种方法将具有很合适的属性:更晚的事务会有更大的时间戳。当然,问题在于时钟精度的不确定性。
- Spanner以这种方式实现跨数据中心的快照隔离。Spanner提出了一种新的思路,在不进行通信的情况下,利用高精度和可观测误差的本地时钟 TrueTime API给事件打上时间戳,并且以此比较分布式系统中两个事件的先后顺序
- TrueTime API的实现:大体上类似于网络时间协议NTP,但只有两个层次。第一层次,服务器是拥有高精度计时设备的,每个机房若干台,大部分机器都装备了GPS 接收器,剩下少数机器是为GPS系统全部失效的情况而准备的,叫做“末日”服务器,装备了原子钟。所有的Spanner服务器都属于第二层,定期向多个第 一层的时间服务器获取时间来校正本地时钟,先减去通信时间,再去除异常值,最后求交集。
- 它使用TrueTime API报告的时钟置信区间,并基于以下观察结果:如果您有两个置信区间,每个置信区间包含最早和最晚可能的时间戳( A = [ A e a r l i e s t , A l a t e s t ] A = [A_{earliest}, A_{latest}] A=[Aearliest,Alatest], B = [ B e a r l i e s t , B l a t e s t ] B=[B_{earliest}, B_{latest}] B=[Bearliest,Blatest]),这两个区间不重叠(即: A e a r l i e s t < A l a t e s t < B e a r l i e s t < B l a t e s t A_{earliest} < A_{latest} < B_{earliest} < B_{latest} Aearliest<Alatest<Bearliest<Blatest)的话,那么B肯定发生在A之后——这是毫无疑问的。只有当区间重叠时,我们才不确定A和B发生的顺序。
- 为了确保事务时间戳反映因果关系,在提交读写事务之前,Spanner在提交读写事务时,会故意等待置信区间长度的时间。通过这样,它可以确保任何可能读取数据的事务处于足够晚的时间,因此它们的置信区间不会重叠。为了保持尽可能短的等待时间,Spanner需要保持尽可能小的时钟不确定性,为此,Google在每个数据中心都部署了一个GPS接收器或原子钟,这允许时钟同步到大约7毫秒以内
- 虽然避免了通信开销,却引入了等待时间。为了保证外部一致性,写延迟是不可避免的,这也印证了CAP定理所揭示的法则,一致性与延迟之间是需要权衡的
3.4 进程暂停
例子:有一个数据库,每个分区只有一个领导者。只有领导被允许接受写入。一个节点如何知道它仍然是领导者(它并没有被别人宣告为死亡),并且它可以安全地接受写入?
租约方式解决领导者问题:
-
方式:一种选择是领导者从其他节点获得一个租约(lease),类似一个带超时的锁。任一时刻只有一个节点可以持有租约——因此,当一个节点获得一个租约时,它知道它在某段时间内自己是领导者,直到租约到期。为了保持领导地位,节点必须周期性地在租约过期前续期。如果节点发生故障,就会停止续期,所以当租约过期时,另一个节点可以接管。
while (true) {request = getIncomingRequest();// 确保租约还剩下至少10秒if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000){lease = lease.renew();}if (lease.isValid()) {process(request);} }
-
存在问题
- 它依赖于同步时钟:租约到期时间由另一台机器设置(例如,当前时间加上30秒,计算到期时间),并将其与本地系统时钟进行比较。如果时钟不同步超过几秒,将出现问题
- 即使将协议更改为仅使用本地单调时钟,也存在另一个问题:代码假定在执行剩余时间检查
System.currentTimeMillis()
和实际执行请求process(request)
中间的时间间隔非常短。通常情况下,这段代码运行得非常快,所以10秒的缓冲区已经足够确保租约在请求处理到一半时不会过期。如果程序执行中出现了意外的停顿,如线程在lease.isValid()
行周围停止15秒,然后才继续。在这种情况下,在请求被处理的时候,租约可能已经过期,而另一个节点已经接管了领导。然而,没有什么可以告诉这个线程已经暂停了这么长时间了,所以这段代码不会注意到租约已经到期了,直到循环的下一个迭代 ——到那个时候它可能已经做了一些不安全的处理请求。
-
线程暂停的情况
- 许多编程语言运行时(如Java虚拟机)都有一个垃圾收集器(GC),偶尔需要停止所有正在运行的线程。这些“停止所有处理(stop-the-world)”GC暂停有时会持续几分钟!甚至像HotSpot JVM的CMS这样的所谓的“并行”垃圾收集器也不能完全与应用程序代码并行运行,它需要不时地停止所有处理。尽管通常可以通过改变分配模式或调整GC设置来减少暂停,但是如果我们想要提供健壮的保证,就必须假设最坏的情况发生。
- 在虚拟化环境中,可以挂起(suspend) 虚拟机(暂停执行所有进程并将内存内容保存到磁盘)并恢复(恢复内存内容并继续执行)。这个暂停可以在进程执行的任何时候发生,并且可以持续任意长的时间。这个功能有时用于虚拟机从一个主机到另一个主机的实时迁移,而不需要重新启动,在这种情况下,暂停的长度取决于进程写入内存的速率。
- 在最终用户的设备(如笔记本电脑)上,执行也可能被暂停并随意恢复,例如当用户关闭笔记本电脑的盖子时。
- 当操作系统上下文切换到另一个线程时,或者当管理程序切换到另一个虚拟机时(在虚拟机中运行时),当前正在运行的线程可能在代码中的任意点处暂停。在虚拟机的情况下,在其他虚拟机中花费的CPU时间被称为窃取时间(steal time)。如果机器处于沉重的负载下(即,如果等待运行的线程队列很长),暂停的线程再次运行可能需要一些时间。
- 如果应用程序执行同步磁盘访问,则线程可能暂停,等待缓慢的磁盘I/O操作完成。在许多语言中,即使代码没有包含文件访问,磁盘访问也可能出乎意料地发生——例如,Java类加载器在第一次使用时惰性加载类文件,这可能在程序执行过程中随时发生。 I/O暂停和GC暂停甚至可能合谋组合它们的延迟。如果磁盘实际上是一个网络文件系统或网络块设备(如亚马逊的EBS),I/O延迟进一步受到网络延迟变化的影响。
- 如果操作系统配置为允许交换到磁盘(页面交换),则简单的内存访问可能导致页面错误(page fault),要求将磁盘中的页面装入内存。当这个缓慢的I/O操作发生时,线程暂停。如果内存压力很高,则可能需要将另一个页面换出到磁盘。在极端情况下,操作系统可能花费大部分时间将页面交换到内存中,而实际上完成的工作很少(这被称为抖动(thrashing))。为了避免这个问题,通常在服务器机器上禁用页面调度(如果你宁愿干掉一个进程来释放内存,也不愿意冒抖动风险)。
- 可以通过发送SIGSTOP信号来暂停Unix进程,例如通过在shell中按下Ctrl-Z。 这个信号立即阻止进程继续执行更多的CPU周期,直到SIGCONT恢复为止,此时它将继续运行。 即使你的环境通常不使用SIGSTOP,也可能由运维工程师意外发送。
-
所有这些事件都可以随时抢占(preempt) 正在运行的线程,并在稍后的时间恢复运行,而线程甚至不会注意到这一点。这个问题类似于在单个机器上使多线程代码线程安全:你不能对时序做任何假设,因为随时可能发生上下文切换,或者出现并行运行。
-
当在一台机器上编写多线程代码时,我们有相当好的工具来实现线程安全:互斥量,信号量,原子计数器,无锁数据结构,阻塞队列等等。不幸的是,这些工具并不能直接转化为分布式系统操作,因为分布式系统没有共享内存,只有通过不可靠网络发送的消息。
-
分布式系统中的节点,必须假定其执行可能在任意时刻暂停相当长的时间,即使是在一个函数的中间。在暂停期间,世界的其它部分在继续运转,甚至可能因为该节点没有响应,而宣告暂停节点的死亡。最终暂停的节点可能会继续运行,在再次检查自己的时钟之前,甚至可能不会意识到自己进入了睡眠。
3.4.1 响应时间保证
问题:线程和进程可能暂停,可能会造成巨大影响
解决:实时操作系统(RTOS),允许在指定的时间间隔内保证CPU时间的分配。库函数必须申明最坏情况下的执行时间;动态内存分配可能受到限制或完全不允许(实时垃圾收集器存在,但是应用程序仍然必须确保它不会给GC太多的负担);必须进行大量的测试和测量,以确保达到保证
事实上,实时系统可能具有较低的吞吐量,因为他们必须让及时响应的优先级高于一切,大多数用于嵌入式设备。对于大多数服务器端数据处理系统来说,实时保证是不经济或不合适的。因此,这些系统必须承受在非实时环境中运行的暂停和时钟不稳定性。
3.4.2 限制垃圾收集的影响
解决方式:将GC暂停视为一个节点的短暂计划中断,并在这个节点收集其垃圾的同时,让其他节点处理来自客户端的请求。如果运行时可以警告应用程序一个节点很快需要GC暂停,那么应用程序可以停止向该节点发送新的请求,等待它完成处理未完成的请求,然后在没有请求正在进行时执行GC。这个技巧向客户端隐藏了GC暂停,并降低了响应时间的高百分比。一些对延迟敏感的金融交易系统使用这种方法
这个想法的一个变种是只用垃圾收集器来处理短命对象(这些对象可以快速收集),并定期在积累大量长寿对象(因此需要完整GC)之前重新启动进程。一次可以重新启动一个节点,在计划重新启动之前,流量可以从该节点移开,就像滚动升级一样。
4 知识、真相与谎言
分布式遇到的问题:分布式系统与运行在单台计算机上的程序的不同之处:没有共享内存,只有通过可变延迟的不可靠网络传递的消息,系统可能遭受部分失效,不可靠的时钟和处理暂停。
网络中的一个节点无法确切地知道任何事情——它只能根据它通过网络接收到(或没有接收到)的消息进行猜测。节点只能通过交换消息来找出另一个节点所处的状态(存储了哪些数据,是否正确运行等等)。如果远程节点没有响应,则无法知道它处于什么状态,因为网络中的问题不能可靠地与节点上的问题区分开来。
4.1 真相由多数所定义
半断开:一个具有不对称故障的网络:一个节点能够接收发送给它的所有消息,但是来自该节点的任何传出消息被丢弃或延迟。即使该节点运行良好,并且正在接收来自其他节点的请求,其他节点也无法听到其响应。经过一段时间后,其他节点宣布它已经死亡,因为他们没有听到节点的消息。
半断开的节点可能会注意到它发送的消息没有被其他节点确认,因此意识到网络中必定存在故障。尽管如此,节点被其他节点错误地宣告为死亡,而半连接的节点对此无能为力。
长时间的垃圾收集暂停:一个经历了一个长时间停止所有处理垃圾收集暂停(stop-the-world GC Pause) 的节点。节点的所有线程被GC抢占并暂停一分钟,因此没有请求被处理,也没有响应被发送。其他节点等待,重试,不耐烦,并最终宣布节点死亡。最后,GC完成,节点的线程继续,好像什么也没有发生。其他节点感到惊讶,因为所谓的死亡节点突然活过来。GC后的节点最初甚至没有意识到已经经过了整整一分钟,而且自己已被宣告死亡。从它自己的角度来看,从最后一次与其他节点交谈以来,几乎没有经过任何时间。
结论:节点不一定能相信自己对于情况的判断。分布式系统不能完全依赖单个节点,因为节点可能随时失效,可能会使系统卡死,无法恢复。相反,许多分布式算法都依赖于法定人数,即在节点之间进行投票:决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。
法定人数投票:这也包括关于宣告节点死亡的决定。如果法定数量的节点宣告另一个节点已经死亡,那么即使该节点仍感觉自己活着,它也必须被认为是死的。个体节点必须遵守法定决定并下台。
4.1.1 领导者和锁
通常情况下,一些东西在一个系统中只能有一个。例如:
- 数据库分区的领导者只能有一个节点,以避免脑裂(split brain)。
- 特定资源的锁或对象只允许一个事务/客户端持有,以防同时写入和损坏。
- 一个特定的用户名只能被一个用户所注册,因为用户名必须唯一标识一个用户。
如果一个节点继续表现为天选者(分区的负责人,锁的持有者,成功获取用户名的用户的请求处理程序),即使大多数节点已经声明它已经死了,则在考虑不周的系统中可能会导致问题。这样的节点能以自己赋予的权能向其他节点发送消息,如果其他节点相信,整个系统可能会做一些不正确的事情。
例子:HBase中,假设你要确保一个存储服务中的文件一次只能被一个客户访问,如果持有租约的客户端暂停太久,它的租约将到期。另一个客户端可以获得同一文件的租约,并开始写入文件。当暂停的客户端回来时,它认为(不正确)它仍然有一个有效的租约,并继续写入文件。结果,客户的写入将产生冲突并损坏文件。
4.1.2 防护令牌
- 我们假设每次锁定服务器授予锁或租约时,它还会返回一个防护令牌(fencing token),这个数字在每次授予锁定时都会增加(例如,由锁定服务增加)。然后,我们可以要求客户端每次向存储服务发送写入请求时,都必须包含当前的防护令牌。
- 如果将ZooKeeper用作锁定服务,则可将事务标识
zxid
或节点版本cversion
用作防护令牌。由于它们保证单调递增,因此它们具有所需的属性 - 注意,这种机制要求资源本身在检查令牌方面发挥积极作用,通过拒绝使用旧的令牌,而不是已经被处理的令牌来进行写操作——仅仅依靠客户端检查自己的锁状态是不够的。
- 在服务器端检查一个令牌可能看起来像是一个缺点,但这可以说是一件好事:一个服务假定它的客户总是守规矩并不明智,因为使用客户端的人与运行服务的人优先级非常不一样
4.2 拜占庭故障
- 令牌存在的问题:如果节点有意破坏系统的保证,则可以通过使用假防护令牌发送消息来轻松完成此操作。
- 如果节点可能声称其实际上没有收到特定的消息。这种行为被称为拜占庭故障(Byzantine fault),在不信任的环境中达成共识的问题被称为拜占庭将军问题
- Web应用程序确实需要预期受终端用户控制的客户端(如Web浏览器)的任意和恶意行为。这就是为什么输入验证,数据清洗和输出转义如此重要:例如,防止SQL注入和跨站点脚本。
- 防御很难:在大多数系统中,如果攻击者可以渗透一个节点,那他们可能会渗透所有这些节点,因为它们可能都运行着相同的软件。因此,传统机制(认证,访问控制,加密,防火墙等)仍然是抵御攻击者的主要保护措施。
4.1.1 弱谎言形式
尽管我们假设节点通常是诚实的,但值得向软件中添加防止“撒谎”弱形式的机制——例如,由硬件问题导致的无效消息,软件错误和错误配置。这种保护机制并不是完全的拜占庭容错,但它们仍然是简单而实用的步骤,以提高可靠性。例如:
- 由于硬件问题或操作系统、驱动程序、路由器等中的错误,网络数据包有时会受到损坏。通常,损坏的数据包会被内建于TCP和UDP中的校验和所俘获,但有时它们也会逃脱检测 。要对付这种破坏通常使用简单的方法就可以做到,例如应用程序级协议中的校验和。
- 可公开访问的应用程序必须仔细清理来自用户的任何输入,例如检查值是否在合理的范围内,并限制字符串的大小以防止通过大内存分配的拒绝服务。防火墙后面的内部服务对于输入也许可以只采取一些不那么严格的检查,但是采取一些基本的合理性检查(例如,在协议解析中)仍然是一个好主意。
- NTP客户端可以配置多个服务器地址。同步时,客户端联系所有的服务器,估计它们的误差,并检查大多数服务器是否对某个时间范围达成一致。只要大多数的服务器没问题,一个配置错误的NTP服务器报告的时间会被当成特异值从同步中排除。使用多个服务器使NTP更健壮(比起只用单个服务器来)。
4.3 系统模型与现实
算法的编写方式不应该过分依赖于运行的硬件和软件配置的细节。这就要求我们以某种方式将我们期望在系统中发生的错误形式化。我们通过定义一个系统模型来做到这一点,这个模型是一个抽象,描述一个算法可以假设的事情。
关于时序假设,三种系统模型是常用的:
同步模型
同步模型(synchronous model) 假设网络延迟、进程暂停和和时钟误差都是受限的。这并不意味着完全同步的时钟或零网络延迟;这只意味着你知道网络延迟、暂停和时钟漂移将永远不会超过某个固定的上限。同步模型并不是大多数实际系统的现实模型,因为无限延迟和暂停确实会发生。
部分同步模型
部分同步(partial synchronous) 意味着一个系统在大多数情况下像一个同步系统一样运行,但有时候会超出网络延迟,进程暂停和时钟漂移的界限。这是很多系统的现实模型:大多数情况下,网络和进程表现良好,否则我们永远无法完成任何事情,但是我们必须承认,在任何时刻都存在时序假设偶然被破坏的事实。发生这种情况时,网络延迟、暂停和时钟错误可能会变得相当大。
异步模型
在这个模型中,一个算法不允许对时序做任何假设——事实上它甚至没有时钟(所以它不能使用超时)。一些算法被设计为可用于异步模型,但非常受限。
进一步来说,除了时序问题,我们还要考虑节点失效。三种最常见的节点系统模型是:
崩溃-停止故障
在崩溃停止(crash-stop) 模型中,算法可能会假设一个节点只能以一种方式失效,即通过崩溃。这意味着节点可能在任意时刻突然停止响应,此后该节点永远消失——它永远不会回来。
崩溃-恢复故障
我们假设节点可能会在任何时候崩溃,但也许会在未知的时间之后再次开始响应。在崩溃-恢复(crash-recovery) 模型中,假设节点具有稳定的存储(即,非易失性磁盘存储)且会在崩溃中保留,而内存中的状态会丢失。
拜占庭(任意)故障
节点可以做(绝对意义上的)任何事情,包括试图戏弄和欺骗其他节点,如上一节所述。
对于真实系统的建模,具有崩溃-恢复故障(crash-recovery) 的部分同步模型(partial synchronous) 通常是最有用的模型。
4.3.1 算法正确性
为了定义算法是正确的,我们可以描述它的属性。例如,排序算法的输出具有如下特性:对于输出列表中的任何两个不同的元素,左边的元素比右边的元素小。这只是定义对列表进行排序含义的一种形式方式。
同样,我们可以写下我们想要的分布式算法的属性来定义它的正确含义。例如,如果我们正在为一个锁生成防护令牌,我们可能要求算法具有以下属性:
唯一性(uniqueness)
没有两个防护令牌请求返回相同的值。
单调序列(monotonic sequence)
如果请求 x x x 返回了令牌 t x t_x tx,并且请求 y y y返回了令牌 t y t_y ty,并且 x x x 在 y y y 开始之前已经完成,那么 t x < t y t_x <t_y tx<ty。
可用性(availability)
请求防护令牌并且不会崩溃的节点,最终会收到响应。
如果一个系统模型中的算法总是满足它在所有我们假设可能发生的情况下的性质,那么这个算法是正确的。但这如何有意义?如果所有的节点崩溃,或者所有的网络延迟突然变得无限长,那么没有任何算法能够完成任何事情。
4.3.2 安全性和活性
为了澄清这种情况,有必要区分两种不同的属性:安全(safety)属性和活性(liveness)属性。在刚刚给出的例子中,唯一性和单调序列是安全属性,而可用性是活性属性。
活性属性通常在定义中通常包括“最终”一词。 最终一致性是一个活性属性
安全通常被非正式地定义为:没有坏事发生,而活性通常就类似:最终好事发生:
- 如果安全属性被违反,我们可以指向一个特定的安全属性被破坏的时间点(例如,如果违反了唯一性属性,我们可以确定重复的防护令牌被返回的特定操作)。违反安全属性后,违规行为不能被撤销——损失已经发生。
- 活性属性反过来:在某个时间点(例如,一个节点可能发送了一个请求,但还没有收到响应),它可能不成立,但总是希望在未来能成立(即通过接受答复)。
区分安全属性和活性属性的一个优点是可以帮助我们处理困难的系统模型。对于分布式算法,在系统模型的所有可能情况下,要求始终保持安全属性是常见的。也就是说,即使所有节点崩溃,或者整个网络出现故障,算法仍然必须确保它不会返回错误的结果(即保证安全属性得到满足)。
但是,对于活性属性,我们可以提出一些注意事项:例如,只有在大多数节点没有崩溃的情况下,只有当网络最终从中断中恢复时,我们才可以说请求需要接收响应。部分同步模型的定义要求系统最终返回到同步状态——即任何网络中断的时间段只会持续一段有限的时间,然后进行修复。
4.3.3 将系统映射到现实世界
安全属性和活性属性以及系统模型对于推理分布式算法的正确性非常有用。然而,在实践中实施算法时,很明显系统模型是对现实的简化抽象。
例如,在崩溃-恢复(crash-recovery)模型中的算法通常假设稳定存储器中的数据在崩溃后可以幸存。但是,如果磁盘上的数据被破坏,或者由于硬件错误或错误配置导致数据被清除,会发生什么情况?如果服务器存在固件错误并且在重新启动时无法识别其硬盘驱动器,即使驱动器已正确连接到服务器,那又会发生什么情况?
法定人数算法依赖节点来记住它声称存储的数据。如果一个节点可能患有健忘症,忘记了以前存储的数据,这会打破法定条件,从而破坏算法的正确性。也许需要一个新的系统模型,在这个模型中,我们假设稳定的存储大多能在崩溃后幸存,但有时也可能会丢失。但是那个模型就变得更难以推理了。
算法的理论描述可以简单宣称一些事是不会发生的——在非拜占庭式系统中,我们确实需要对可能发生和不可能发生的故障做出假设。然而,真实世界的实现,仍然会包括处理“假设上不可能”情况的代码。
证明算法正确并不意味着它在真实系统上的实现必然总是正确的。但这迈出了很好的第一步,因为理论分析可以发现算法中的问题,这种问题可能会在现实系统中长期潜伏,直到你的假设(例如,时序)因为不寻常的情况被打破。理论分析与经验测试同样重要。
5 文章小结
在本章中,讨论了分布式系统中可能发生的各种问题,包括:
- 当您尝试通过网络发送数据包时,数据包可能会丢失或任意延迟。同样,答复可能会丢失或延迟,所以如果你没有得到答复,你不知道消息是否发送成功了。
- 节点的时钟可能会与其他节点显著不同步(尽管您尽最大努力设置NTP),它可能会突然跳转或跳回,依靠它是很危险的,因为您很可能没有好的方法来测量你的时钟的错误间隔。
- 一个进程可能会在其执行的任何时候暂停一段相当长的时间(可能是因为停止所有处理的垃圾收集器),被其他节点宣告死亡,然后再次复活,却没有意识到它被暂停了。
这类部分失效(partial failure) 可能发生的事实是分布式系统的决定性特征。每当软件试图做任何涉及其他节点的事情时,偶尔就有可能会失败,或者随机变慢,或者根本没有响应(最终超时)。在分布式系统中,我们试图在软件中建立部分失效的容错机制,这样整个系统在即使某些组成部分被破坏的情况下,也可以继续运行。
为了容忍错误,第一步是检测它们,但即使这样也很难。大多数系统没有检测节点是否发生故障的准确机制,所以大多数分布式算法依靠超时来确定远程节点是否仍然可用。但是,超时无法区分网络失效和节点失效,并且可变的网络延迟有时会导致节点被错误地怀疑发生故障。此外,有时一个节点可能处于降级状态:例如,由于驱动程序错误,千兆网卡可能突然下降到1 Kb/s的吞吐量。这样一个“跛行”而不是死掉的节点可能比一个干净的失效节点更难处理。
一旦检测到故障,使系统容忍它也并不容易:没有全局变量,没有共享内存,没有共同的知识,或机器之间任何其他种类的共享状态。节点甚至不能就现在是什么时间达成一致,就不用说更深奥的了。信息从一个节点流向另一个节点的唯一方法是通过不可靠的网络发送信息。重大决策不能由一个节点安全地完成,因此我们需要一个能从其他节点获得帮助的协议,并争取达到法定人数以达成一致。
如果你习惯于在理想化的数学完美的单机环境(同一个操作总能确定地返回相同的结果)中编写软件,那么转向分布式系统的凌乱的物理现实可能会有些令人震惊。相反,如果能够在单台计算机上解决一个问题,那么分布式系统工程师通常会认为这个问题是平凡的,现在单个计算机确实可以做很多事情。如果你可以避免打开潘多拉的盒子,把东西放在一台机器上,那么通常是值得的。
但是,可伸缩性并不是使用分布式系统的唯一原因。容错和低延迟(通过将数据放置在距离用户较近的地方)是同等重要的目标,而这些不能用单个节点实现。
在本章中,我们也转换了几次话题,探讨了网络、时钟和进程的不可靠性是否是不可避免的自然规律。我们看到这并不是:有可能给网络提供硬实时的响应保证和有限的延迟,但是这样做非常昂贵,且导致硬件资源的利用率降低。大多数非安全关键系统会选择便宜而不可靠,而不是昂贵和可靠。
我们还谈到了超级计算机,它们采用可靠的组件,因此当组件发生故障时必须完全停止并重新启动。相比之下,分布式系统可以永久运行而不会在服务层面中断,因为所有的错误和维护都可以在节点级别进行处理——至少在理论上是如此。 (实际上,如果一个错误的配置变更被应用到所有的节点,仍然会使分布式系统瘫痪)。
本章一直在讲存在的问题,给我们展现了一幅黯淡的前景。在下一章中,我们将继续讨论解决方案,并讨论一些旨在解决分布式系统中所有问题的算法。