【数据系统—分布式系统】分布式系统的挑战

虽然长篇累牍谈了很多,但是现实则更加复杂。现在我们将这种复杂性推向极致,并做一个非常悲观的假定:所有可能出错的事情一定会出错。实际上,有经验的系统管理员会告诉你这其实是一个非常合理的假定,他们可能都遭遇过一些可怕甚至惨痛的教训。

故障与部分失效

单台节点上的软件通常不应该出现模棱两可的现象:当硬件正常工作时,相同的操作通常总会产生相同的结果;而如果硬件存在问题,结果往往是系统性故障,如内核崩溃,蓝屏死机,启动失败等。因此在单节点上一个质量合格的软件状态要么是功能正常,要么是完全失效,而不会介于两者之间。

然而当涉及多台节点时,情况发生了根本性变化。对于这种分布式系统,理想化的标准正确模型不再适用,我们必须面对一个可能非常混乱的现实。

不可靠的网络

我们关注的主要是分布式无共享系统,即通过网络连接的多个节点。网络是跨节点通信的唯一途径,我们还假定每台机器都有自己的内存和磁盘,一台机器不能直接访问另一台机器的内存或磁盘除非通过网络向对方发出请求。

首先要说明,无共享并不是构建集群系统的唯一方式,但它却是构建互联网服务的主流方式。主要是由于以下几个原因:由于不需要专门的硬件因此成本相对低廉,可以采用通用的商品化硬件,可以采用跨区域的多数据中心来实现高可靠性。

互联网以及大多数数据中心的内部网络(通常是以太网)都是异步网络。在这种网络中,一个节点可以发送消息(数据包)到另一个节点,但是网络并不保证它什么时候到达,甚至它是否一定到达。发送之后等待响应过程中,有很多事情可能会出错:

  1. 请求可能已经丢失(比如有人拔掉了网线)。
  2. 请求可能正在某个队列中等待,无法马上发送(也许网络或接收方已经超负荷)。
  3. 远程接收节点可能已经失效(例如崩溃或关机)。
  4. 远程接收节点可能暂时无法响应(例如正在运行长时间的垃圾回收)。
  5. 远程接收节点已经完成了请求处理,但回复却在网络中丢失(例如网络交换机配置错误)。
  6. 远程接收节点已经完成了请求处理,但回复却被延迟处理(例如网络或者发送者的机器超出负荷)。

发送者甚至不清楚数据包是否完成了发送,只能选择让接收者来回复响应消息,但回复也有可能丢失或延迟。这些问题在一个异步网络中无法明确区分,发送者拥有的唯一信息是,尚未收到响应,但却无法判定具体原因。

处理这个问题通常采用超时机制:在等待一段时间之后,如果仍然没有收到回复则选择放弃,并且认为响应不会到达。但是,即使判定超时,仍然并不清楚远程节点是否收到了请求(一种情况,请求仍然在某个地方排队,即使发送者放弃了,但最终请求会发送到接收者)。

现实中的网络故障

一些系统研究和大量的侧面证据表明,网络问题出人意料地普遍,包括那些由公司运营的数据中心。即使网络故障在你的环境中比较少见,但故障可能发生也要求软件需要能够处理它们。事实上,只要有网络通信,就可能会出现故障,这一点始终无法彻底避免。

如果没有处理或者测试网络故障,可能会发生意想不到的后果。例如,集群可能会死锁,即使网络恢复了也无法提供服务,甚至可能误删除数据。如果触发了一些软件未定义的情形,则发生任何意外都不奇怪。

处理网络故障并不意味着总是需要复杂的容错措施:假定你的网络通常非常可靠,而万一出现问题,一种简单的方法是对用户提示错误信息。但前提是,必须非常清楚接下来软件会如何应对,以确保系统最终可以恢复。

检测故障

许多系统都需要自动检测节点失效这样的功能。例如:

  • 负载均衡器需要避免向已失效的节点继续分发请求。
  • 对于主从复制的分布式数据库,如果主节点失败,需要将某个从节点提升为主节点。不过,由于网络的不确定性很难准确判断节点是否确实失效。
  • 假设可以登录节点,但发现服务进程没有侦听目标端口(例如,由于进程已经崩溃),那么操作系统会返回RST或FIN标志的数据包来辅助关闭或拒绝TCP连接。但是,如果节点在处理请求的过程中发生了崩溃,则很难知道该节点实际处理了多少数据。
  • 如果服务进程崩溃,但操作系统仍正常运行,可以通过脚本通知其他节点,以便新节点来快速接管而跳过等待超时。
  • 如果有权访问数据中心网络交换机,则可以通过管理接口查询是否存在硬件级别的链路故障。不过,该方法也有局限性,例如通过互联网连接,或者是处于共享数据中心而没有访问交换机的权限,以及由于网络问题而根本无法登录管理界面等。
  • 如果路由器已经确认目标节点不可访问,则会返回ICMP“目标不可达”数据包来回复请求。但是,路由器本身并不具有什么神奇的检测能力。

能快速告之远程节点的关闭状态自然有用,但也不是万能的。例如,即使TCP确认一个数据包已经发送到目标节点,但应用程序也可能在处理完成之前发生崩溃。如果你想知道一个请求是否执行成功,就需要应用级别的回复。

总之,如果出现了问题,你可能会在应用堆栈的某个级别拿到了一个关于错误的回复,但最好假定最终收不到任何错误报告。接下来尝试重试,等待超时之后,如果还是没有收到响应,则最终声明节点已经失效。

超时与无限期的延迟

如果超时是故障检测唯一可行的方法,那么超时应该设多长呢?不幸的是没有标准的答案。

设置较长的超时值意味着更长时间的等待,才能宣告节点失效。较短的超时设置可以帮助快速检测故障,但可能会出现误判,例如实际上节点只是出现暂时的性能波动,结果却被错误地宣布为失效。

如果节点实际上是生效状态(例如,正在发送一封电子邮件),过早地将其声明为失效会带来一些问题,新节点会尝试接管,然后出现一些操作在两个节点上执行两次。

当一个节点被宣告为失效,其承担的职责要交给到其他节点,这个过程会给其他节点以及网络带来额外负担,特别是如果此时系统已经处于高负荷状态。例如节点只是负载过高而出现了响应缓慢,转移负载到其他节点可能会导致失效扩散,产生级联扩大效应,在极端情况下,所有节点都宣告对方死亡,造成服务处于事实停止状态。

网络拥塞与排队

计算机网络上数据包延的变化根源往往在于排队:

  • 当多个不同节点同时发送数据包到相同的目标节点时,网络交换机会出现排队,然后依次将数据包转发到目标网络。如果网络负载过重,数据包可能必须等待一段时间才能获得发送机会。如果数据量太大,交换机队列塞满,之后的数据包则会被丢弃,网络还在运转,但会引发大量数据包重传,会引入额外的延迟。
  • 当数据包到达目标机器后,如果所有CPU核都处于繁忙状态,则网络数据包请求会被操作系统排队,直到应用程序能够处理。根据机器的配置和负载情况,这里也会引入一段不确定的等待时间。
  • 在虚拟化环境下,CPU核会切换虚拟机,从而导致正在运行的操作系统会突然暂停几十毫秒。在这段时间,客户虚机无法从网络中接收任何数据,入向的包会被虚拟机管理器排队缓冲,进一步增加了网络延迟的不确定性。
  • TCP执行流量控制时,节点会主动限制自己的发送速率以避免加重网络链路或接收节点负载。

所有以上因素都会造成网络延迟的变化或者不确定性。当系统接近其最大设计上限时,系统负载过高,队列深度就会显著加大,排队对延迟的影响变得特别明显。

在这种环境下,只能通过实验方式一步步设置超时。先在多台机器上,多次测量网络往返时间,以确定延迟的大概范围;然后结合应用特点,在故障检测与过早超时风险之间选择一个合适的中间值。

更好的做法是,超时设置并不是一个不变的常量,而是持续测量响应时间及其变化,然后根据最新的响应时间分布来自动调整。可以用Phi Accrual故障检测器完成,该检测器目前已在Akka和Cassandra中使用。TCP的重传超时也采用了类似的机制。

同步与异步网络

如果网络层可以在规定的延迟内保证完成数据包的发送,且不会丢弃数据包,那么分布式系统就会简单很多。为什么我们不能考虑在硬件层解决这个问题呢?使网络足够可靠,然后软件就无需如此担心。

为了回答这个问题,可以将数据中心网络与传统的固定电话网络进行对比分析,首先后者非常可靠:语音延迟和掉话现象极为罕见。当通过电话网络拨打电话时,系统会动态建立一条电路:在整个线路上为呼叫分配一个固定的、带宽有保证通信链路,该电路一直维持到通话结束。

这种网络本质是同步的:即使数据中间经过了多个路由器,传输信号的空间在电路建立时已经在网络中得到预留,不会受到排队的影响。由于没有排队,网络最大的端到端延迟是固定的。我们称之为有界延迟。

网络延迟是否可预测?

固定电话网络中的电路与TCP连接存在很大不同:电路方式总是预留固定带宽,在电路建立之后其他人无法使用;而TCP连接的数据包则会尝试使用所有可用的网络带宽。TCP可以传送任意大小可变的数据块,它会尽力在最短的时间内完成数据发送。而当TCP连接空闲时,通常不占用任何带宽。

以太网和IP都是基于分组交换协议,这种协议注定受到排队的影响,从而导致网络延迟不确定,在这些协议里完全没有电路的概念。

那为什么数据中心网络和互联网采用分组交换呢?答案是,它们针对突发流量进行了很多优化。如果你想通过电路链接来传输文件,将不得不预估一个待分配的带宽。如果预估值太低,传输速度就特别缓慢,甚至无法实际可用;如果预估带宽太高,电路甚至无法完成建立。所以,对于突发数据的传输,电路网络无法充分利用网络容量,导致发送缓慢。相比之下,TCP动态调整传输速率则可以充分利用所有可用的网络容量。

总之,当前广泛部署的技术无法为我们提供延迟或可靠性方面的硬件级保证,我们必须假设会出现网络拥塞,排队和无上限的延迟。基于此,超时设置并不存在某个绝对正确的值,而是需要通过实验的方式来确定。

不可靠的时钟

在分布式系统中,时间总是件棘手的问题,由于跨节点通信不可能即时完成,消息经由网络从一台机器到另一台机器总是需要花费时间。收到消息的时间应该晚于发送的时间,但是由于网络的不确定延迟,精确测量面临着很多挑战。这些情况使得多节点通信时很难确定事情发生的先后顺序。

而且,网络上的每台机器都有自己的时钟硬件设备,通常是石英晶体振荡器。这些设备并非绝对准确,即每台机器都维护自己本地的时间版本,可能比其他机器稍快或更慢。可以在一定程度上同步机器之间的时钟,最常用的方法是网络时间协议(Network Time Protocol,NTP)。

单调时钟与墙上时钟

墙上时钟

墙上时钟根据某个日历(也称为墙上时间)返回当前的日期与时间。例如,Linux的clock\_gettime (CLOCK\_REALTIME)和Java中的System.currentTimeMillis()会返回自纪元1970年1月1日以来的秒数和毫秒数,不含闰秒。而有些系统则使用其他日期作为参考点。

墙上时钟可以与NTP同步。但是,这里还存在一些奇怪问题。特别是,如果本地时钟远远快于NTP服务器,强行重置之后会跳回到先前的某个时间点。这种跳跃以及经常忽略闰秒,导致其不太适合测量时间间隔。

单调时钟

单调时钟更适合测量持续时间段(时间间隔),例如超时或服务的响应时间。Linux上的clock\_gettime(cLoCK\_MONOTONIC)和Java中的System.nanoTime()返回的即是单调时钟。

可以在一个时间点读取单调时钟的值,完成某项工作,然后再次检查时钟。时钟值之间的差值即两次检查之间的时间间隔。注意,单调时钟的绝对值并没有任何意义,它可能电脑启动以后经历的纳秒数或者其他含义。因此比较不同节点上的单调时钟值毫无意义,它们没有任何相同的基准。

在分布式系统中,可以采用单调时钟测量一段任务的持续时间,它不假定节点间有任何的时钟同步,且可以容忍轻微测量误差。

时钟同步与准确性

单调时钟不需要同步,但是墙上时钟需要根据NTP服务器或其他外部时间源做必要的调整。然而,我们获取时钟的方法并非预想那样可靠或准确,硬件时钟和NTP可能会出现一些莫名其妙的现象。举几个例子:

  • 计算机中的石英钟不够精确,存在漂移现象(运行速度会加快或减慢)。时钟漂移主要取决于机器的温度。
  • 如果时钟与NTP服务器的时钟差别太大,可能会出现拒绝同步,或者本地时钟将被强制重置。
  • 由于某些原因,如果与NTP服务器链接失败,可能会很长一段时间没有留意到错误配置最终导致同步失败。
  • NTP同步会受限于当时的网络环境特别是延迟,如果网络拥塞、数据包延迟变化不定,则NTP同步的准确性会受影响。
  • 一些NTP服务器本身出现故障、或者配置错误,其报告的时间可能存在数小时的偏差。
  • 闰秒会产生一分钟为59秒或61秒的现象,这会在使一些对闰秒毫无防范的系统出现混乱。
  • 在虚拟机中,由于硬件时钟也是被虚拟化的,当虚拟机共享一个CPU核时,每个虚拟机会出现数十毫秒内的暂停以便切换客户虚机。但从应用的角度来看,这种停顿会表现为时钟突然向前发生了跳跃。
  • 如果运行在未完全可控的设备上,需要留意不能完全相信设备上的硬件时钟。

高精度的时钟可以采用GPS接收机,精确时间协议(PTP)并辅以细致的部署和监测。但通常也意味着大量的资源投入和技术门槛,并持续监控时钟同步可能出现的错误情况。

依赖同步的时钟

时钟虽然看起来简单,但却有不少使用上的陷阱:一天可能不总是86 400秒,时钟会向后回拨,一个节点上的时间可能与另一个节点上的时间完全不同。

假如一台机器的CPU出现了故障或者网络有问题,系统可能根本无法工作,所以很快就会被注意到进而得到修复,但时钟问题却不那么容易被及时发现。如果石英时钟有缺陷,或者NTP客户端配置错误,最后出现了时间偏差,对大多数功能可能并没太大影响。但对于一些高度依赖于精确时钟的软件,出现的后果可能是隐式的,或许会丢失一小部分数据而不是突然的崩溃。

因此,如果应用需要精确同步的时钟,最好仔细监控所有节点上的时钟偏差。如果某个节点的时钟漂移超出上限,应将其宣告为失效,并从集群中移除。这样的监控的目的是确保在造成重大影响之前尽早发现并处理问题。

时间戳与时间顺序

对于一个常见的功能:跨节点的事件排序,如果它高度依赖时钟计时,就存在一定的技术风险。例如,两个客户端同时写入分布式数据库,哪一个操作是最新的呢?

对于在多主节点以及无主节点复制数据库中广泛使用的最后写入获胜策略,会产生一些有关时间戳的问题:

  • 数据库写入可能会奇怪地丢失:明明后续发生的写操作却没法覆盖另一个较早的值,原因是后者节点的时钟太快了。这会导致一些数量未知的数据被悄悄地丢弃,并且不会向应用报告任何错误。
  • 无法区分连续快速发生的连续写操作和并发写入。需要额外的因果关系跟踪机制(例如版本向量)来防止因果冲突。
  • 由于时钟精度的限制(例如毫秒级),两个节点可能各自独立产生了完全相同的时间戳。为了解决这样的冲突,需要一个额外的仲裁值(可以简单地引入一个大的随机数),但该方法还是无法区分因果关系。

对于排序来讲,基于递增计数器而不是振荡石英晶体的逻辑时钟是更可靠的方式。逻辑时钟并不测量一天的某个时间点或时间间隔,而是事件的相对顺序(事件发生的相对前后关系)。

时钟的置信区间

我们不应该将时钟读数视为一个精确的时间点,而更应该视为带有置信区间的时间范围。例如,系统可能有95%的置信度认为目前时间介于10.3 \~10.5秒之间。如果我们可完全相信的精度为+/-100毫秒,那么时间戳中那些微秒级的读数并无实际意义。

可以根据具体的时间源来推算出时钟误差的上限。如果节点上直接装有GPS接收器或原子时钟,那它的误差范围通常可查询制造商的手册。如果节点是从服务器获取时间,则不确定性取决于上次服务器同步以来的石英漂移范围,加上NTP服务器的不确定性,再加上与服务器之间的网络往返时间。

全局快照的同步时钟

常见的快照隔离实现中需要单调递增事务ID。如果写入发生在快照之后(即写入具有比快照更大的事务ID),那么该写入对于快照不可见。在单节点数据库上,一个简单的计数器足以生成事务ID。

但是,当数据库分布在多台机器上(可能跨越多个数据中心)时,由于需要复杂的协调以产生全局的、单调递增的事务ID(跨所有分区)。事务ID要求必须反映因果关系:事务B如果要读取事务A写入的值,则B的事务ID必须大于A的事务ID,否则快照将不一致。

能否使用同步后的墙上时钟作为事务ID呢?如果我们能够获得足够可靠的同步时钟,自然它可以符合事务ID属性要求:后发生的事务具有更大的时间戳。然而问题还是时钟精度的不确定性。

Google Spanner采用以下思路来实现跨数据中心的快照隔离。它根据TrueTimeAPI返回的时钟置信区间,并基于以下观察结果:如果有两个置信区间,每个置信区间都包含最早和最新可能的时间戳(A = [Aearliest, Alatest]和B = [Bearliest, Blatest]),且这两个区间没有重叠,那么可以断定B一定发生在A之后。只有发生了重叠,A和B发生顺序才无法明确。

为了确保事务时间戳反映因果关系,Spanner在提交读写事务之前故意等待置信区间的长度。这样做的目的是,确保所有读事务要足够晚才发生,避免与先前的事务的置信区间产生重叠。

进程暂停

在系统运行时,一个线程可能会被暂停很长时间,原因可能会有很多种:

  • 许多编程语言都有垃圾收集器,有时运行期间会暂停所有正在运行的线程。即使像HotSpot JVM CMS所谓的“并发”垃圾收集器也不能完全与应用代码并行运行,需要时不时地停止活动的线程。
  • 在虚拟化环境中,可能会暂停虚拟机然后继续。暂停可能发生进程运行的任一时刻,并且可能持续很长的时间。
  • 当操作系统执行线程上下文切换时,或者虚拟机管理程序切换到另一个虚机时,正在运行的线程可能会在代码的任意位置被暂停。如果机器负载很高,被暂停的线程可能需要一段时间之后才能再次运行。
  • 如果应用程序执行同步磁盘操作,则线程可能暂停并等待磁盘I/O完成。在许多语言中,即使代码并没有明确执行文件操作,也可能意外引入磁盘I/O。
  • 如果操作系统配置了基于磁盘的内存交换分区,内存访问可能触发缺页中断,进而需要从磁盘中加载内存页。
  • 通过发送SIGSTOP信号来暂停UNIX进程,例如在shell中按下Ctrl-Z。

所有上述情况都可能随时抢占一个正在运行的线程,然后在之后的某个时间点再恢复线程的执行,而线程自身却对此一无所知。

分布式系统中的一个节点必须假定,执行过程中的任何时刻都可能被暂停相当长一段时间,包括运行在某个函数中间。暂停期间,整个集群的其他部分都在照常运行,甚至会一致将暂停的节点宣告为故障节点。最终,暂停的节点可能会回来继续运行,除非再次检查时钟,否则它对刚刚过去的暂停毫无意识。

响应时间保证

在许多编程语言和操作系统中,线程和进程可能会暂停相当长的时间。如果仔细配置系统,可以做到避免很多这种暂停。某些软件如果在指定时间内无法响应则会导致严重后果,对于这些系统,软件有一个必须做出响应的上限。

提供实时保证需要来自软件栈的多个层面的支持:首先是一个实时操作系统;其次库函数也必须考虑最坏的执行时间;然后,动态内存分配很可能要受限或者完全被禁止;最终还是需要大量、充分的测试和验证,以确保满足要求。

显然,这需要大量额外的工作,也严重限制了可使用的编程语言、库和工具的范围。对于大多数服务器端数据处理系统来说,实时性保证并不经济或者不合适。因此,现在这些运行在非实时环境下的系统就得承受如进程暂停、时钟不稳定等困扰。

调整垃圾回收的影响

一个较新的想法是把GC暂停视为节点的一个计划内的临时离线,当节点启动垃圾回收时,通知其他节点来接管客户端的请求。此外,系统可以提前为前端应用发出预警,应用会等待当前请求完成,但停止向该节点发送新的请求,这样垃圾回收可以在无干扰的情况下更加高效运行。

该方法的一个变种是,只对短期对象(可以快速回收)执行垃圾回收,然后在其变成长期存活对象之前,采取定期重启的策略从而避免对长期存活对象执行全面回收。每次选择一个节点重新启动,在重启之前,重新平衡节点之间的流量,思路与滚动升级类似。

这些措施虽然并不能完全避免垃圾回收导致的进程暂停,但可以有效地减少对应用层的影响。

知识,真相与谎言

在分布式系统中,我们可以明确列出对系统行为(系统模型)所做的若干假设,然后以满足这些假设条件为目标来构建实际运行的系统。在给定系统模型下,可以验证算法的正确性。这也意味着即使底层模型仅提供了少数几个保证,也可以在系统软件层面实现可靠的行为保证。

真相由多数决定

节点不能根据自己的信息来判断自身的状态。由于节点可能随时会失效,可能会暂停-假死,甚至最终无法恢复,因此,分布式系统不能完全依赖于单个节点。目前,许多分布式算法都依靠法定票数,即在节点之间进行投票。任何决策都需要来自多个节点的最小投票数,从而减少对特定节点的依赖。

最常见的法定票数是取系统节点半数以上(也有其他类型的法定人数)。如果某些节点发生故障,quorum机制可以使系统继续工作。由于系统只可能存在一个多数,绝不会有两个多数在同时做出相互冲突的决定,因此系统的决议是可靠的。

主节点与锁

有很多情况,我们需要在系统范围内只能有一个实例。例如:

  • 只允许一个节点作为数据库分区的主节点,以防止出现脑裂。
  • 只允许一个事务或客户端持有特定资源的锁,以防止同时写入从而导致数据破坏。
  • 只允许一个用户来使用特定的用户名,从而确保用户名可以唯一标识用户。

在分布式系统实现时需要额外注意:即使某个节点自认为它是“唯一的那个”,但不一定获得了系统法定票数的同意!当多数节点声明节点已失效,而该节点还继续充当“唯一的那个”,如果系统设计不周就会导致负面后果。该节点会按照自认为正确的信息向其他节点发送消息,其他节点如果还选择相信它,那么系统就会出现错误的行为。

如图,展示了由于不正确的加锁而导致数据破坏的例子。其设计目标是确保存储系统的文件一次只能由一个客户端访问,如果多个客户端试图同时写入该文件,文件就会被破坏。因此,在访问文件之前客户端需要从锁服务获取访问租约。

【数据系统—分布式系统】分布式系统的挑战_第1张图片

这个问题属于前面“进程暂停”中的一种情况:持有租约的客户端被暂停太久直到租约到期。然后另一个客户端已经获得了文件的锁租约,并开始写文件。接下来,当暂停的客户端重新回来时,它仍然认为合法持有锁并尝试写文件。结果导致客户2的文件写入被破坏。

Fencing令牌

当使用锁和租约机制来保护资源的并发访问时,必须确保过期的“唯一的那个”节点不能影响其他正常部分。要实现这一目标,可以采用一种相当简单的技术fencing令牌。

假设每次锁服务在授予锁或租约时,还会同时返回一个fencing令牌,该令牌每授授予一次就会递增。然后,要求客户端每次向存储系统发送写请求时,都必须包含所持有的fencing令牌。只要存储服务器记录了已经完成了更高令牌号的写请求,就会拒绝携带较低令牌号的写请求。

只靠客户端自己检查锁状态是不够的,这种机制要求资源本身必须主动检查所持令牌信息。如果资源不支持额外的令牌检查,可以采取一些临时技巧来绕过去(例如,对于访问文件存储服务的情况,可以将令牌信息内嵌在文件名中)。总之,为了避免在锁保护之外发生请求处理,需要进行额外的检查机制。

在服务器端检查令牌可能看起来有些复杂,但其实是推荐的正确做法:系统服务不能假定所有的客户端都表现符合预期,事实上客户端通常由权限级别相对较低的人来操作运行,因此存在一定的误用、滥用风险,从安全角度讲,服务端必须防范这种来自客户端的滥用。

拜占庭故障

如果节点存在“撒谎”的情况(即故意发送错误的或破坏性的响应),那么分布式系统处理的难度就上了一个台阶。例如,节点明明没有收到某条消息,但却对外声称收到了。这种行为称为拜占庭故障,在这样不信任的环境中需要达成共识的问题也被称为拜占庭将军问题。

然而,在我们所讨论的这些系统中,我们可以安全地假定没有拜占庭式的故障。在数据中心里,所有的节点都是由一个组织来集中控制(可信任),辐射水平也足够低因而内存损坏可以忽略。解决拜占庭容错的系统协议异常复杂,而容错的嵌入式系统还依赖于硬件层面的支持。因而在绝大多数服务器端数据系统中,部署拜占庭容错解决方案基本不太可行。

尽管我们假设节点通常是诚实的,但依然推荐增加必要的机制来防范一些不那么恶意的“谎言”。例如由于硬件问题造成的无效消息、软件bug和配置错误。这种保护机制显然并不是完整的拜占庭式容错,无法防范敌手的攻击,但它们更为简单实用,可以帮助提高软件系统的可靠性和健壮性。例如:

  • 由于硬件问题或操作系统、驱动程序、路由器等方面的错误,导致网络数据包有时出现损坏。通常,可以借助TCP/UDP中内置的数据包校验和来发现这类问题,但有时他们会逃避检测。此时,一个简单的防范措施是在应用层添加校验和。
  • 对公众开放的应用必须仔细检查用户的所有输入,例如输入值是否在合理的范围内,并限制字符串的大小,防止分配超大内存导致拒绝服务攻击。
  • NTP客户端最好配置多个时间服务器。同步时间时,连接到多个时间服务器,收到回应之后要评估时间偏差,使得多数服务器就一定的时间范围达成一致。

理论系统模型与现实

目前分布式系统方面已有许多不错的具体算法,这些算法需要容忍本章所讨论的各种故障。算法的实现不能过分依赖特定的硬件和软件配置。这就要求我们需要对预期的系统错误进行形式化描述。我们通过定义一些系统模型来形式化描述算法的前提条件。关于计时方面,有三种常见的系统模型:

同步模型

同步模型假定有上界的网络延迟,有上界的进程暂停和有上界的时钟误差。注意,这并不意味着完全同步的时钟或者网络延迟为零。它只意味着你清楚地了解网络延迟、暂停和时钟漂移不会超过某个固定的上限。大多数实际系统的实际模型并非同步模型,因为无限延迟和暂停确实可能发生。

部分同步模型

部分同步意味着系统在大多数情况下像一个同步系统一样运行,但有时候会超出网络延迟、进程暂停和时钟漂移的预期上界。这是一个比较现实的模型:大多数情况下,网络和进程比较稳定,但是我们必须考虑到任何关于时机的假设都有偶尔违背的情况,而一旦发生,网络延迟,暂停和时钟偏差可能会变得非常大。

异步模型

在这个模型中,一个算法不会对时机做任何的假设,甚至里面根本没有时钟(也就没有超时机制)。某些算法可以支持纯异步模型,但并不常见。

除了时机之外,我们还需要考虑节点失效。有以下三种最常见的节点失效系统模型:

崩溃-中止模型

在崩溃-中止模型中,算法假设一个节点只能以一种方式发生故障,即遭遇系统崩溃。这意味着节点可能在任何时候突然停止响应,且该节点以后永远消失,无法恢复。

崩溃-恢复模型

节点可能会在任何时候发生崩溃,且可能会在一段未知的时间之后得到恢复并再次响应。在崩溃-恢复模型中,节点上持久性存储的数据会在崩溃之后得以保存,而内存中状态可能会丢失。

拜占庭(任意)失效模型

节点可能发生任何事情,包括试图作弊和欺骗其他节点。

对于真实系统的建模,最普遍的组合是崩溃-恢复模型结合部分同步模型。那么上层的分布式算法该如何应对这样的模型呢?

算法的正确性

为了定义算法的正确性,我们可以描述它的属性信息。例如,对于锁服务的fencing令牌生成算法,要求算法具有以下属性:

唯一性

两个令牌请求不能获得相同的值。

单调递增

如果请求x返回了令牌tx,请求y返回了令牌ty,且x在y开始之前先完成,那么tx < ty

可用性

请求令牌的节点如果不发生崩溃则最终一定会收到响应。

如果针对某个系统模型的算法在各种情况下都能满足定义好的属性要求,那么我们称这个算法是正确的。

安全与活性

为进一步加深理解,有必要区分两种不同的属性:安全性和活性。在上面的例子中,唯一性和单调递增属于安全属性,而可用性则属于活性。

这两种性质有何区别?一种理解思路是,活性的定义中通常会包括暗示“最终”一词(最终一致性也是一种活性)。安全性通常可以理解为“没有发生意外”,而活性则类似“预期的事情最终一定会发生”。

如果违反了安全属性,我们可以明确指向发生的特定的时间点(例如,唯一性如果被违反,我们可以定位到具体哪个操作产生了重复令牌)。且一旦违反安全属性,违规行为无法撤销,破坏已实际发生。

活性则反过来:可能无法明确某个具体的时间点(例如一个节点发送了一个请求,但还没有收到响应),但总是希望在未来某个时间点可以满足要求。

区分安全性和活性的一个好处是可以帮助简化处理一些具有挑战性的系统模型。通常对于分布式算法,要求在所有可能的系统模型下,都必须符合安全属性。

而对于活性,则存在一些必要条件。例如,我们可以说,只有在多数节点没有崩溃,以及网络最终可以恢复的前提下,我们才能保证最终可以收到响应。部分同步模型的定义即要求任何网络中断只会持续一段有限的时间,然后得到了修复,系统最终返回到同步的一致状态。

将系统模型映射到现实世界

安全性、活性以及所建立的系统模型对于评测分布式算法的正确性意义重大。然而,很明显系统模型只是对现实情况的简化抽象,实践中具体实施算法时,各种因素混杂在一起会提出更严峻的挑战。

例如,在崩溃-恢复模型中,算法通常假设保存在持久性介质的数据可以安然无恙。但是,如果硬盘上的数据发生损坏,或者由于硬件错误,配置错误等导致数据被清除,会发生什么后果呢?即使硬盘本身正确连接到服务器,但服务器存在固件错误,导致重启时无法正确识别硬盘,又会发生什么情况?

Quorum算法要求节点必须记录之前对外所宣告的数据。如果节点发生意外而丢弃存储的数据,会打破法定条件并破坏算法的正确性。

算法的理论描述可以简单地宣称某些事情绝不会发生,不过真正去实现时最好还是有一些必要的代码来简单处理一些几乎不可能发生的事情,即使只是去输出一些提醒信息和程序退出错误代码。

你可能感兴趣的:(分布式)