vlambda博客
学习文章列表

​分布式系统与单节点系统的本质区别是什么?

分布式系统与单节点系统的本质区别是什么?我们通过一个简单的例子来说明。在单线程程序中,我们定义变量和执行过程(即一组步骤)。

例如,我们可以定义一个变量,并对它执行简单的算术运算:

int x = 1;
x += 2;
x *= 2;

这里我们只会得到唯一的执行历史:声明一个变量,把它加上2,再乘以2,然后得到结果6。假设这些运算不是由单个线程执行的,而是有两个线程读写变量x,我们就需要考虑并发执行的问题。


1 并发执行

一旦两个执行线程都能访问变量,除非我们在线程间同步这些步骤,否则这些并发步骤的执行结果是无法预知的。如图8-1所示,我们得到了4个可能的结果。注1

a) x = 2,如果两个线程都读到初始值,加法写入结果,但被乘法的结果覆盖了。

b) x = 3,如果两个线程都读到初始值,乘法写入结果,但被加法的结果覆盖了。

c) x = 4,如果乘法读到初始值,且乘法在加法之前执行。

d) x = 6,如果加法读到初始值,且加法在乘法之前执行。

即便仅在单个节点上,我们就已经遇到了分布式系统中的第一个问题:并发。每个并发程序都具有分布式系统的某些特性。线程访问共享状态,在本地执行一些运算,再将结果传回共享变量。


图1:并发执行中可能的交错情形

为了精确定义执行历史并减少可能的结果数量,我们需要一致性模型。一致性模型描述并发执行的过程,并且确定了运算执行以及对其他参与者可见的顺序。使用不同的一致性模型,我们可以约束或放松系统可能的状态数量。

分布式系统和并发计算在术语和学术研究上有许多重叠之处,但也存在一些差异。并发系统中存在共享内存,进程可以用它来交换信息。在分布式系统中,各个进程拥有自己的本地状态,参与者之间通过传递消息进行通信。


并发和并行

我们经常互换使用并发和并行计算这两个术语,但是这两个概念在语义上有细微的差异。当两个步骤序列并发执行时,二者都在进行中,但任意时刻都只有其中一个在执行。当两个步骤序列并行执行时,它们的步骤可以(在某一时刻)同时执行。并发的操作时间上存在重叠,而并行的操作由多个处理器执行[WEIKUM01]。

Erlang编程语言的创建者Joe Armstrong举过一个例子:并发执行就像一台咖啡机前排了两队,而并行执行就像两台咖啡机前排了两队。即便如此,绝大部分资料都用术语“并发”来描述拥有多个并行执行线程的系统,而“并行”这个词则很少见。

分布式系统中的共享状态

我们可以尝试在分布式系统中引入共享内存的概念,例如,单一信息源(比如数据库)。即使我们解决了并发访问的问题,我们依然无法保证所有进程都是同步的。

为了访问数据库,进程需要通过通信介质发送和接受消息,以查询或修改状态。但是,如果一个进程很久都没有从数据库得到响应会如何?为了回答这个问题,我们首先要定义什么是很久。为此,必须从同步性的角度来描述系统:通信是否是全异步的?是否存在某些时序假设?如果存在的话,这些时序假设将允许我们引入操作超时和重试机制。


我们无从知晓数据库没有响应是因为过载、不可用、响应太慢还是网络问题。这描述了崩溃的本质—进程可能以各种方式崩溃:可能因某种原因无法继续执行后面的算法步骤;可能遇到了临时性的故障;也可能是消息丢失。我们需要定义一个故障模型并描述故障可能发生的方式,然后再决定如何处理它们。

如果系统在故障发生时仍然能继续正常运行,我们将这样的特性称为容错性。故障是不可避免的,所以我们需要构建出具有可靠组件的系统。消除单点故障,比如前文提到的单节点数据库,可能是我们朝此方向迈出的第一步。我们可以引入一些冗余,增设备份数据库。然而这就引出了另一个问题:如何使共享状态的多个副本保持同步?


到目前为止,在我们这个简单系统中引入共享状态所带来的问题比答案还多。现在我们知道,共享状态不像引入数据库那样简单,还必须采取更细化的方法,即基于消息传递来描述各个独立进程之间的交互。


2 分布式计算的误区

理想情况下,当两台计算机在网络上通信时,一切都能正常工作:进程开启一个连接、发送数据、收到响应,每个人都很开心。但是假设所有操作总会成功并且没有任何错误是很危险的,因为当某些东西出问题时,我们的假设也就不成立了,那时系统的行为将变得难以预测。

大多数时候,假设网络可靠是合理的。网络至少在一定程度上可靠才能有用。我们都曾经历过这样的情况,当我们尝试连接到远程服务器时,却收到了一个“网络不可达”的错误。即使能建立连接,一个成功的初始连接也无法保证这条链路是稳定的,连接随时可能中断。消息可能送达了对端,但对端的响应却可能丢失了,也有可能在对端的响应发送之前连接就中断了。

网络交换机会有故障,电缆可能断开,网络配置也随时可能发生变化。我们构建系统时需要适当地处理所有这些情况。

连接可以是稳定的,但我们不能期望远程调用能像本地调用一样快。我们应尽可能少地对延迟做出假设,并且永远不要假设延迟为零。一条消息要想到达远程服务器,需要穿过若干个软件层和一个物理媒介(比如光纤或电缆),所有这些操作都不是瞬间完成的。


Michael Lewis在他所著的书Flash Boys(Simon and Schuster公司出版)中讲述了这样一个故事,公司花费数百万美元把延迟降低几毫秒,从而能比竞争对手更快地访问交易所。这是一个把延迟作为竞争优势的绝佳例子,然而值得一提的是,根据其他一些研究,比如文献[BARTLETT16],过时报价套利(通过比竞争对手更快地得知价格并执行交易来获取利润)并不能使快速交易者从市场中获利。


从上述教训当中学习,我们增加了重试和重连机制,并去掉了关于瞬间执行的假设,但是事实证明这还不够:当我们增加消息的数量、发送速率和大小,并向现有网络中添加新的进程时,我们不应该假设带宽是无限的。


1994年,Peter Deutsch发布过一个如今很有名的断言列表,标题为“分布式计算的误区”,描述了分布式计算中易被忽视的一些方面。除了网络可靠性、延迟和带宽假设,它还提到了其他问题,比如,网络的安全性、可能存在的攻击者、有意或无意的拓扑变化都可能打破我们的一些假设,这些假设包括:某一资源存在性和所在位置,网络传输所消耗的时间和资源,以及最后—存在一个拥有整个网络的知识和控制权的权威个体。

Deutsch的列表可以说非常详尽,但它侧重于通过链路传递消息时可能出错的地方。这些担忧是合理的,而且描述了最通用、最底层的复杂性,但不幸的是,在设计和实现分布式系统时,我们还做出了很多其他假设,这些假设也可能在运行中导致问题。


2.1 处理

在远程进程响应刚刚收到的消息之前,它还需要在本地执行一些工作,因此我们不能假定处理是瞬时完成的。只考虑网络延迟还不够,因为远程进程执行的操作也不是立即完成的。

此外,我们还无法保证消息送达后会立刻被处理。消息可能会进入远程服务器的等待队列中,等到所有更早到达的消息处理完后才被处理。

节点可能相距很近,也可能很远,各节点可能有不同的CPU、内存和磁盘配置,可能运行不同的软件版本和配置。我们不能期望它们以相同的速度处理请求。如果完成一项任务需要等待几个并行工作的远程服务器响应,则整个执行的完成时间取决于最慢的服务器。

与普遍存在的看法相反,队列容量并非是无限的,堆积更多的请求不会对系统有任何好处。当生产者产生消息的速度大于消费者能够处理的速度时,我们可以使用背压(backpressure)策略减慢生产者的速度。背压是分布式系统中人们了解和应用最少的概念之一,通常是事后才建立,而不是将其视为系统设计必需的一个组成部分。


尽管增加队列容量听起来像是个好主意—可以帮助我们管道化、并行化以及有效地调度请求,但是,如果消息仅仅是停在队列中等待处理,什么也不会发生。增大队列大小可能对延迟产生负面影响,因为这并不会改善处理速度。

通常,进程本地队列是用于实现以下目标:

解耦

        使接收和处理在时间上分开,并各自独立发生。

流水线化

        不同阶段的请求由系统中独立的部分处理。负责接收消息的子系统不用阻塞到上一条消息处理完成。

吸收瞬时突发流量

        系统负载可能经常变化,但是请求到达的间隔时间对负责处理请求的组件是隐藏的。总体的系统延迟会由于排队而增加,但这通常仍比响应失败并重试请求更好。


队列大小取决于工作负载和应用程序。对于相对稳定的工作负载,我们可以通过测量任务处理时间以及各任务的平均排队时间来确定队列大小,从而确保在提升吞吐量的同时,延迟仍保持在可接受的范围内。在这种情况下,队列大小相对较小。对于不可预测的工作负载,可能会出现任务提交的突发流量,这时队列大小也应当考虑突发流量和高负载。

即使远程服务器可以快速地处理请求,也并不意味着我们总是能获得正面的响应。它也可能回应一个失败:无法进行写操作、要查找的值不存在或是触发了bug。总之,即使是最顺利的情况也需要我们的关注。


2.2 时钟和时间

时间是一种幻觉,尤其是午餐时间。 

                                              —Ford Prefect, The Hitchhiker抯 Guide to the Galaxy


假设不同的远程计算机上的时钟都同步也很危险。再加上延迟为零以及处理是瞬时的这些假设,将会导致不同的特质,尤其是在时序和实时数据处理中。例如,当从时间感知不同的参与者收集和聚合数据时,你必须了解它们之间的时间漂移并相应地对时间进行归一化,而不是依赖源时间戳。除非使用特殊的高精度时间源,否则不能依赖时间戳进行同步或排序。当然,这并不意味着我们完全不能或不该依赖时间:说到底,任何同步系统都依靠本地时钟实现超时。

我们必须始终注意进程之间可能存在的时间误差,以及传递和处理消息所需的时间。例如,Spanner(参见13.5节)使用特殊的时间API,该API返回时间戳和不确定性界限以施加严格的事务顺序。一些故障检测算法依赖于共享的时间概念,要求时钟漂移始终在允许的范围内才能确保正确性[GUPTA01]。


除了分布式系统中的时钟同步非常困难之外,当前时间也在不断变化:你可以从操作系统请求当前的POSIX时间戳,并在执行几个步骤后请求另一个当前时间戳,两次结果是不同的。尽管这是一个明显的现象,但是了解时间的来源以及时间戳捕获的确切时刻至关重要。

了解时钟源是否是单调的(即永远不会后退),以及与调度时间相关的操作可能偏移多少,可能也会有所帮助。


2.3 状态一致性

之前说到的假设大多属于“几乎总是错的”一类,但是,还有一些假设最好归入“并非总是对的”一类。这类假设帮助我们走思维捷径,通过以特定方式思考来简化模型,忽略某些棘手的边缘情形。

分布式算法并不总是保证状态严格一致。一些方法具有较宽松的约束,允许各副本之间的状态存在分歧,并依赖冲突解决(检测和解决系统内分歧状态的能力)和读取时数据修复(读取期间,当各副本响应不同结果时,使副本恢复同步的能力)。有关这些概念的更多信息参见第12章。假定状态在节点间完全一致可能会导致难以察觉的bug。

最终一致的分布式数据库可能具有这样的逻辑:读取时通过查询Quorum的节点来处理副本不一致,但是假定数据库表结构和集群视图是强一致的。除非我们确保这些信息的一致性,否则依赖该假设可能会造成严重的后果。


例如,Apache Cassandra曾有一个bug,其原因是表结构变更在不同时刻传播到各个服务器。如果在表结构传播过程中尝试从数据库读取数据,则可能会读到损坏的数据,因为一台服务器以某种表结构进行编码,而另一台服务器使用不同的表结构对其进行解码。

另一个例子是由环的视图分歧引起的bug:如果一个节点假定另一个节点保存了某个键的数据记录,但另一个节点具有不同的集群视图,此时读写数据可能会导致数据记录被错误放置,或是获得一个空的响应,虽然数据实际上好端端地存放在另一个节点上。

即使完全的解决方案成本很高,我们也最好事先考虑各种可能的问题。通过了解和处理这些情况,你能以更自然的方式解决问题,比如内置防护措施或修改设计。


2.4 本地和远程执行

将复杂性隐藏在API内部可能很危险。例如,对于本地数据集上的一个迭代器,即使你对存储引擎不熟悉,也可以合理地推测内部行为。理解远程数据集上的迭代过程则是一个完全不同的问题:你需要理解一致性、传递语义、数据协调、分页、合并、并发访问含义以及许多其他事情。

简单地将两者隐藏在同一个接口后,即便有用,也可能会产生误导。调试、配置和可观察性可能需要额外的API参数。我们应该始终牢记,本地执行和远程执行是不同的。

隐藏远程调用最明显的问题是延迟:远程调用的成本比本地调用高很多倍,因为它涉及双向网络传输、序列化/反序列化以及许多其他步骤。交错使用本地调用和阻塞的远程调用可能会导致性能下降和预期之外的副作用[VINOSKI08]。


2.5 处理故障的需要

刚开始构建系统的时候,我们可以假设所有节点都可以正常工作,但如果总是这么想就很危险了。在长时间运行的系统中,节点可能会关机维护(通常会有个优雅关闭的过程)或因为种种原因(例如软件问题、内存耗尽(out-of-memory killer [KERRISK10])、运行时bug、硬件问题等)而崩溃。进程会发生故障,而你能做的最好的事情就是做好准备并知道如何处理它们。

如果远程服务器没有响应,我们并不总是知道确切的原因。这可能是由系统崩溃、网络故障、远程进程或中间链路太慢导致的。一些分布式算法使用心跳协议和故障检测机制来确定哪些参与者还活着且可达。


2.6 网络分区和部分故障

当两个或更多服务器无法相互通信时,我们称这种情况为网络分区。Seth Gilbert和Nancy Lynch在Perspectives on the CAP Theorem [GILBERT12]中区分了以下两种情况:两个参与者无法相互通信;几组参与者彼此隔开,无法交换消息并继续运行算法。

网络的总体不可靠性(数据包丢失、重传、延迟难以预测)令人烦恼但尚可容忍,而网络分区则会造成更多的麻烦,因为各个独立的分组可以继续执行并产生冲突的结果。网络链路的故障也可能是不对称的:消息仍然能从一个进程传递到另一个进程,反之则不行。

为了构建在一个或多个进程出现故障的情况下仍健壮的系统,我们必须考虑部分故障的情况[TANENBAUM06],如何让系统在部分不可用或运行不正常的情况下仍能继续工作。

故障很难检测,并且在系统的不同部分看来,不总是以相同的方式可见。设计高可用性系统时,我们应该始终考虑边缘情形:如果我们确实复制了数据却没有收到确认该怎么办?要重试吗?在发送了确认的节点上,数据仍可用于读取吗?

墨菲定律注2告诉我们故障一定会发生。编程界又补充道,故障将以最坏的方式发生。因此,作为分布式系统工程师,我们的工作是尽可能减少可能出现错误的场景,并为故障做好准备—包括这些故障可能导致的破坏。

避免一切故障是不可能的,但我们仍可以构建一个弹性的系统,使之在故障出现时仍然能正常运行。设计应对故障的最佳方式是进行故障测试。我们无法考虑清楚每种可能的故障场景,并预测多个进程的行为。最好的解决方法就是通过测试工具来制造网络分区、模拟比特位腐烂[GRAY05]、增加延迟、使时钟发生偏移以及放大相对处理速度。现实世界中分布式系统的设置可能是对抗性的、不友好的,甚至是“有创造性的”(然而,以非常敌对的方式),因此测试工作应当尝试覆盖尽可能多的场景。

过去几年中出现了一些开源项目,它们能帮助我们构造出各种故障场景。Toxiproxy用于模拟网络问题:限制带宽、引入延迟、超时等。Chaos Monkey的方法更为激进,它通过随机关闭服务使工程师直面生产环境故障的风险。CharybdeFS模拟文件系统及硬件错误与故障。你可以用这些工具来测试软件,以确保在这些故障出现时软件仍能正确工作。CrashMonkey是一个与文件系统无关的记录–重放–测试框架,用于测试持久性文件的数据及元数据一致性。

设计分布式系统时,我们必须认真考虑容错性、弹性,以及可能的故障场景和边缘情形。类似于“足够多的眼睛,就可让所有问题浮现”,我们可以说足够大的集群最终一定会命中所有可能的问题。与此同时,只要有足够多的测试,我们最终能够发现每个存在的问题。


2.7 级联故障

我们做不到总是完全隔离故障:被高负载压垮的进程会增加集群其余部分的负载,从而使其他节点更有可能发生故障。级联故障能够从系统的一部分传播到另一部分,扩大了问题的范围。

有时,级联故障甚至可能来源于完全善意的目的。例如,某个节点离线了一段时间,因而没有接收到最近的更新。当它恢复在线时,乐于助人的其他节点希望帮助它追赶上最近的变化,于是开始向它发送缺失的数据,而这又导致网络资源耗尽,或是导致该节点启动后短时间内再次发生故障。

为了防止系统的故障扩散并妥善处理故障场景,我们可以使用断路器(或熔断机制)。在电气工程中,断路器可通过中断电流来保护昂贵且难以更换的部件,使其免受电流过载或短路的影响。在软件开发中,熔断机制会监视故障,并使用回退(fallback)机制保护整个系统:避免使用出故障的服务,给它一些时间进行恢复,并妥善处理失败的调用。

当与某一台服务器的连接失败或服务器没有响应时,客户端将开始循环重连。那时候,过载的服务器已经难以应付新的连接请求,因而客户端的循环重试也无济于事。为了避免这一情况,我们可以使用退避(backoff)策略,客户端不要立即重试,而是等待一段时间。退避通过合理安排重试、增加后续请求之间的时间窗口来避免问题扩大。

退避用于增加单个客户端的请求间隔。但是,使用相同退避策略的多个客户端也会产生大量负载。为了防止多个客户端在退避期之后同时重试,我们可以引入抖动(jitter)。抖动在退避上增加了一个小的随机时间间隔,从而降低了多个客户端同时醒来并重试的可能性。

硬件故障、比特位腐烂和软件错误都会导致数据损坏,而损坏的数据会通过标准的传递机制传播。如果没有适当的验证机制,系统可能将损坏的数据传播到其他节点,甚至可能覆盖未损坏的数据记录。为了避免这一情况,我们应该采用校验和(checksum)以及验证机制,来验证节点之间交换的任何内容的完整性。

通过计划和协调执行可以避免过载和热点问题。相比于让各个对等节点独立执行操作步骤,我们可以用协调器来依据可用资源准备一份执行计划,并根据过去的执行数据来预测负载。

总之,我们应该始终考虑这样的情形:系统某一部分的故障可能导致其他地方也出现问题。我们应该为系统装备上熔断、退避、验证和协调机制。处理被隔离的小问题总比从大规模故障中恢复更简单。

我们用整整一节讨论了分布式系统中的问题和潜在的故障场景,但是我们应当将其视为警告,而不是被它们吓跑。

了解什么会出问题,并仔细设计和测试我们的系统,可以让它更健壮、更具弹性。了解这些问题可以帮助你在开发过程中识别、发现潜在的问题根源,也能帮助你在生产环境中调试。


 往期推荐:






— END —

技术琐话 



以分布式设计、架构、体系思想为基础,兼论研发相关的点点滴滴,不限于代码、质量体系和研发管理。