简介
本文是分布式系统理论的开山鼻祖、2013 年图灵奖获得者 Lamport 的成名作,也是分布式计算领域杰出论文最佳影响力奖 Dijkstra Prize 的第一篇论文,高达 11692 的引用量(截至 2019/12/08)足以证明其广泛的影响力:
![](https://blobscdn.gitbook.com/v0/b/gitbook-28427.appspot.com/o/assets%2F-LMjQD5UezC9P8miypMG%2F-LvXsiCfww2vR_HsaxRb%2F-LvXtSjgJb4u4T-v-7v6%2FScreen%20Shot%202019-12-08%20at%208.25.33%20AM.jpg?alt=media&token=7acfd213-f92a-4947-9d19-18ef4a210bbd)
本文主要讨论 3 个话题:
- 分布式系统中的事件偏序
- 利用逻辑时钟实现事件偏序
- 利用逻辑时钟实现事件全序
事件顺序
生活中的事件顺序
生活中,当两个事件 A 和 B 发生时,我们可以利用其发生的时刻来确定它们的先后关系,如:
A:2019-12-08T00:00:00+00:00
B:2019-12-07T08:00:00+00:00
这样就能很容易地确定 B 在 A 之前发生。这是因为:
- 这些事件发生时刻的度量粒度比较大,从分钟、小时到日、月、年
- 记录这些事件发生的时间戳通常精确到秒
只要二者时间戳不完全相等,我们就能准确判断先后顺序。
计算机中的事件顺序
在计算机中,同一秒内可能发生大量的事件:小到一个 CPU 指令,大到一段 shell 脚本的执行。我们是否需要确定计算机中所有事件的绝对顺序,才能保证计算过程的正确性?显然并非如此,以这一段代码为例:
func main() {
fmt.Println("Hello")
fmt.Println("World")
}
我们希望计算机输出:
Hello
World
那么不论这台计算机上发生什么事情,只要 fmt.Println("Hello")
发生在 fmt.Println("World")
之前,这段程序就是正确的。根据冯诺依曼计算机的工作原理,PC 寄存器会先指向 fmt.Println("Hello")
的指令地址,执行完以后再移向 fmt.Println("World")
,所以二者的先后顺序能够被保证。
再看下面这段代码:
func main() {
wg := sync.WaitGroup{}
wg.Add(2)
go func() {
fmt.Println("Hello")
wg.Done()
}()
go func() {
fmt.Println("World")
wg.Done()
}()
wg.Wait()
}
这时两个打印语句分别位于不同的线程中,二者的执行顺序无法保证。但通常按这种方式构建的程序是为了充分利用系统的 CPU,其正确性本身不受二者的执行顺序影响。即这是程序员有意识地在用顺序换时间。
分布式系统
分布式系统由位于相同或不同空间上的进程/线程集合构成,进程/线程之间通过收发信息来通信。如果一个系统中消息传递延迟相对于事件间隔不可忽略,就可称之为分布式系统。在分布式系统中,判断事件的先后顺序并非易事,以下图为例:
![](https://img-blog.csdnimg.cn/20200527094255414.jpg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2MxODM2NjIxMDE=,size_16,color_FFFFFF,t_70)
P1、P2 分别表示某系统中两个不同的进程/线程,图中的圆圈代表其上发生的事件。
假设消息传递延迟相对于事件间隔可以忽略,那么 P 1 P1 P1、 P 2 P2 P2 能够通过通信确定 E 1 E1 E1、 E 2 E2 E2 及 E 3 E3 E3 的先后顺序:
![](https://img-blog.csdnimg.cn/20200527094255361.jpg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2MxODM2NjIxMDE=,size_16,color_FFFFFF,t_70)
假设消息传递延迟相对于事件间隔不可忽略,如下图所示:
![](https://img-blog.csdnimg.cn/20200527094255376.jpg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2MxODM2NjIxMDE=,size_16,color_FFFFFF,t_70)
如果我们对 P 1 P1 P1、 P 2 P2 P2 之间的传输协议没有任何保证,那么:
- 对于 P 1 P1 P1:可以确定的顺序为 E 1 → E 2 E1 \to E2 E1→E2,但 E 1 E1 E1、 E 2 E2 E2 与 E 3 E3 E3 之间的关系无法确定
- 对于 P 2 P2 P2:三个事件任意的全排列皆有可能
如果我们假设二者通过 TCP 传输,那么 P 2 P2 P2 也可以确定 E 1 E1 E1 发生在 E 2 E2 E2 之前,但二者的顺序与 E 3 E3 E3 的顺序仍然无法确定,因为在网络环境不定的情况下, P 2 P2 P2 可能在任意时刻收到 P 1 P1 P1 发送的消息。
偏序
对于分布式系统,我们可以定义 “ → \to →” 关系如下:
- 如果 a a a 和 b b b 是同一个进程/线程中的两个事件,如果 a a a 在 b b b 之前发生,那么 a → b a \to b a→b
- 如果 a a a 事件是 " P 1 P1 P1 发送消息 m m m 到 P 2 P2 P2", b b b 事件是 " P 2 P2 P2 收到 P 1 P1 P1 发送的消息 m m m",那么 a → b a \to b a→b
- 如果 a → b a \to b a→b 且 b → c b \to c b→c,那么 a → c a \to c a→c
- 如果 a ↛ b a \not \to b a→b 且 b ↛ a b \not \to a b→a,那么 a a a 和 b b b 是并发 (concurrent) 事件
如果再加上一条假设 a ↛ a a \not \to a a→a,那么 “ → \to →” 就符合离散数学中的 **偏序 **定义。我们可以这样理解:偏序并不要求系统中任意两个事件都存在先后关系,但它规定了部分事件的先后顺序,如下图所示:
![](/blog/images/time-1978/partial-order.jpg)
其中可以确定的先后关系是 E 1 → E 2 → E 3 → E 4 E1 \to E2 \to E3 \to E4 E1→E2→E3→E4 和 E 1 → E x → E 4 E1 \to Ex \to E4 E1→Ex→E4,但 E x Ex Ex 与 E 2 E2 E2、 E 3 E3 E3 之间的关系则无法确定。
逻辑时钟
为了能显式地表示分布式系统中事件的偏序,我们需要引入逻辑时钟,顾名思义,这种时钟与物理时钟没有任何关系。假设每个进程 P i P_{i} Pi 的逻辑时钟为 C i C_{i} Ci ,那么对于 P i P_{i} Pi中的任意事件 a a a,其发生的时刻即为 C i ⟨ a ⟩ C_{i}\langle{a}\rangle Ci⟨a⟩ 。
根据上文中偏序的讨论,一个正确的逻辑时钟需要满足:
Clock Condition:对任意两个事件 a 和 b,如果 a → b a \to b a→b ,那么 C i ⟨ a ⟩ < C i ⟨ b ⟩ C_{i}\langle{a}\rangle < C_{i}\langle{b}\rangle Ci⟨a⟩<Ci⟨b⟩
需要注意的是,Clock Condition 的逆命题 "如果 C i ⟨ a ⟩ < C i ⟨ b ⟩ C_{i}\langle{a}\rangle < C_{i}\langle{b}\rangle Ci⟨a⟩<Ci⟨b⟩,那么 a → b a \to b a→b" 并不成立,若后者成立则意味着系统中所有并发事件必须在相同的逻辑时刻发生,事实并非如此。
如果以下两个条件成立:
- C1:如果 a a a 和 b b b 是进程 P i P_{i} Pi 中的两个事件,且 a 先于 b 发生,那么 C i ⟨ a ⟩ < C i ⟨ b ⟩ C_{i}\langle{a}\rangle < C_{i}\langle{b}\rangle Ci⟨a⟩<Ci⟨b⟩
- C2:如果进程 P i P_{i} Pi 向进程 P j P_{j} Pj 发送消息,a 表示发送事件,b 表示接受事件,那么 C i ⟨ a ⟩ < C i ⟨ b ⟩ C_{i}\langle{a}\rangle < C_{i}\langle{b}\rangle Ci⟨a⟩<Ci⟨b⟩
那么我们可以说服自己 “Clock Condition 成立”。顺着思路,我们可以构建这样一个逻辑时钟:
- IR1:在进程内部,每发生一个事件,逻辑时钟绝对值自增一次
- IR2:
- 如果 a a a 事件是 " P i P_{i} Pi 发送消息 m m m 到 P j P_{j} Pj",那么 m m m 需要包含 P 1 P1 P1 发送消息时的逻辑时间戳 T m T_m Tm
- 当 P j P_{j} Pj 接收到消息 m m m 时,必须将自身的逻辑时钟与 T m T_{m} Tm 对齐,即 C j + 1 = m a x ( T m , C j ) + 1 C_{j+1} = max(T_{m}, C_{j}) + 1 Cj+1=max(Tm,Cj)+1
只要按照 IR1 和 IR2 构建逻辑时钟,逻辑时钟 C C C 就能满足 Clock Condition,进而保持分布式系统的事件偏序。
记住这个逻辑时钟的实现,你会在不同的共识算法中看见它的身影
全序
我们已经有了事件的偏序,有没可能基于此构建分布式系统事件的全序?回顾下面这个图:
![](https://img-blog.csdnimg.cn/20200527094255406.jpg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2MxODM2NjIxMDE=,size_16,color_FFFFFF,t_70)
只要能够确定 E x Ex Ex 与 E 2 E2 E2、 E 3 E3 E3 之间的关系,不论是以下哪种情况:
- E x → E 2 → E 3 Ex \to E2 \to E3 Ex→E2→E3
- E 2 → E x → E 3 E2 \to Ex \to E3 E2→Ex→E3
- E 2 → E 3 → E x E2 \to E3 \to Ex E2→E3→Ex
只要有一种方式能从中稳定的选一种,问题就解决了。
其实这个问题的解决方案出奇地简单,我们只要能给系统中的所有进程排个序即可,假设 P 1 P1 P1 排在 P 2 P2 P2 之前,那么 E x Ex Ex 就排在 E 2 E2 E2、 E 3 E3 E3 之前,即 E x → E 2 → E 3 Ex \to E2 \to E3 Ex→E2→E3 成立。
下面给出严谨的定义:假设进程间的顺序关系用 “ ≺ \prec ≺” 表示,系统中所有事件的全序用 “ ⇒ \Rightarrow ⇒” 表示,那么 “ a ⇒ b a \Rightarrow b a⇒b” 意味着下面两个条件至少有一个成立:
- C i ⟨ a ⟩ < C j ⟨ b ⟩ C_{i}\langle{a}\rangle < C_{j}\langle{b}\rangle Ci⟨a⟩<Cj⟨b⟩
- C ⟨ a ⟩ = C j ⟨ b ⟩ C\langle{a}\rangle = C_{j}\langle{b}\rangle C⟨a⟩=Cj⟨b⟩ 且 P i ≺ P j P_{i} \prec P_{j} Pi≺Pj
场景举例
实现事件的全序是保障分布式系统正确性的起点,这意味着系统中的多个节点能够达成共识。
想象这样一个具体场景:一组进程需要排他地使用某个资源,如何能保证所有进程以没有分歧的方式轮流使用这个资源?即满足:
- 当前进程释放资源后,其它进程才能使用资源
- 资源必须按照请求的先后顺序来赋予其它进程
- 只要每个进程在使用完资源后会遵守规定释放资源,那么每个请求最终都将被满足
调度中心方案
我们能否用唯一的一个调度进程 P 0 P_0 P0 来满足这个方案?似乎可以?
但想象这样一个场景:
- P 1 P_{1} P1 先向 P 0 P_{0} P0 发送资源使用请求,然后向 P 2 P_{2} P2 发送一条消息
- P 2 P_{2} P2 收到消息后向 P 0 P_{0} P0 发送资源使用请求
- 因为网络传输的延迟问题, P 2 P_{2} P2 的请求先于 P 1 P_{1} P1的请求到达 P 0 P_{0} P0
- P 0 P_0 P0 将资源先分配给 P 1 P_1 P1,等 P 1 P_1 P1 释放资源后再分配给 P 2 P_2 P2
但是根据事件的偏序关系,我们知道 P 2 P_2 P2 是在收到 P 1 P_1 P1 消息后才发送资源使用请求,但 P 1 P_1 P1 在此之前已经发送了资源使用请求,这违背了场景描述的第 2 点要求。
全序方案
基于我们构建全序的方案,我们来尝试解决这个问题。为了简化逻辑,我们先制定一个假设:
对于任意两个进程 P i P_{i} Pi 和 P j P_{j} Pj ,从 P i P_{i} Pi 发送到 P j P_{j} Pj 的消息将按顺序到达
即使没有该假设,我们仍然可以自行通过类似 TCP 的 sequence number 和 ack 机制使该假设成立。
每个进程 P i P_{i} Pi 需要维护一个逻辑时钟和收到的请求队列 (Request Queue),具体实现如下:
- 请求资源时,进程 P i P_{i} Pi 需要向所有其它进程 P j P_{j} Pj 发送消息 “ T m T_m Tm: P i P_i Pi 请求使用资源”,消息中包含 P i P_{i} Pi 的当前时间戳,同时把请求也放进本地的请求队列中
- 当进程 P j P_{j} Pj 收到请求后,将请求放入请求队列后,返回 ack 给 P i P_{i} Pi ,ack 消息中包含 P j P_j Pj 当前的最大时间戳
- 当进程 P i P_{i} Pi 要释放资源时,从本地的请求队列中移除自己的请求 “ T m T_m Tm: P i P_i Pi 请求使用资源”,并向其它进程 P j P_{j} Pj 发送释放消息 “ T m T_m Tm: P i P_i Pi 释放资源”
- 当进程 P j P_{j} Pj 收到释放请求时,从本地请求队列中移除相应的请求 “ T m T_m Tm: P i P_i Pi 请求使用资源”
- 当满足以下条件时,进程
P
i
P_{i}
Pi 被赋予资源的访问权:
- 按照逻辑时钟的顺序,消息 “ T m T_m Tm: P i P_i Pi 请求使用资源” 发生的时间 T m T_m Tm 最早
- P i P_{i} Pi 已经收到过其余所有进程的时间戳大于 T m T_m Tm 的消息
证明
论文上有相关的证明推理过程,这里不再复述。这里想说的是:确定全序的核心在于 “保证每个进程在做出占用或释放资源的决定之前已经充分了解到其它进程的信息”。
根据第 5 点:在 P i P_i Pi 获得资源的之前,已经收到所有其它进程的时间戳大于 T m T_m Tm 的消息。如果有任意其它进程 P j P_j Pj 的请求 “ T n T_n Tn: P j P_j Pj 请求使用资源” 中的时间戳 T n < T m T_n < T_m Tn<Tm ,那么 P i P_i Pi 就无法获得资源,即 P j P_j Pj 在更早的时间点就发送了资源请求。
我利用这个 项目 验证了这个方案,有兴趣不妨一看。
方案对比
如果场景的要求是:“全局上看,所有进程按照一个确定的顺序使用和释放资源”,那么 调度中心方案 是满足要求的,这里要求 “资源必须按照请求的先后顺序来赋予其它进程”,主要在于我们要求整个系统中的所有事件存在全序关系。值得关注的是,这里的 全序方案 不需要 master 节点,所有节点的身份相同,你可以将它与如今流行的分布式共识算法的选主阶段关联起来理解。
外部事件
在上文的讨论中,实际上隐含着一个假设:
所有的事件都发生在系统内部
如果系统内部的两个事件 a a a 和 b b b 之间没有直接关系,但通过某外部事件产生了因果关系,那么对于系统本身,无论如何也无法通过逻辑时钟来捕捉到这一关系,保证数据一致性也就无从谈起了。
举例如下:假设 A A A 发送消息给 C C C,同时打了个电话给 B B B,让 B B B 也发消息给 C C C, B B B 的消息可能在 A A A 的消息之前到达,且打电话事件属于外部事件,系统并不知晓。
物理时钟
NOTE: 如果不是特别感兴趣,本节可以跳过
要解决外部事件引起的问题,就必须引入物理时钟。假设 C i ( t ) C_{i}(t) Ci(t) 为物理时钟 C i C_{i} Ci 在时间 t 上的绝对数值,且 C i ( t ) C_{i}(t) Ci(t) 连续可导。真正的物理时钟应该保证稳定的速率、单调递增,即:
PC1:存在很小的常数 k k k ,使得对于任意 i,有 ∣ d C i ( t ) d t − 1 ∣ < k |\frac{dC_{i}(t)}{dt} - 1| < k ∣dtdCi(t)−1∣<k 成立
通常石英钟的 k k k 能达到 k ⩽ 1 0 − 6 k \leqslant 10^{-6} k⩽10−6。
单个时钟的稳定还不够,不同的时钟应该尽可能同步,即:
PC2:存在很小的常数 ϵ \epsilon ϵ 对于任意 i, j,有 ∣ C i ( t ) − C j ( t ) ∣ < ϵ ∣ |C_i(t) - C_j(t)| < \epsilon∣ ∣Ci(t)−Cj(t)∣<ϵ∣ 成立
设 μ \mu μ 表示系统中跨进程消息传递的最小时长。事件 a a a、 b b b 分别为不同进程上的两个事件,且 a a a 在 b b b 之前发生,如果 a a a 发生的时间为 t t t 那么 b b b 必然晚于 t + μ t + \mu t+μ 发生。通过推导,要排除外部事件的干扰,必须满足:
KaTeX parse error: No such environment: equation at position 7: \begin{̲e̲q̲u̲a̲t̲i̲o̲n̲}̲ C_i(t + \mu) -…
可以推导出:
KaTeX parse error: No such environment: equation at position 7: \begin{̲e̲q̲u̲a̲t̲i̲o̲n̲}̲ \epsilon / (1-…
即,只要上述表达式成立,那么外部事件也将不会影响系统的正确性。从该表达式也可以看出:进程之间距离越近,即 μ \mu μ 越小,那么时钟误差 ϵ \epsilon ϵ 也必须越小,才能满足要求,符合直觉。详细推导可见 原文
容忍外部事件的全序
接下来,我们将提出一个分布式系统事件全序算法的改进版本,它将保证 PC2 成立(PC1 通过物理时钟本身决定),从而在分布式系统中确定容忍外部事件的全序。
假设 μ m \mu_m μm 为每个进程所知道的通信最大延迟,即对于某个消息的发送事件 t t t 和接收时间 t ′ t' t′ ,必有 t ′ − t ⩽ μ m t' - t \leqslant \mu_m t′−t⩽μm 成立。相对于逻辑时钟一节的实现 IR1 和 IR2,我们提出 IR1’ 和 IR2’:
- IR1’: 对于任意未接收消息的时刻 t t t,进程 P i P_i Pi 的物理时钟 C i C_{i} Ci 可导,且满足 d C i ( t ) d t > 0 \frac{dC_i(t)}{dt} > 0 dtdCi(t)>0
- IR2’:
- 如果 P i P_i Pi在 t t t 时刻发送消息 m m m,那么 m m m 须包含当前时间戳 T m = C i ( t ) T_m = C_i(t) Tm=Ci(t)
- 当 P j P_{j} Pj 在时刻 t ′ t' t′ 收到消息 m m m 后,将自身的物理时钟调整为 m a x ( C j ( t ′ − 0 ) , T m + μ m ) max(C_j(t'-0), T_m + \mu_m) max(Cj(t′−0),Tm+μm)
看起来和原始的全序算法十分相似,具体证明可自行翻阅论文附录。
小结
论文讨论了分布式系统中的事件偏序,并通过一个逻辑时钟实现这样的偏序,然后进一步为分布式系统的事件赋予全序。当存在外部事件影响系统内部事件的因果关系时,逻辑时钟无能为力,这时理论上我们可以通过引入满足一定要求的物理时钟来解决这个问题。
但本文讨论的话题是建立在系统运行顺利的基础上,提出的算法不具备容错能力。但它是我们进一步理解分布式系统理论的基石。