分布式系统本质上就是使用多机来解决单机的问题,为了保证它的通用性,必须能够保证如单机一样的order。所有你所关心的就是它确实像单机一样在执行操作。本章主要讲解分布式系统一个基本问题:时序问题。
如果你还不了解分布式系统,那么欢迎戳它:分布式系统基本概念-(1)
如果你想进一步了解分布式系统的基本特征以及consensus问题,那么欢迎戳它:分布式系统上下层概念抽象-(2)
这说明的分布式系统最重要的顺序问题。从单机角度上来,不同任务之间可以具有完全的order,然后从多机系统的角度,我们想却无法保证全局的Order。
全序:集合中的任何两个元素都是可比较的(例如实数域);
偏序:集合中只有部分元素是是可比较的(例如复数域)。
对于全序和偏序来说,都具有反对称性和传递性
也就是说对于任意的a和b,有
If a ≤ b and b ≤ a then a = b (antisymmetry);
If a ≤ b and b ≤ c then a ≤ c (transitivity);
不同之处,对于偏序来说,具有自反性,即
a ≤ a (reflexivity) for all a in X
对于全序来说,具有完全性,即
a ≤ b or b ≤ a (totality) for all a, b in X
不知道上面的性质有没有把你说懵逼?
反对称性和传递性都比较容易理解。举例集合的包含关系,如果集合a包含集合b,且集合b包含集合a,那么a=b(反对称性),如果集合a包含b,b包含c,那么集合a包含c(传递性)。
对于全序来说,具有完全性,也就是说任意的两个元素都具有比较关系,即要不a>= b,要不b>=a,至少一个成立;对于偏序来说,并不能满足任意成立,只需要满足自反性即可。可以看出,偏序包含全序,偏序是一种更泛化的情况。
以git为例,在一个分支中,git log中显示的版本号具有序号的概念,可比较(例如先提交,后提交之说),然后不同branch之间的git提交却是无法比较的。例如都是基于master,创建了分支A和B,然后
分支A: a1, a2, a3 ...
分支B: b1, b2, b3 ...
虽然分支A和B起源于同一个位置,然后A和B向不同方向发展,因此不可比较,这叫发生了分叉,也就是说全序不再成立。对于分叉的处理要不自动合并,要不人工选择某一个。
time具有三种属性:
分布式系统的各个节点内部都具有自身的Order(可以依靠自身的时钟),但是由于它们独立的运行,无法得到全局的Order(各个节点的clock可能不完全一致)。
对于这个问题,有三种角度:
假设整个系统有一个全局的精确时钟,所有event的发生的时间戳都被精确的确定,因此就具有了全局的Order,然而这是一个理想的系统。真实情况下,时钟同步会有一定的延迟,用户也可能手动修改了时钟,这都会导致各个节点的时间不一致。
即使这样,仍然有系统假设时钟是完美同步的,例如Facebook的Cassandra基于时钟同步假设,它解决冲突的标准是选择最新的写入(最新基于时钟最新)。
Local clock假设感觉更可行一些,它说的是每个机器都有本地的时钟,并没有全局的时钟。Local clock定义了一种偏序关系,在节点内部,event是有序的,然而节点之间,event是不可比较的。
No clock意味着没有逻辑时间的概念,取而代之的是我们使用couters和communication来决定事件是否发生过,异或是还未发生。 由于没有time的概念,我们也就无法使用timeout。这也是一个偏序概念:从节点本身来看,可以使用counter决定顺序,跨节点的事件顺序则可以使用communication。如果没有使用时钟,那么事件排序的精度取决于节点之间的通信延迟。
从分布式系统的角度来说,time非常重要。它可以用来决定事件的顺序,从而能够确保执行结果的正确性,因为很多agreement需要依赖事件的Order来达成一致。如果有global clock,那么不同节点上的event排序就显得非常容易,无需通信,而在没有global clock的情况下,节点之间只能依赖communication决定Ordering。
Lamport的时钟取代了物理时钟,它依赖counter和communication来决定事件的发何时能顺序,所有的进程遵循以下规则:
max(local_counter, received_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 clock使得跨节点之间的counter比较成为可能,如果timestamp(a) < timestamp(b)
,则有两种可能:
从单个机器来说,先发送a消息,然后收到b消息,那么可以确保a在b之前;从两个机器来说,一个机器收到a消息,另外一个收到b消息,对于这两个消息赋值相应的时间戳,即使ts(a) < ts(b),我们也无法说明a发生在b之前,因为在这种情况下,a和b比较是无意义的。
向量锁是Lamport clock的扩展,它维护着一个array序列[t1, t2, …, tn],其中一个节点一个ts。与前面的Lamport clock的更新不同,这次每个节点只更新属于自己的那个counter,具体来说:
代码如下:
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;
};
//只对属于自己的counter增加1
VectorClock.prototype.increment = function(nodeId) {
if(typeof this.value[nodeId] == 'undefined') {
this.value[nodeId] = 1;
} else {
this.value[nodeId]++;
}
};
// 作者手误?感觉少增加一个1
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;
};
借用wiki的一幅图片:简单说说来就是每个进程只负责增加自己的counter,每次收到消息和执行操作,就增加counter。个人还不是完全理解其本质,感觉上通过这种方式对通信和事件都基于counter,从而确定了有序性?
上面使用counter代替了global clock来实现了事件的有序性,那么time的第二个属性duration如何来操作呢?
当收不到回复的消息,如何判断这是由于remote node故障,还是由于high network latency导致的?这种一般需要设置一个最长超时时间。这个时间长短的设定本质上是在completeness和accuracy之间权衡。
完整性:crashed process被全部检测到
正确性:是否有正常的process被误认为crashed
完整性很容易达到,例如你可以等待无限制的时间,然后所有crashed process都会被最终检测到。然而现实中,我们无法做到这种无限等待,只能做到weak failure detector,实现了weak completeness和weak accuracy。Chandra et al.证明weak failure detector已经足够来解决consensus问题。
如果要求最好的时序,那么可以使用同步语义,但是这样性能就会有所牺牲。实际的系统需要根据时序的要求在可靠时序和性能方面进行权衡。
不同的系统对时序的要求不同,有些系统要求中间结果的每一步都是正确的,不能返回任何不一致的结果,有些系统并不要求中间过程(例如计算任务),只要确保结果最终正确就行。还有些情况,完全正确的结果并不一定重要,一个最好的结果估计也许就足够,例如最好的电影评分的排名,是第一还是第二也许区别并不那么大,the best effort result也许就足够了。
Lamport clocks, vector clocks
Snapshots
Causality