要使一个系统可靠,就要确保即使出了问题发生故障,整个系统仍能继续工作。要预见并处理所有可能的故障并非易事。
使用 分布式系统(distributed system)与在单台计算机上编写软件有着根本区别——最主要的区别,就是事情有了许多新奇而刺激的出错方式。
在一台计算机上编写程序时,通常会以相当可预测的方式运行,要么正常工作,要么彻底罢工。
从根本上说,单台计算机上的软件不应该时灵时不灵:只要硬件正常,同样的操作总会产生同样的结果(也就是 确定性的,deterministic)。如果硬件出了问题(例如内存损坏或连接器松动),后果通常是整个系统失效(例如内核恐慌、“蓝屏死机”或无法启动)。一台运行着良好软件的计算机,通常要么功能完好,要么完全失效,而不会停留在两者之间。
发生内部故障时,宁愿让计算机彻底崩溃,也不愿让它返回错误结果,因为后者即难处理又容易造成混乱。
在分布式系统中,即使其他部分工作正常,系统的某些部分也很可能以不可预知的方式发生故障。这叫作 部分失效(partial failure) 。棘手之处在于,部分失效是 非确定性的(nondeterministic) :任何涉及多个节点及其网络的操作,有时能够成功,有时却会莫名其妙地失败。
分布式系统大多是 无共享系统,也就是一组通过网络连接的机器,网络是这些机器彼此通信的唯一途径。即使存储本身是共享的 如 Amazon S3,机器 也仍要通过网络与共享存储服务通信。
互联网和数据中心里的大多数内部网络(通常是以太网)都是 异步分组网络(asynchronous packet network) 。在这种网络中,一个节点可以向另一个节点发送消息(即数据包),但网络既不保证消息何时到达,也不保证它一定能够到达。如果你发出请求并等待响应,可能会发生许多问题。
发送方甚至无法判断数据包是否送达,唯一的办法是由接收方发回响应消息,而这个响应同样可能丢失或延迟。 在异步网络中,这些情形无法区分。
处理这个问题的惯常办法是设置 超时(timeout) :等待一段时间后便放弃,并假定响应不会再来。然而,即使发生超时,你仍然不知道远程节点是否收到了请求(如果请求仍在某处排队,那么即便发送方已经放弃,它仍可能在稍后送达接收方)。
网络数据包有大小上限(通常只有几千字节),但许多应用程序需要发送无法装进单个数据包的消息,例如请求和响应。这类应用程序通常使用 TCP(传输控制协议)建立一条 连接(connection),把较大的数据流拆成一个个数据包,再在接收端重新组装起来。
TCP 常被说成能提供“可靠”的传输,这里的“可靠”是指:它能检测并重传丢失的数据包,发现顺序错乱的数据包并将其恢复为正确顺序,还能用简单的校验和检测数据包损坏。TCP 也会判断应当以多快的速度发送数据,既尽可能快速传输,又不至于压垮网络或接收节点;这叫作 拥塞控制(congestion control) 、流量控制(flow control) 或 背压(backpressure) 。
当你把数据写入套接字来“发送”时,数据其实不会立即发出,只会先进入操作系统管理的缓冲区。拥塞控制算法判断目前有能力发送数据包后,才会从缓冲区取出一个数据包大小的数据,交给网络接口。数据包会经过若干交换机和路由器,最终由接收节点的操作系统把数据放进接收缓冲区,并向发送方发回确认包。直到这时,接收端操作系统才会通知应用程序又有数据到达。
既然 TCP 提供了“可靠性”,是不是就不必再操心网络不可靠了?遗憾的是,并非如此。如果在一定的超时时间内没有收到确认,TCP 会认定数据包必定已经丢失,但它同样无法判断丢掉的究竟是发出的数据包,还是返回的确认包。TCP 虽然可以重发,却不能保证重发的数据包一定能够到达。如果网线被拔了,TCP 可没法替你把它插回去。最终,经过可配置的超时时间后,TCP 会放弃重试,并向应用程序报告错误。
如果 TCP 连接因错误而关闭——或许是远程节点崩溃了,也可能是网络中断了——你无法知道远程节点究竟处理了多少数据 6。即使 TCP 已经确认某个数据包送达,也只说明远程节点的操作系统内核收到了它;应用程序仍可能在处理这份数据之前就崩溃。如果要确认请求成功,必须由应用程序本身明确返回成功响应。
尽管如此,TCP 依然非常有用,因为它让我们能够方便地收发无法装进单个数据包的消息。建立 TCP 连接后,还可以通过同一条连接发送多个请求和响应。通常的做法是先发送一个头部,注明紧随其后的消息有多少字节,再发送消息本身。HTTP 和许多 RPC 协议,REST 与 RPC 都是这样工作的。
没有让网络彻底可靠的办法,即使在由一家公司运营的数据中心这类受控环境中,网络问题也可能频繁得出人意料
当网络的一部分因网络故障而与其余部分隔绝时,这种情况有时称为 网络分区(network partition)或 网络分裂(netsplit) ,但它与其他类型的网络中断并没有本质区别。网络分区与存储系统的分片无关,后者有时也称为 分区。
即使环境很少遇到网络故障,故障 有可能 发生这一事实也意味着软件必须能够处理它。只要通过网络通信,就有可能失败——这一点无可回避。
许多系统需要自动检测发生故障的节点,例如:
遗憾的是,网络的不确定性让人很难判断节点是否仍在工作。在某些特定情况下,你也许能收到明确表明某处出了问题的反馈:
能够迅速得知远程节点宕机固然有用,但不能指望总有这样的反馈。发生问题时,你或许会从协议栈的某一层收到错误响应,但通常必须假定自己什么响应也收不到。你可以重试几次,等待超时;如果超时时间内一直没有回复,最终便宣告该节点已经死亡。
如果超时是检测故障的唯一可靠方法,那么超时时间应该设为多长?遗憾的是,这个问题没有简单答案。
较长的超时意味着要等很久才能宣告节点死亡(在此期间,用户可能只能等待,或不断看到错误消息)。较短的超时可以更快发现故障,却更容易把只是暂时变慢的节点(例如节点或网络出现负载峰值)误判为死亡。
过早宣告节点死亡会带来麻烦:如果节点其实仍然活着,并且正在执行某个操作(例如发送电子邮件),此时另一个节点又接管了它的工作,同一个操作最终可能执行两次。
宣告节点死亡后,它承担的职责就要转移给其他节点,这会额外增加其他节点和网络的负担。如果系统本来就在高负载下勉力支撑,过早宣告节点死亡可能让局面进一步恶化。尤其可能出现这样的情况:节点其实没有死,只是过载导致响应缓慢;把它的负载转移给其他节点,又可能引发级联失效(极端情况下,所有节点互相宣告对方死亡,整个系统彻底停止工作)。
设想一个虚构的系统,其网络保证数据包的最大延迟:每个数据包要么在时间 d 以内送达,要么丢失,绝不会在超过 d 之后才送达。再假定我们能够保证,任何尚未失效的节点总会在时间 r 以内处理完请求。在这种情况下,每个成功的请求都能保证在 2 d + r 以内收到响应;如果过了这么久仍未收到响应,就可以断定网络或远程节点没有正常工作。假如这些保证真的成立,那么 2 d + r 就是合理的超时时间。
遗憾的是,我们使用的大多数系统都不具备上述任何一项保证:异步网络具有 无界延迟(unbounded delay;也就是说,它会尽快尝试送达数据包,但数据包所需的传输时间没有上限),大多数服务器实现也不能保证一定在某个最长时间内处理完请求。对故障检测而言,系统仅仅在大多数时候运行得很快还不够:如果超时时间设得很短,一次短暂的往返时间尖峰就足以打乱整个系统。
在计算机网络上,数据包延迟的变化通常是排队造成的:
此外,当 TCP 检测到数据包丢失并自动重传时,应用程序虽然不会直接看到丢包,却会感受到由此造成的延迟:先等待超时,再等待重传的数据包得到确认。
TCP 与 UDP:
一些对延迟敏感的应用程序(例如视频会议和 IP 语音,即 VoIP)使用 UDP 而非 TCP。这是在可靠性与延迟波动之间做出的权衡:UDP 不进行流量控制,也不重传丢失的数据包,因而避开了网络延迟发生波动的部分原因(不过,它仍会受到交换机排队和调度延迟的影响)。
如果数据一旦延迟就不再有价值,UDP 是很好的选择。例如在 VoIP 通话中,等到音频该从扬声器播放时,通常已经来不及重传丢失的数据包。这种情况下,重传毫无意义——应用程序只能用静音填补丢失数据包对应的时间片(听起来就是声音短暂中断),然后继续播放后面的音频。真正的重试发生在人这一层。(“能再说一遍吗?刚才声音断了一下。”)
还有用UDP模拟的TCP一样的可靠连接协议 QUIC、KCP。
在公共云和多租户数据中心中,许多客户共享同一批资源:网络链路和交换机是共享的,甚至每台机器的网络接口和 CPU(使用虚拟机时)也是共享的。处理大量数据时,可能耗尽网络链路的全部容量,使其达到 饱和(saturation) 。你既无法控制,也无法了解其他客户如何使用共享资源;如果附近某个 吵闹的邻居(noisy neighbor) 正在大量消耗资源,网络延迟就可能剧烈波动
更好的办法是不用固定配置的超时时间,而让系统持续测量响应时间及其波动(抖动,jitter),再根据观测到的响应时间分布自动调整超时。Phi 累积故障检测器 就采用了这种方法,Akka 和 Cassandra 等系统都在使用它。TCP 的重传超时也以类似方式工作。
如果可以指望网络在某个固定的最长延迟内送达数据包,而且绝不丢包,分布式系统就会简单得多。为什么不能在硬件层面解决这个问题,让网络变得可靠,从而使软件不必再操心呢?
不妨把数据中心网络与传统的固定电话网络(非蜂窝网络,也非 VoIP)比较一下。传统电话网络极其可靠:音频帧延迟和通话中断都很罕见。电话通话需要持续保持较低的端到端延迟,并提供足够带宽来传输语音采样。如果计算机网络也能具备类似的可靠性与可预测性,岂不是很好?
通过电话网络拨号时,网络会建立一条 电路(circuit):从一名通话者到另一名通话者的整条路径上,都会为这次通话分配固定且有保证的带宽。这条电路会一直保留到通话结束。例如,ISDN 网络以每秒 4,000 帧的固定速率运行。建立通话时,每一帧都会在两个方向上各分配 16 位空间。因此在整个通话期间,双方都能保证每 250 微秒恰好发送 16 位音频数据。
这类网络是 同步的(synchronous) :即使数据要经过多台路由器,也不会排队,因为每一跳都已经为这次通话预留了 16 位的空间。由于无需排队,网络的最大端到端延迟是固定的。我们称之为 有界延迟(bounded delay) 。
注意,电话网络中的电路与 TCP 连接截然不同:电路会预留固定数量的带宽,只要电路存在,其他人就无法使用这部分带宽;TCP 连接的数据包则会伺机利用当时可用的任意网络带宽。你可以交给 TCP 一块大小不定的数据(例如电子邮件或网页),它会尽力在最短时间内传输完毕。TCP 连接空闲时不会占用带宽,偶尔发送的保活包除外。
如果数据中心网络和互联网采用电路交换,那么建立电路时就可以同时确定有保证的最大往返时间。但它们并非如此:以太网和 IP 都是分组交换协议,数据包会排队,因而网络延迟没有上界。这些协议中根本没有“电路”的概念。
为什么数据中心网络和互联网要使用分组交换?因为它们针对 突发流量(bursty traffic)做了优化。音频或视频通话在整个通话期间每秒传输的位数相当稳定,很适合使用电路。相比之下,请求网页、发送电子邮件或传输文件并没有特定的带宽要求,我们只希望它们尽快完成。
如果要通过电路传输文件,就必须先猜测应当分配多少带宽。猜得太低,传输速度会慢得毫无必要,同时还有网络容量闲置;猜得太高,电路又无法建立,因为网络不能为无法保证带宽分配的电路放行。因此,用电路传输突发数据既浪费网络容量,又使传输无谓地变慢。TCP 则不同,它会根据可用的网络容量动态调整数据传输速率。
时钟和时间都很重要。应用程序会以各种方式依赖时钟,回答下面这些问题:
问题 1~4 测量的是 持续时间(duration;例如从发送请求到收到响应之间的时间间隔),问题 5~8 描述的则是 时间点(point in time;在某个具体日期和时刻发生的事件)。
网络中的每台机器都有自己的时钟,而且它是实实在在的硬件设备,通常是石英晶体振荡器。这些设备并不十分精确,因此每台机器都各有一套时间观念,可能比其他机器走得稍快或稍慢。
时钟可以在一定程度上同步:最常用的机制是网络时间协议(NTP),它根据一组服务器报告的时间来校准计算机时钟;这些服务器又从 GPS 接收器等更精确的时间源取得时间。
现代计算机至少有两种不同的时钟,日历时钟(time-of-day clock) 和 单调时钟(monotonic clock) 。它们虽然都用来度量时间,却服务于不同目的,因此必须加以区分。
日历时钟所做的,正是人们直觉上认为时钟应该做的事:按照某种历法返回当前日期和时间(也称为
墙上时钟时间,wall-clock time)。例如,Linux 的
clock_gettime(CLOCK_REALTIME) 和
Java 的 System.currentTimeMillis() 返回从
纪元(epoch)至今经过的秒数(或毫秒数):这里的纪元是格里高利历中的
1970 年 1 月 1 日 UTC 零时,且不计闰秒。有些系统使用其他日期作为参考点。
日历时钟通常会与 NTP 同步,因此理想情况下,一台机器上的某个时间戳与另一台机器上的同一时间戳表示同一时刻。
日历时钟也有各种怪异之处,尤其是当本地时钟比 NTP 服务器快得太多时,它可能会被强制重置,看起来就像突然跳回了过去。这样的跳变,以及闰秒造成的类似跳变,使日历时钟不适合测量已经过去了多长时间。
夏令时(DST)开始或结束时,日历时钟也可能跳变;只要始终采用没有夏令时的 UTC 时区,就能避开这类跳变。
单调时钟适合测量持续时间(时间间隔),例如超时时间或服务响应时间。Linux
的 clock_gettime(CLOCK_MONOTONIC)
、clock_gettime(CLOCK_BOOTTIME) 和 Java 的
System.nanoTime()
都属于单调时钟。之所以叫“单调”,是因为它保证只会向前走(日历时钟却可能突然跳回过去)。
可以先读取一次单调时钟,做些事情,稍后再读一次。两次读数之 差 就是其间经过的时间——它更像秒表,而不是挂钟。
单调时钟的 绝对 读数没有任何意义:它可能表示计算机启动至今的纳秒数,也可能采用其他任意起点。尤其不能比较两台不同计算机的单调时钟读数,因为它们表示的并不是同一回事。
在有多个 CPU 插槽的服务器上,每颗 CPU 都可能有自己的计时器,而且未必与其他 CPU 同步。操作系统会补偿其中的差异,尽力让应用程序线程看到单调递增的时钟,即使线程在不同 CPU 之间调度也是如此。不过,对这样的单调性保证最好还是有所保留。
如果 NTP 发现计算机的本地石英钟比 NTP 服务器走得更快或更慢,可以调节单调时钟向前推进的速率,这叫作对时钟进行 渐进校准(slewing) 。默认情况下,NTP 最多可以把时钟速率加快或减慢 0.05%,但不能让单调时钟突然向前或向后跳变。单调时钟通常有很好的分辨率:在大多数系统上,它能测量微秒级甚至更短的时间间隔。
在分布式系统中,用单调时钟测量经过的时间(例如超时)通常没有问题,因为这不要求不同节点的时钟彼此同步,而且测量中的轻微误差也不会造成太大影响。
单调时钟不需要同步,但日历时钟只有根据 NTP 服务器或其他外部时间源进行校准才有用。遗憾的是,让时钟显示正确时间的手段远没有想象中那么可靠和准确——硬件时钟与 NTP 都可能反复无常。下面只是其中几个例子:
欧洲针对金融机构的 MiFID II 法规要求所有高频交易基金把时钟与 UTC 的误差控制在 100 微秒以内,以便排查“闪崩”等市场异常,并帮助发现市场操纵。
借助专用硬件(GPS 接收器和/或原子钟)、精确时间协议(PTP),再辅以审慎的部署与监控,就能达到这样的精度。不过,只依赖 GPS 也有风险,因为 GPS 信号很容易受到干扰;某些地方(例如军事设施附近)甚至经常发生这种情况。一些云服务商已经开始为虚拟机提供高精度时钟同步。即便如此,时钟同步仍需格外小心。如果 NTP 守护进程配置有误,或防火墙阻断了 NTP 流量,漂移造成的时钟误差很快就会变得很大。
时钟的问题在于,它看似简单易用,陷阱却多得惊人:一天未必恰好有 86,400 秒,日历时钟可能倒着走,一个节点所认为的时间也可能与另一个节点相差很大。
错误的时钟很容易无人察觉。如果机器的 CPU 有缺陷,或网络配置有误,它多半会彻底无法工作,因此问题很快就会暴露并得到修复。反之,如果石英钟有缺陷,或 NTP 客户端配置有误,那么即使时钟逐渐偏离现实越来越远,大多数事情看上去仍然运行正常。如果某段软件依赖精确同步的时钟,最终结果更可能是隐蔽而细微的数据丢失,而不是一场惊天动地的崩溃。
如果使用的软件要求时钟同步,就必须仔细监控所有机器之间的时钟偏差。凡是时钟与其他节点偏离太远的节点,都应当被宣告死亡并移出集群。这样的监控可以确保在故障时钟造成太大破坏以前及时发现它。
例如,两个客户端都向分布式数据库写入时,谁先到达?哪次写入更新?
展示了 多主复制 的数据库中,日历时钟的一种危险用法。
在 最后写入者胜 LLW(丢弃并发写入),解决不同节点并发写入值之间冲突的一种办法是 最后写入者胜(LWW):对于同一个键,只保留时间戳最大的写入,丢弃所有时间戳更早的写入。上面这种就会因为时钟问题系统出问题了,节点 2 收到这两个事件后,会错误地断定 x = 1 才是更新的值,并丢弃对 x = 2 的写入,于是递增操作便丢失了。。
要避免这个问题,可以确保每当覆盖一个值时,新值的时间戳一定高于被覆盖值,即使这个时间戳已经超前于写入者的本地时钟。不过,这样就要付出额外读取的代价,先找出当前最大的时间戳。Cassandra 和 ScyllaDB 等系统希望在一次往返中写入所有副本,因此它们直接使用客户端时钟生成的时间戳,并采用最后写入者胜策略。这种做法存在一些严重问题:
所谓的 逻辑时钟(logical clock) 以递增计数器为基础,而不是以振荡的石英晶体为基础,因此是为事件排序时更安全的选择。逻辑时钟既不度量一天中的时刻,也不度量经过了多少秒,只记录事件之间的相对顺序(一个事件发生在另一个事件之前还是之后)。与之相对,日历时钟和单调时钟度量真实流逝的时间,因此也称为 物理时钟(physical clock)。
机器的日历时钟也许能以微秒甚至纳秒为分辨率提供读数,但测量得如此精细,并不表示读数真的精确到了这个程度。事实上,它大概率没有这么准。
即使每分钟都与局域网中的 NTP 服务器同步一次,不精确的石英钟也很容易漂移数毫秒。若使用公共互联网上的 NTP 服务器,最理想的精度大概也只有数十毫秒;遇到网络拥塞时,误差很容易飙升到 100 毫秒以上。
不应把一次时钟读数理解为一个精确的时间点,它更像是落在某个置信区间内的一段时间范围。例如,系统也许有
95% 的把握认为当前时刻位于这一分钟的第 10.3 秒至第 10.5
秒之间,但无法知道得更精确。如果时间只能确定到 ±100
毫秒,那么时间戳中精确到微秒的那些数字基本毫无意义。
不确定性的边界可以根据时间源来计算。如果计算机直接连接着 GPS 接收器或原子钟,预期误差范围取决于设备本身;对 GPS 而言,还取决于卫星信号的质量。如果从服务器获取时间,不确定性大致等于:自上次与服务器同步以来石英钟的预期漂移,加上 NTP 服务器自身的不确定性,再加上与服务器之间的网络往返时间——这是第一步近似,并且假定服务器值得信任。
大多数系统都不会暴露这种不确定性。例如,调用
clock_gettime()
时,返回值并不会告诉你时间戳的预期误差,因此你无从得知它的置信区间究竟是五毫秒,还是五年。
Google Spanner 的 TrueTime API 和 Amazon 的 ClockBound
都会明确报告本地时钟的置信区间。查询当前时间时,得到的是两个值:[earliest, latest],分别表示
最早可能 与 最晚可能
的时间戳。根据对不确定性的计算,时钟能够断定真实的当前时间位于这个区间内。区间的宽度取决于多种因素,其中包括本地石英钟距离上次与更精确的时间源同步已经过去多久。
多版本并发控制(MVCC),MVCC 需要单调递增的事务 ID。如果某次写入发生在快照之后(也就是说,写入的事务 ID 大于快照的事务 ID),那么快照事务就看不到这次写入。在单节点数据库上,用一个简单的计数器就足以生成事务 ID。
当数据库分布在许多机器上,甚至横跨多个数据中心时,生成全局单调递增的事务 ID(覆盖所有分片)就很困难,因为这需要协调。事务 ID 还必须反映因果关系:如果事务 B 读取或覆盖了事务 A 先前写入的值,B 的事务 ID 就必须大于 A,否则快照将不一致。面对大量短小快速的事务,在分布式系统中生成事务 ID 会成为难以承受的瓶颈。
能不能直接把同步日历时钟产生的时间戳当作事务ID?如果时钟同步能做得足够好,时间戳的确具备所需属性:越晚的事务,时间戳越大。当然,问题依旧在于时钟精度的不确定性。
Spanner 正是以这种方式实现跨数据中心的快照隔离。它利用 TrueTime API
报告的时钟置信区间,依据的是下面这个观察:假设有两个置信区间,每个区间都由最早和最晚的可能时间戳组成(
A = [A最早, A最晚] ,B = [B最早, B最晚]
),如果两个区间不重叠(即
A最早 < A最晚 < B最早 < B最晚),那么 B 必定发生在
A 之后,不存在任何疑问。只有当两个区间发生重叠时,我们才无法确定 A 与 B
的先后顺序。
为了确保事务时间戳能够反映因果关系,Spanner 会在提交读写事务之前,刻意等待相当于置信区间长度的一段时间 。这样一来,任何可能读到该数据的事务都会发生在足够晚的时刻,使它们的置信区间不再重叠。为了尽量缩短等待,Spanner 需要把时钟不确定性控制得尽可能小;为此,Google 在每个数据中心都部署了 GPS 接收器或原子钟,从而把时钟同步误差控制在大约 7 毫秒以内。
严格来说,Spanner 并非一定要使用原子钟和 GPS 接收器:真正重要的是获得置信区间,精确的时间源只是帮助缩小这个区间。其他系统也开始采用类似方法。例如,YugabyteDB 在 AWS 上运行时可以利用 ClockBound,还有若干系统也开始在不同程度上依赖时钟同步。
一个在分布式系统中危险使用时钟的例子。假设某个数据库的每个分片都只有一个领导者,而且只有领导者可以接受写入。一个节点怎么知道自己仍是领导者(没有被其他节点宣告死亡),因而可以安全地接受写入呢?
一种办法是由领导者向其他节点取得一份 租约(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
秒,随后才恢复。在处理请求时,租约很可能早已过期,另一个节点也已接任领导者。然而,没有任何东西会告诉这个线程它刚才停了那么久;直到循环进入下一轮,它才会发现租约已经过期——而在此之前,它可能已经处理了请求,做出了不安全的操作。
造成长时间暂停的原因多种多样:
SIGSTOP 信号也会令其暂停,例如在 shell
中按 Ctrl-Z。这个信号会立即停止给进程分配 CPU 周期,直到
SIGCONT
令它恢复;随后,它会从先前停下的位置继续运行。即使你的环境通常不用
SIGSTOP,运维人员也可能不小心发出这个信号。在单台机器上编写多线程代码时,有不少成熟工具来保证线程安全:互斥锁、信号量、原子计数器、无锁数据结构、阻塞队列等等。遗憾的是,这些工具不能直接套用到分布式系统,因为分布式系统没有共享内存,只有经由不可靠网络传递的消息。
分布式系统中的节点必须假定:自己的执行可能在任意时刻暂停很长时间,哪怕正处于函数执行途中。暂停期间,外部世界仍在继续运转,甚至可能因为这个节点迟迟没有响应而宣告它死亡。最终节点恢复运行时,甚至意识不到自己曾经“睡着”,直到稍后再次读取时钟。
在许多编程语言与操作系统中,线程和进程都可能暂停任意长的时间。不过,只要投入足够努力,这些暂停的原因 确实可以 消除。
有些软件运行在这样的环境中:如果不能在规定时间内响应,就可能造成严重损害。控制飞机、火箭、机器人、汽车及其他实体设备的计算机,必须快速而且可预测地响应传感器输入。在这些系统中,软件必须赶在明确规定的 截止时间(deadline) 之前响应;错过截止时间,就可能导致整个系统失效。这样的系统称为 硬实时(hard real-time)系统 。
例如,汽车的车载传感器检测到碰撞正在发生时,你绝不会希望安全气囊因为控制系统恰好遭遇 GC 暂停而延迟弹出。
要在系统中提供实时保证,需要软件栈的每一层共同支持:需要 实时操作系统(RTOS) ,保证在规定的时间间隔内为进程分配 CPU 时间;库函数必须说明最坏情况下的执行时间;动态内存分配可能要受到限制,甚至完全禁止(虽然存在实时垃圾回收器,应用程序仍必须确保不会给 GC 安排太多工作);此外还要进行海量测试与测量,验证系统确实满足保证。
这些要求不仅带来大量额外工作,也严重限制了可用的编程语言、库和工具,因为大多数语言和工具都不提供实时保证。正因如此,实时系统开发极其昂贵,最常用于安全攸关的嵌入式设备。而且,“实时”并不等于“高性能”——事实上,实时系统的吞吐量可能更低,因为及时响应必须优先于一切。
对于大多数服务器端数据处理系统,实时保证既不经济,也不合适。因此,这些系统只能承受非实时环境带来的进程暂停与时钟不稳定。
垃圾回收曾是造成进程暂停的最大原因之一。好在 GC 算法已经有了长足进步:如今,经过适当调优的回收器通常只会暂停几毫秒。Java 运行时提供并发标记清除(CMS)、垃圾优先(G1)、Z 垃圾回收器(ZGC)、Epsilon 和 Shenandoah 等回收器,分别针对高频创建对象、大型堆等不同内存使用特征进行优化。相比之下,Go 提供的是一种更简单、尝试自我优化的并发标记清除垃圾回收器。
如果必须彻底避免 GC 暂停,可以选择完全没有垃圾回收器的语言。例如,Swift 使用自动引用计数来判断何时能够释放内存;Rust 和 Mojo 则通过类型系统追踪对象的生命周期,让编译器判断内存需要保留多久。
也可以继续使用带垃圾回收的语言,同时减轻暂停造成的影响。一种做法是把 GC 暂停看作节点短暂的计划内停机:某个节点进行垃圾回收时,由其他节点处理客户端请求。如果运行时能够提前告知应用程序节点即将进行 GC 暂停,应用程序就可以停止向该节点发送新请求,等待它处理完尚未完成的请求,然后趁没有请求进行时执行 GC。这种技巧能向客户端隐藏 GC 暂停,并降低响应时间的高百分位数 。
这种思路还有一个变体:只让垃圾回收器处理容易快速回收的短命对象,并定期重启进程,赶在长期存活对象积累到需要执行一次完整 GC 之前。每次可以只重启一个节点,并在计划重启前先把流量从该节点迁走,就像滚动升级一样。
这些措施无法彻底杜绝垃圾回收暂停,却能切实减轻其对应用程序的影响。
分布式系统与单机程序不同,系统没有共享内存,只能通过延迟不定的不可靠网络来传递消息,还可能遭遇部分失效、不可靠的时钟和进程暂停。
网络中的一个节点不可能 确切知道 其他节点的任何事情,只能根据收到(或没有收到)的消息作出猜测。一个节点只有与另一个节点交换消息,才能得知对方处于什么状态,例如存储了哪些数据、是否正常运行等等。如果远程节点没有响应,就无从得知它的状态,因为网络问题与节点自身的问题无法可靠地区分。
在分布式系统中,可以明确写出对系统行为所作的假设(即 系统模型,system model),再把实际系统设计成符合这些假设, 还可以证明某个算法在特定系统模型中能够正确运行,这意味着,即使底层系统模型只提供极少保证,也仍然可以实现可靠的行为。
设想一个存在非对称故障的网络:某个节点可以收到发给它的所有消息,但它发出的消息都会丢失或延迟。这个节点明明工作得完全正常,也在接收其他节点的请求,可其他节点就是听不见它的回应。等到超时后,其他节点由于一直收不到回复,便宣告它已经死亡。
这个半失联的节点被拖向墓地,一路挣扎高喊 我还没死,可惜谁也听不见它的呼喊,送葬队伍仍以坚韧不拔的决定继续前进。
半失联的节点也许会发现自己发的消息得不到其他节点的确认,于是意识到网络必定出了故障。然而,其他节点仍会错误地宣告它死亡,而它对此无能为力。
第三种场景是,假设某个节点暂停执行一分钟。在此期间,它既不处理请求,也不发送响应。其他节点一边等待、一边重试,终于失去耐心,宣告它死亡并把它抬上灵车。最终暂停结束,节点的线程若无其事地继续执行。其他节点惊讶地看到,那个本应死去的节点突然精神抖擞地从棺材里探出头来,兴高采烈地与旁人聊天。刚恢复时,这个节点甚至不知道整整一分钟已经过去,也不知道自己已经被宣告死亡——在它看来,距离上次和其他节点交谈仿佛只过了一瞬间。
这些故事告诉我们,节点未必能相信自己对处境的判断。分布式系统不能只依赖某一个节点,因为节点随时可能失效,使系统陷入僵局而无法恢复。 因此,许多分布式算法依赖 法定人数(quorum) ,也就是让多个节点投票(读写仲裁):一项决策必须获得若干节点的最低票数,借此减少对任何单个节点的依赖。
宣告节点死亡的决定也是如此。如果达到法定人数的节点宣告另一个节点已经死亡,那么即使它觉得自己还活得好好的,也必须被视为死亡。单个节点必须服从法定人数作出的决定并下台。
常见的法定人数,是超过节点总数一半的绝对多数,当然也可以采用其他形式。多数法定人数让系统能在少数节点发生故障时继续工作:三个节点可以容忍一个故障节点,五个节点则可以容忍两个。与此同时,它仍然是安全的,因为系统中只能形成一个多数派,不可能同时出现两个作出冲突决定的多数派。
分布式应用程序中的锁和租约很容易被误用,也是程序缺陷的常见来源。
当系统要求某种东西只能有一个时,就可以使用租约,例如:
如果多个节点坚信自己持有租约,第三个例子无非是浪费计算资源,而前两个例子 数据可能丢失或损坏。
持有租约的客户端如果暂停太久,租约就会过期,另一个客户端此时可以取得同一文件的租约,并开始写入,等暂停的客户端恢复后,它误以为自己租约依然有效,也继续写入文件。于是便出现了脑裂,两个客户端的写入彼此冲突,最终损坏文。
类似的问题,如 client1 没有进程暂停,崩溃了,崩溃前向存储服务写入请求, 但因网络延迟,存储服务很久才收到请求。
僵尸(zombie) 一词有时用来形容这样的原租约持有者:它还不知道自己已经失去租约,仍把自己当作当前持有者行事。既然无法彻底杜绝僵尸,就必须确保它们不能以脑裂的形式造成任何破坏。这称为用 栅栏机制(fencing)隔离僵尸 。
有些系统试图通过关停僵尸来隔离它,例如断开它的网络连接、通过云服务商的管理界面关闭虚拟机,甚至直接切断机器电源。这种做法称为 STONITH,即“击毙另一个节点”。遗憾的是,它有几个问题:无法防范 图 9-5 所示的超长网络延迟;所有节点可能彼此关停;而等到僵尸被发现并关闭时,也许早已为时过晚,数据已经损坏。
一种更健壮的栅栏机制,既能防范僵尸,也能防范延迟请求。
假设锁服务每次授予锁或租约时,还会返回一个 栅栏令牌(fencing token),这是一个每次授予锁都会增大的数字, 例如由锁服务负责递增。随后可以要求客户端每次向存储服务发送写请求时,都必须带上自己当前的栅栏令牌。
栅栏令牌还有几种别称。在 Google 的锁服务 Chubby 中,它叫作 序列器(sequencer) ;在 Kafka 中则叫作 纪元编号(epoch number) 。在共识算法中,Paxos 的 投票编号(ballot number) 或 Raft 的 任期编号(term number) 也发挥着类似作用。
如果使用 ZooKeeper 作为锁服务,可以把事务ID zxid 或 节点版本 cversion 用作栅栏令牌。在 etcd 中,修订号与租约ID共同发挥类似作用,Hazelcast 的 FencedLock API 则会显式生成栅栏令牌。
这种机制要求存储服务能够检查写入所携带的令牌是否已经过时。另一种办法是 让服务支持类似原子比较并设置(CAS)的写入 :只有从当前客户端上次读取对象以后,没有其他客户端写过该对象,写入才会成功。对象存储服务就支持这类检查:Amazon S3 称之为 条件写入(conditional write) ,Azure Blob Storage 称之为 条件标头(conditional header) ,Google Cloud Storage 则称之为 请求前置条件(request precondition) 。
如果客户端只需写入一个支持这类条件写入的存储服务,那么锁服务多少有些多余,因为完全可以直接依托该存储服务来分配租约。不过,有了栅栏令牌以后,也可以把它用于多个服务或副本,确保原租约持有者在所有这些服务上都被隔离。
例如,假设存储服务是一个采用最后写入者胜来解决冲突的无主复制键值存储(无主复制)。在这样的系统中,客户端直接向每个副本发送写入,各副本根据客户端分配的时间戳,自行决定是否接受写入。
可以把写入者的栅栏令牌放在时间戳最高有效的若干位或数字中。这样就能确保,新租约持有者生成的任何时间戳,都大于原租约持有者生成的所有时间戳,即使原持有者的写入实际发生得更晚。
后续就不成问题了,因为后续的仲裁读会优先选择客户端2那个时间戳更大的写入, 而读修复或反熵过程最终会覆盖客户端1写入的值。
假定任何时刻只有一个节点持有租约并不安全,只要稍加留意,就可以借助 栅栏令牌防止僵尸与延迟请求造成任何破坏。
栅栏令牌能够发现并阻止 无意间 出错的节点,例如尚未发现租约已经过期的节点。但如果节点蓄意破坏系统保证, 只需发送带有伪造栅栏令牌的消息,就能轻易绕过这种防护。
如果节点可能撒谎,分布式系统问题就会困难得多。例如,一个节点可能在同一轮选举中投出多张互相矛盾的票。这种行为叫作 拜占庭故障(Byzantine fault) ,而在这种互不信任的环境中达成共识的问题,则称为 拜占庭将军问题(Byzantine Generals Problem) 。
拜占庭将军问题是所谓 两将军问题(Two Generals Problem) 的推广。两将军问题设想,两名军队将领必须就作战计划达成一致,但他们驻扎在不同地点,只能派信使互通消息,而信使有时会迟到或失踪(就像网络数据包一样)。在拜占庭版本的问题中,需要达成一致的将军有 n 名,但其中混入了若干叛徒。大多数将军都忠诚可靠,会发送真实消息;叛徒却可能发送虚假消息,企图欺骗并迷惑其他人。而谁是叛徒,事先无从得知。
如果某些节点发生故障、不遵守协议,或有恶意攻击者干扰网络时,系统仍能继续正确运行,就称这个系统具有 拜占庭容错(Byzantine fault tolerance,BFT)能力。这类问题在某些特定情形下确实很重要。例如:
数据中心里的节点都受自己阻止控制,一般没这种问题。在没有中央权威的点对点网络中,拜占庭容错才更重要。想了解可以去了解比特币系统中的工作量证明。
虽然通常假定节点是诚实的,但仍值得在软件中加入一些机制,防范 硬件问题、软件缺陷、配置错误,这些机制算不上完整的拜占庭容错,因为它们挡不住意志坚定的攻击者;但作为提高可靠性的手段,它们既简单又务实。例如:
算法的编写方式不应过度依赖其运行环境中具体的硬件与软件配置。这又要求我们以某种方式,把系统中预期会发生的故障类型形式化。为此,我们定义 系统模型(system model) :它是一种抽象,用来说明算法可以作出哪些假设。
关于时序假设,通常使用以下三种系统模型:
1 同步模型(synchronous model)
同步模型假定网络延迟、进程暂停和时钟误差都有上界。这并不表示时钟完全同步,也不表示网络延迟为零;它只是意味着,我们知道网络延迟、暂停和时钟漂移永远不会超过某个固定上限。对大多数实际系统而言,同步模型并不现实,因为正如本章所讨论的,无界延迟和暂停确实可能发生。
2 部分同步模型(partially synchronous model)
部分同步是指系统在 大多数时候 都像同步系统一样运行,但偶尔会突破网络延迟、进程暂停和时钟漂移的界限。对许多系统来说,这是一个现实的模型:绝大多数时候,网络与进程表现得相当规矩,否则我们什么事情也做不成;但我们也必须正视这样一个事实——任何时序假设都有可能偶尔失效。发生这种情况时,网络延迟、进程暂停和时钟误差都可能变得任意之大。
3 异步模型(asynchronous model)
在这种模型中,算法不能作出任何时序假设——事实上,它甚至没有时钟可用(因而也不能使用超时)。有些算法可以针对异步模型来设计,但这种模型限制极大。
关于节点失效,常见的节点系统模型 :
1 崩溃停止故障(crash-stop fault)
在 崩溃停止(crash-stop,或 故障停止,fail-stop)模型中,算法可以假定节点只有一种失效方式:崩溃。节点可能在任意时刻突然停止响应,此后便永远消失,再也不会回来。
2 崩溃恢复故障(crash-recovery fault)
我们假定节点可能在任意时刻崩溃,也可能在一段未知时间后重新开始响应。在崩溃恢复模型中,节点拥有能在崩溃后保留数据的稳定存储(即非易失性磁盘存储),但内存中的状态会丢失。
3 性能下降和功能不全
除了崩溃与重启,节点还可能变慢:它们也许仍能响应健康检查,却慢得无法完成任何实际工作。网络接口问题、内存压力垃圾回收、SSD 磨损不稳定、高温、机械振动 等等,这类情形称为 跛行节点(limping node)、灰色失效(gray failure)或 慢失效(fail-slow),甚至可能比彻底失效的节点更难处理。还有一种相关问题:进程不再执行原本应做的某些工作,其他功能却仍在继续,例如后台线程崩溃或死锁时。
4 拜占庭(任意)故障
节点可能做出任何行为,包括像上一节所述那样欺骗其他节点。
对现实系统建模时,带崩溃恢复故障的部分同步模型通常最有用。它允许出现无界网络延迟、进程暂停和慢节点。
要定义一个算法怎样才算 正确,可以描述它必须具备哪些属性。例如,排序算法的输出具有这样一项属性:对输出列表中的任意两个不同元素,位于左侧的元素都小于位于右侧的元素。这不过是用形式化语言定义一份列表何谓“有序”。
也可以列出分布式算法应具备的属性,以此定义它怎样才算正确。例如,如果算法要为锁生成栅栏令牌,我们可能要求它满足以下属性:
唯一性:
任意两个栅栏令牌请求都不能返回相同的值。
单调序列:
如果请求 x 返回令牌 t**x,请求 y 返回令牌
t**y,而且 x 在 y 开始以前已经完成,那么
t**x < t**y。
可用性:
请求栅栏令牌且没有崩溃的节点,最终会收到响应。
如果算法在某个系统模型允许发生的所有情形下,始终满足这些属性,就可以说它在该系统模型中是正确的。
有必要区分两类不同的属性:安全性(safety) 与 活性(liveness) 。在刚才的例子中,唯一性 和 单调序列 属于安全属性,可用性 则属于活性属性。
一个明显线索是,活性属性的定义中往往含有“最终”二字。(最终一致性 就是一项活性属性)
安全性常被非正式地定义为 坏事不会发生 ,活性则是 好事最终会发生 。
对分布式算法,通常要求安全属性在系统模型允许的所有情形下都 始终 成立。即使所有节点崩溃,或整个网络失效,算法仍必须保证绝不返回错误结果,因而安全属性依旧得到满足。
对活性属性,我们可以附加条件。例如,可以规定只有在多数节点尚未崩溃、且网络最终能从中断中恢复时,请求才必须得到响应。部分同步模型的定义要求系统最终回到同步状态,也就是说,任何一次网络中断都只能持续有限时间,随后会得到修复。
安全属性、活性属性和系统模型,对推理分布式算法的正确性非常有用。但当算法真正落地实现时,现实世界混乱的一面又会回来找麻烦;这时便清楚地看到,系统模型只是对现实的简化抽象。
例如,崩溃恢复模型中的算法通常假定稳定存储里的数据能挺过崩溃。但如果磁盘数据损坏了,或因硬件错误、配置错误而被清空,会发生什么?如果服务器存在固件缺陷,重启时明明硬盘连接无误,却无法识别它们,又会怎样?
法定人数算法(读写仲裁)依赖节点记住自己声称已经存储的数据。如果节点会患上“失忆症”,忘掉先前保存的数据,就会破坏法定人数条件,进而破坏算法的正确性。或许还需要定义一种新系统模型:假定稳定存储在崩溃后通常得以保留,但偶尔也会丢失。可这样的模型又会变得更加难以推理。
算法的理论描述可以直接声明,假定某些事情绝不会发生 ——
在非拜占庭系统中,我们确实必须对哪些故障可能发生、哪些不可能发生作出假设。然而,真实实现也许仍要包含代码,处理那些理论上“不可能”发生的事情;哪怕处理方式归结为
printf("Sucks to be you") 和 exit(666)
,也就是把残局留给人类运维人员收拾。(这正是计算机科学与软件工程之间的一项区别。)
这并不是说理论化、抽象化的系统模型毫无价值 —— 恰恰相反。它们极其有助于把真实系统的复杂性提炼成一组可管理、可推理的故障,使我们能够理解问题,并尝试用系统化的方法加以解决。
怎样知道一个算法确实满足所需属性?并发、部分失效与网络延迟会产生海量潜在状态。我们需要保证这些属性在每一种可能状态下都成立,还要确保没有遗漏任何边界情况。
一种办法是对算法进行形式化验证:用数学语言描述算法,再运用证明技术,证明它在系统模型允许的所有情形下都满足所需属性。证明算法正确,并不表示它在真实系统中的 实现 必然始终行为正确。不过,这是非常好的一步,因为理论分析能够发现算法中的隐患;这些问题在真实系统中可能潜伏很久,直到某些异常情况打破了你的假设(例如时序假设)才突然发作。
把理论分析与经验性测试结合起来,验证实现的行为是否符合预期,是一种稳妥做法。基于属性的测试(property-based testing)、模糊测试(fuzz testing)和确定性模拟测试(deterministic simulation testing,DST)等技术都利用随机化,在各种不同情形下测试系统。Amazon Web Services 等公司已经成功地把这些技术组合运用于许多产品。
模型检查器(model checker)是帮助验证算法或系统行为是否符合预期的工具。算法规范要用 TLA+、Gallina 或 FizzBee 等专门设计的语言来编写。借助这类语言,我们可以专注于算法行为,不必纠缠于代码实现细节。模型检查器随后会系统地尝试各种可能发生的情况,利用模型来验证不变量是否在算法的所有状态中都成立。
严格来说,模型检查无法证明算法的不变量在每一种可能状态下都成立,因为大多数现实算法的状态空间是无限的。要真正验证所有状态,需要给出形式化证明;这虽然可行,但通常比运行模型检查器困难得多。因此,使用模型检查器时,通常要把算法模型缩减成一个可以完全验证的近似版本,或是给执行设置某种上限(例如限制最多可以发送多少条消息)。这样一来,只会在更长执行过程中出现的缺陷就无法被发现。
即便如此,模型检查器仍然在易用性与发现隐蔽缺陷的能力之间取得了很好的平衡。CockroachDB、TiDB、Kafka 和许多其他分布式系统,都使用模型规范来发现并修复缺陷。例如,研究人员借助 TLA+ 证明,视图戳复制(VR)的文字描述存在歧义,可能导致数据丢失。
按照设计,模型检查器运行的并非实际代码,而是一个只描述协议核心思想的简化模型。这样更容易系统地探索状态空间,但也带来规范与实现逐渐偏离的风险 126。可以检查模型与真实实现的行为是否等价,不过这需要在真实实现中插桩。
许多缺陷只有在机器或网络发生失效时才会触发。故障注入(fault injection)是一种有效(有时也相当吓人)的技术,用来验证系统实现遇到问题时是否仍会按预期工作。思路很简单:向正在运行的系统环境注入故障,再观察系统如何反应。注入的故障可以是网络失效、机器崩溃、磁盘损坏、进程暂停 —— 凡是你能想到的计算机出错方式都可以尝试。
故障注入测试通常在与系统实际生产环境十分相似的环境中运行;有些团队甚至直接在生产环境中注入故障。Netflix 通过 Chaos Monkey 工具推广了这种做法。在生产环境中注入故障通常称为 混沌工程(chaos engineering) 。
运行故障注入测试时,首先要部署被测系统,以及故障注入协调者和脚本。协调者负责决定注入哪些故障、何时注入;本地或远程脚本则负责让单个节点或进程发生失效。注入脚本会利用许多不同工具来触发故障:Linux 进程可以用 kill 命令暂停或终止,磁盘可以用 umount 卸载,网络连接可以通过防火墙规则中断。检查故障注入期间以及之后的系统行为,就能确认系统是否一如预期。
触发不同失效所需的工具五花八门,因此故障注入测试写起来颇为繁琐。常见做法是采用 Jepsen 之类的故障注入框架来简化流程。这类框架集成了多种操作系统,并预置了大量故障注入器。Jepsen 在许多广泛使用的系统中都成功发现过关键缺陷,成效极为显著。
确定性模拟测试(DST)也已成为模型检查与故障注入的一种流行补充。它探索状态空间的方式与模型检查器相似,但测试的是真实代码,而不是模型。
在 DST 中,模拟器会自动运行大量随机化的系统执行。模拟期间的网络通信、I/O 和时钟计时都由模拟组件取代,让模拟器能够精确控制各种时序与失效场景下,所有事情发生的先后顺序。这样一来,模拟器所能探索的情形远多于手写测试或故障注入。如果测试失败,还可以重新运行,因为模拟器知道触发失败的准确操作序列;故障注入则无法对系统实施如此细粒度的控制。
DST 要求模拟器能够控制网络延迟等一切非确定性来源。要让代码变得确定,通常采用以下三种策略之一:
1 应用程序层
有些系统从一开始就以便于确定性执行代码为目标进行构建。例如,FoundationDB 是 DST 领域的先驱之一,它基于名为 Flow 的异步通信库构建。Flow 为开发者提供了一个接入点,可以把确定性网络模拟注入系统。类似地,TigerBeetle 是一款原生支持 DST 的在线事务处理(OLTP)数据库。它把系统状态建模为状态机,所有状态变更都在单一事件循环内发生。再配合时钟等确定性模拟原语,这种架构便能以确定性方式运行。
2 运行时层
带异步运行时与常用库的编程语言,为引入确定性提供了接入点。可以用单线程运行时,强制所有异步代码顺序执行。例如,FrostDB 修改 Go 运行时,让 goroutine 依次执行。Rust 的 madsim 库也采用类似方式。Madsim 为 Tokio 异步运行时 API、AWS S3 库、Kafka 的 Rust 库以及许多其他组件提供确定性实现。应用程序只需换用确定性的库与运行时,无需修改自身代码,就能获得确定性的测试执行。
3 机器层
除了在运行时修改代码,也可以让整台机器变得确定。这个过程十分精细:机器必须对所有通常带有非确定性的调用给出确定性响应。Antithesis 等工具通过构建定制的虚拟机监控器来实现这一点,由它把通常非确定性的操作替换成确定性操作。从时钟到网络再到存储,一切都必须纳入考虑。完成后,开发者就能在虚拟机监控器内的一组容器中运行整个分布式系统,得到一个完全确定的分布式系统。
DST 的优势不止是能够重放。Antithesis 等工具发现较为罕见的行为时,会把一次测试执行分叉成多个子执行,借此尝试探索应用程序中的更多代码路径。确定性测试通常使用模拟时钟与网络调用,因此运行速度可以快于现实中的时间流逝。例如,TigerBeetle 的时间抽象可以模拟网络延迟和超时,而无需真的等待足够长时间让超时触发。这类技术让模拟器能够以更快速度探索更多代码路径。
非确定性正是本章所讨论的所有分布式系统难题的核心:并发、网络延迟、进程暂停、时钟跳变和崩溃都会以不可预测的方式发生,而且系统每次运行时都可能不同。
要让代码具有彻底的确定性,仍须十分小心。即使已经消除了所有并发,并用确定性模拟替换 I/O、网络通信、时钟和随机数生成器,系统里仍可能残留非确定性。例如,在某些编程语言中,遍历哈希表元素的顺序可能不确定;是否会触及资源上限(内存分配失败、栈溢出)同样具有非确定性。