分布式系统(三)(译)
3. 时间和顺序
分布式系统中的顺序是什么?为什么它重要?
正如前文描述过的,分布式编程就是将在多服务器上解决单机问题的艺术。
事实上,这就是顺序问题的核心。如果一个系统一个时间点上只能专注一件事,那么所有操作之间就是全序的。就像人们相继通过一个门一样,每个操作都有一个确定的前驱者和一个确定的后继者。基本上,这就是我们努力想要保持的编程模型。
传统编程模型是:一个进程独占内存地运行在主机的一个CPU上。操作系统抽象掉了事实:主机是多CPU,多程序的并且内存被多个程序共享。这并不是否认多线程编程和事件驱。它们只是建立在传统模型的进一步抽象。程序都是顺序运行的:他们从程序开头,执行到程序尾。
顺序之所以收到这么多的关注,是因为最简单的定义程序安全的方法就是程序的运行与其在单主机上的执行无异。这通常意味着a)我们运行相同的命令并且b)命令执行的顺序不变--即使系统中存在多个主机。
维持命令执行顺序的分布式系统好处是通用性。你不需要关注命令本身,因为分布式执行命令与在单机系统的效果一致。这样的好处是无论使用什么命令都可以使用同一套系统。
(译者注,如果有这么一个命令流let a=10; let b=a+10;
。流中有两个命令。但命令有可能是两个时间段发出的。在不稳定的网络下,后一个命令很可能先于前一个命令到达其他节点。这样其他节点计算结果就会出现错误。维持命令顺序的分布式系统则可以帮开发者避免这个问题,不用过多拘泥于这些命令本身,如有关联的命令之间的顺序等。这就是通用性。)
事实上,一个分布式程序运行在多个节点上:使用着复数的CPU,接受多个命令流。你可以设定一个全序的命令流,但它要么需要准确的时钟要么需要某种形式的通信。可以通过为每个操作都设定一个准确时钟下的时间戳来指定全序。或者在某个通信体系下,按照全局顺序来分配序列号。
全序和偏序
分布式系统命令之间天然是偏序的。网络和相互独立的节点都无法为命令执行顺序顺序做出任何保证。但在每个节点上,可以观察本地执行顺序。
全序是指,集合中任两个元素都可以排序的关系。
两个不同元素,当一个大于另一个,它们就是可比较的。在偏序集中,部分元素之间是不可比较的,因此偏序无法为集合中所有元素排序。
全序和偏序都是可传递和反对称的。两个性质的描述如下(适用于全序和偏序集合中任两个元素):
假设集合X中有a,b,c
If a ≤ b and b ≤ a then a = b (反对称性);
If a ≤ b and b ≤ c then a ≤ c (传递性);
全序的两个性质通用所有元素,因此
a ≤ b or b ≤ a (totality) for all a, b in X
偏序则只通用单元素(自反性)
a ≤ a (reflexivity) for all a in X
上述可以推测,全序也含自反性。所以可以认为偏序是全序的一个弱化。对于偏序集中的一些元素来说,它们不具有全序性。换句话说,一些元素不可比较。
git的分支也属于偏序集。如果你有了解,git版本控制系统允许我们从单个基础分支创建多个其他分支。例如,从一个master分支创建其他slave分支。每一个分支代表着自己的一系列对源于同一份代码的修改历史。
[ branch A (1,2,0)] [ master (3,0,0) ] [ branch B (1,0,2) ]
[ branch A (1,1,0)] [ master (2,0,0) ] [ branch B (1,0,1) ]
\ [ master (1,0,0) ] /
分支A和分支B都派生于同一个祖分支。但他们之间没有明确的顺序。他们代表不同的修改历史。如果没有额外操作(如代码合并),他们无法聚合成单个分支。当然,我们可以将所有提交任意排序,但这会损失部分代码信息。
在单节点系统里,出现全序是必然的:指令在单个程序里按照特定的顺序执行和处理消息。基于这个指令全序,我们使程序的执行具有可预见性。这个顺序也可以在分布式系统中继续维持,但需要付出代价:通信是昂贵的,并且时间同步困难且脆弱。
时间的定义
时间是顺序的根源。它允许我们去决定指令的顺序。巧合的是,时间有一个人类都可以理解的解释(秒,分钟,天等等)。
某种意义上,时间跟整数计数器没什么区别。它如此重要,所以大多数电脑都有各自的时间传感器,也称为时钟。更重要的是,我们明白了如何在不完美的物理系统上合成出同个计数器的相似值。通过合成,我们可以通过某些物理属性得到一个远方的整数计数器的相似值,而不是直接跟它通信。
时间戳可以看做从宇宙大爆炸到现在的真实世界状态的缩影。如果某事发生在特定的时间戳上,它可能受到之前发生的事件的影响。这个想法可以延伸为一个因果时钟。这个时钟可以追溯原因(依赖)而不是简单的假设之前发生的一切都是相关的。当然,我们通常关注的是特定系统的状态而不是整个世界。
假设各地时间流速都是一样的。这是个很强的假设。稍后会进行解释。程序开发中,时间和时间戳有多个解释。三个解释分别是:
- 顺序
- 持续时间
- 确切时间点
顺序。如前面所属,时间是顺序的根源。这里的含义是
- 我们可以给未排序的时间加上时间戳进行排序
- 我们可以通过时间戳来强制一个特定的指令顺序或者信息发送顺序(例如,如果操作乱序抵达,则延迟执行)
- 我们可以根据时间戳的值来判断某事时间上是否发生在其他事之前
确切时间点。时间通常是可比较的。时间戳的绝对值可以被理解为一个日期。这有助于人们理解。给定一个从log文件获取的故障时间戳,人们可以知道是上个周六。那时正有一场雷暴。
持续时间。持续时间与现实世界具有一些相关性。算法一般不关心时间戳绝对值或者它的日期解释,但它可以使用持续时间来做一些判断。特别地,花费在等待上时间的多少可以提供线索,判断系统是否分区或者仅仅是高延迟。
就其性质而言,分布式系统组件表现都无法预期。它们不会保证确切的顺序,执行速率,无延迟。每个节点都有自己的本地顺序。命令执行虽然是线性,但这些本地顺序都是相互独立的。
施加(或假设)一个执行顺序是缩小执行和结果可能性的方法。人类难以分析无序的事物。因为那有太多的情况需要考虑。
各地的时间流速是否一致?
根据我们个人经验,我们都有一个直观的时间概念。不幸的是,这种直观的时间观念更有助于描绘全序而不是偏序。理解接连发生的顺序比并发更加简单。根据消息按单个顺序进行推理,比消息乱序且延迟不定更加简单。
然而,在实现分布式系统时,我们会避免对时间和顺序做出强假设。因为假设越强,系统面对时间传感器问题时越加脆弱。进一步地说,施加顺序也会带来成本。我们能容忍越多的短暂时间内不确定,能利用的分布式算力就越多。
对于“各地时间流速是否一致”问题,一般有三个回答,分别是:
- “全局时钟”:是的
- “本地时钟”:不是,但可以一致
- “无时钟”:不是
这三个大致与第二章描述的三个时间假设相对应:同步系统模型具有全局时钟。部分同步模型具有本地时钟。在异步系统中,节点不使用时钟来确定顺序。接下来我们会进一步描述他们。
全局时钟下的时间
全局时钟假设是指存在一个精度完美的全局时钟并且每个人都能访问它。这也是我们倾向的思考时间方式,因为在人与人的互动中,时间的微小差异无关紧要。
全局时钟是全序的根源(所有节点都有正确的命令执行顺序即使它们之间没有进行通信)。
然而,这是一个理想的世界观:实际中,时钟同步仅能做到有限精度。它受限于:商用电脑时钟精度,使用时钟同步协议(如NTP)的延迟以及时空性质。
假设分布式节点上的时钟完全同步的,意味着时钟都从同一时间点开始并永不漂移(译者注,电脑的物理时钟每一秒都可能有所偏移,有快有慢,并不完美。经年累月后,会造成时间上的差距。这就是漂移。)。这是一个很好的假设因为你可以自由地使用时间戳来定义全局所有命令顺序——受限于时钟漂移而不是延迟——但是一个重要 挑战并且是潜在的异常来源。在很多不同的场景,一个简单的错误——如用户不小心修改机器的本地时间,或者机器延期加入节点,或者时钟同步细微的漂移等等,都会造成难以追踪的异常。
然而,现实中仍然有基于这种假设的系统。Facebook的Cassandra就是假设时钟同步的示例。它使用时间戳来解决写冲突-带有最新时间戳的写会覆盖其他。这意味着如果时钟漂移,新数据可能被忽略或被旧数据覆盖;再者,这是一个运营挑战(据我所知,人们已经敏锐意识到)。另一个有趣的例子是Google的 Spanner。相关论文描述它的TrueTime API。API可以同步时间,也可以估量最坏情况下的时间漂移。
本地时钟下的时间
第二个时间假设,也许更加可信的假设是每台机器都有自己的时钟,但没有全局时钟。这意味着你不能使用本地时钟去确定远端的时间戳是否在本地时间戳之前或之后;换句话说,无法比较两台不同机器上的时间戳,这毫无意义。
本地时钟假设更加接近真实世界。它指定命令为偏序:每个系统上的事件都是有序的,但是跨系统事件无法使用时钟进行排序。
然而,单台机器上,你可以使用时间戳来排序事件;只要小心时钟不漂移,你也可以指定时间点,用超时多少来排序。当然,在被终端控制的机器上,这个假设依然过强:例如,用户在使用操作系统日期控制查看日期时可能失误地修改日期为其他值。
无时钟下的时间
最后,是逻辑时间概念。这里,我们不再使用时钟,相反用其他方法追踪因果关系。记住,时间戳只是那个时间点下世界状态的简写。所以我们可以使用计数器和通信来确定一个事件是发生在其他事件之前,之后还是并发的。
通过这种方法,我们可以确定不同机器上事件的顺序,但无法理解事件间隔和使用超时(因为我们假设不存在“时间传感器”)。事件这里是偏序的:无通信下,事件在各自系统上通过计数器排序。但是可以通过信息交换来跨系统排列事件。
分布系统引用最多的论文之一就是Lamport关于time, clocks and the ordering of events.的论文。矢量时钟就是基于这种概念(我将在后面进一步讲述)。它是一种无需借用时钟便可以追溯缘由的方法。Cassandra的堂兄弟Riak (Basho)和Voldemort (Linkedin)(译者注,三者都是分布式数据库)使用了向量时钟而不是假设节点都能访问一个完美精度的全局时钟。这允许系统避免前面提到的时间精度问题。
当时钟没有被使用,跨远节点的事件排序最大精度受限于通信延迟。
分布式系统如何使用时间
时间有什么好处?
- 时间可以跨系统定义顺序(不用通信)。
- 时间可以定义算法的边界条件,
事件顺序在分布式系统是很重要的,因为分布式系统许多属性根据操作/事件顺序定义的。
- 正确性依赖于事件的正确排序,例如分布式数据库的序列化
- 出现资源竞争时,顺序可以作为仲裁依据,如对于同个部件的两个订单,满足第一个取消第二个。
全局时钟允许排序两台不同机器上的操作而不用两台机器进行直接通信。如果没有全局时钟,我们需要通信才能确定顺序。
时间可以被用来定义算法的边界条件。具体来说,可以用来区分“高延迟”和“服务器或网络故障”。这是一个非常重要的用例。实际中大部分超时时间都用来确定远端计算机是否故障,或者仅仅是正在经历网络高延迟。能够做出这种判断的算法被称为故障检测。接下来,我们会讨论它们。
向量时钟(因果排序的时间)
早前,我们讨论了分布式系统关于时间流速的不同假设。假设我们不能实现高精度时钟同步,或者一开始就想要系统在时间问题上不敏感,我们该如何排序事件?
Lamport时钟和向量时钟是物理时钟的替代。他们依赖计数器和通信来确定整个分布式系统的事件顺序。这些时钟提供了一个各节点之间可比较的计数器。
Lamport时钟很简单。每个进程都维护一个计数器,遵守以下规则:
- 每当进程工作,计数器+1
- 每当进程发送一个消息,计数器+1
- 每当进程收到消息,计数器数值为
max(local_counter,receiver_counter)+1
代码如下
function LamportClock() {
this.value = 1;
}
LamportClock.prototype.get = function() {
return this.value;
}
LamportClock.prototype.increment = function() {
this.value++;
}
LamportClock.prototype.merge = function(other) {
this.value = Math.max(this.value, other.value) + 1;
}
Lamport时钟允许计数器在系统中跨节点可比较。Lamport定义了一个偏序。如果timestamp(a)<timestamp(b)
:
- a可能发生在b前,或者
- a与b之间无法比较
这也被称为时钟一致性条件:如果一个事件发生在另一个事件之前,那么事件的逻辑时钟小于另一个事件。如果a和b是位于同个因果历史,如两个事件都是进程产生的;或者b是对a发出的消息的响应,那么我们知道a发生在b之前。
可以很直观看到,上述能排序的原因是Lamport时钟能携带时间线/历史的信息;因此,比较两个从未通信的系统的Lamport时间戳可能错误排序原本并发的事件。
想象一个系统,它度过了初始期后分成两个从不通信的子系统。
对于各个互相独立的系统中所有事件,如果a发生在b前,那么ts(a)<ts(b)
;但如果你将两个来自不同的事件(例如,二者可能毫无关联)那么你就从它们的排序得到任何有意义的信息。尽管系统每个部分都赋予事件时间戳,但时间戳之间毫无关联。两个事件可能可以进行排序,即便它们之间毫无关联。
然而-这依然是一个有用的属性-从单服务器的视角来看,任何一个带有ts(a)
的消息发送出去,都会有一个ts(b)
的相应,而ts(a)<ts(b)
。
向量时钟是Lamport时钟的扩展。它维持了一个[t1,t2,...]
数组,包含了N个逻辑时钟-对应各个节点。不是递增一个公共的计数器,每个节点都递增数组里自己的逻辑时钟。更新规则如下:
- 每当进程工作,递增数组中属于自己节点的逻辑时钟
- 每当进程发送一条信息,带上自己维护的数组
- 每当进程收到一条信息:
- 更新自己数组中每个元素:
max(local,received)
- 递增自己当前节点的逻辑时钟
- 更新自己数组中每个元素:
代码如下
function VectorClock(value) {
// expressed as a hash keyed by node id: e.g. { node1: 1, node2: 3 }
this.value = value || {};
}
VectorClock.prototype.get = function() {
return this.value;
};
VectorClock.prototype.increment = function(nodeId) {
if(typeof this.value[nodeId] == 'undefined') {
this.value[nodeId] = 1;
} else {
this.value[nodeId]++;
}
};
VectorClock.prototype.merge = function(other) {
var result = {}, last,
a = this.value,
b = other.value;
// This filters out duplicate keys in the hash
(Object.keys(a)
.concat(b))
.sort()
.filter(function(key) {
var isDuplicate = (key == last);
last = key;
return !isDuplicate;
}).forEach(function(key) {
result[key] = Math.max(a[key] || 0, b[key] || 0);
});
this.value = result;
};
This illustration (source) shows a vector clock:
三个节点(A,B,C)的每个节点都维护着自己的向量时钟,
逻辑时钟的问题主要是每个节点都需要一个条目。这意味着对于大型系统,数组可能变得非常巨大。大量的技术被应用于减小向量时钟的大小(通过运行时垃圾回收,或限制大小,降低精度)。
我们已经明白了物理时钟如何排序和追踪时间原因。现在,让我们看看时间
故障检测
正如此前所说,花费在等待上的时间足以提供线索,用来判断系统是否分区还是仅仅在经历高延迟。在这个用例下,我们不需要假设一个完美精度的全局时钟-一个可依赖的本地时钟便足够了。
给定一个程序,运行在单节点上,它怎么区分远端节点是否故障?在缺失精确信息下,我们可以推断一个在合理时间后仍不相应的节点出现了故障。
但这个“合理时间”是多久?这依赖于本地节点与远端节点之间的延迟。最好是使用一个合适的抽象进行计算,而不是特定算法输入特定值得到的(在某些场景下必然出错)。
故障检测是一种抽象出精确时序假设的方法。通过心跳报文和计时器来实现故障检测。进程之间交换心跳包文。如果报文响应超时未到,进程就会怀疑其他进程。
基于超时检测的故障检测会引入风险。节点要么会过于具有侵略性(鲁莽裁断节点故障),呀么过度保守(花费太多时间探测节点是否崩溃)。故障检测精细到怎样才能具有可用性?
[Chandra等人](http://www.google.com/search?q=Unreliable Failure Detectors for Reliable Distributed Systems) (1996) 在解决共识问题的背景下讨论过故障检测-这是一个密切相关的问题,因为它是大多数复制问题的基础。数据复制需要副本们在延迟和网络分区的环境中达成一致。
他们通过两个属性来描述故障检测:Completeness(完整性)和accuracy(准确性)
Strong completeness Every crashed process is eventually suspected by every correct process.
Weak completeness Every crashed process is eventually suspected by some correct process.
Strong accuracy No correct process is suspected ever.
Weak accuracy Some correct process is never suspected.
completeness比accuracy更容易实现;所有重要的故障检测都实现了它-你所需要做的不是因为怀疑而永久等待。Charndra等人讲到,一个弱完整性的故障检测可以转化为强完整性(通过广播怀疑进程的信息),允许我们把精力集中到准确性上。
避免不必要地怀疑无故障进程是难以实现的,除非你能假设消息延迟有其上限。这个假设成立于同步系统模型-因此故障检测在这样一个系统里是强准确性的。在没限制消息延迟的系统模型里,故障检测最好情况就是最终准确性。
Chandra等人展示了即使一个非常弱的故障检测-最终性弱错误检测⋄W (最终弱准确性+弱完整性)-也能解决共识问题。下图(取自论文)阐述了系统模型与问题之间的关系:
正如上图所示,没有故障检测,异步系统中的问题是无法解决的。因为没有故障检测(或者关于时间边界的强假设 如同步系统模型),确定远端节点是崩溃还是仅仅经历高延迟是不可能的。这个区别对于旨在单版本一致性的系统很重要:故障节点可以被忽略因为它们造成了数据分歧,但分区节点不能被安全忽略。
如何实现错误检测?概念上说,一个简单的故障检测-超时即为故障-没什么好实现的。最令人感兴趣部分是如何判断远端节点是否故障。
理想情况下,我们更希望错误检测能够根据适应不断变化的网络和避免把超时值硬编码进来。例如,Cassandra使用一个应计故障检测。它能输出可疑级别(介于0和1)而不是一个二元判断。这允许应用在权衡精确探查和早起检测之间决定,
时间,排序和性能
此前,我暗示排序必顶增加成本?这意味着什么?
如果你正在写分布式系统,你大概拥有超过一台计算机。自然状态它们之间是偏序的,而不是全序。你可以转化偏序为全序,但这需要节点通信,等待和严格限制在某些时间点工作节点的数量。
所有时钟仅仅是受限于网络延迟(逻辑时间)或物理条件下的近似值而已。即使维持一个能在各节点之间同步的简单的计数器也是一个挑战。
虽然时间和排序经常放在一起讨论,但是时间本身并不是很重要。算法通常不专注于时间,而是更关注许多抽象属性:
- 相互之间有因果关系的事件的排序
- 故障检测(如,预估消息延迟的上限)
- 一致性锁(如,能够检测某些时间点下系统的状态,这里不会多加讨论)
施加全局是可能的,但昂贵的。它要求所有进程运行在相同(最低)的速度。通过,保证事件按照定义的顺序进行分发最简单的方法就是指定一个单节点来作为中心,接受所有命令并排序。
时间/排序/同步是否真的必要?需要因地制宜。在一些场景下,我们需要每个操作都能将系统从一个一致性状态过渡到另一个。例如,在许多用例中,我们想要数据库的响应都代表着有用信息并且避免需要去处理系统的不一致问题。
但在另一些用例里,我们对时间/排序/同步几乎没什么要求。例如,你想要运行一个长计算并且中途无需关注系统。那么只要答案确保是对的,无需对同步多加关注。
当最终结果仅收系统部分数据影响时,同步都不算有力的工具。排序何时才需要去确保准确性?我们会在最后一章CALM定理中回答这个问题。
在其他用例下,
在接下来的两个章节,我们会了解强一致容错系统的数据复制。该系统虽然能够弹性处理故障,但同时具有强一致性。这个系统为了第一个情形提供了解决方法:当你需要确保正确性并愿意为其付出代价。然后,我们会讨论弱保证
扩展阅读
Lamport clocks, vector clocks
- Time, Clocks and Ordering of Events in a Distributed System - Leslie Lamport, 1978
Failure detection
- Unreliable failure detectors and reliable distributed systems - Chandra and Toueg
- Latency- and Bandwidth-Minimizing Optimal Failure Detectors - So & Sirer, 2007
- The failure detector abstraction, Freiling, Guerraoui & Kuznetsov, 2011
Snapshots
- Consistent global states of distributed systems: Fundamental concepts and mechanisms, Ozalp Babaogly and Keith Marzullo, 1993
- Distributed snapshots: Determining global states of distributed systems, K. Mani Chandy and Leslie Lamport, 1985
Causality
- Detecting Causal Relationships in Distributed Computations: In Search of the Holy Grail - Schwarz & Mattern, 1994
- Understanding the Limitations of Causally and Totally Ordered Communication - Cheriton & Skeen, 1993
123