算法的故事 / 全书目录0 / 30 章已读

第 25 章

向量时钟——1988年,为什么两台计算机的时间戳不同,却不能证明谁先谁后?

正在加载交互实验…

第一季 · 人类如何驯服数据历史主线:1978—2007年 · Lamport 逻辑时钟、Fidge 与 Mattern、向量时钟、因果关系和分布式版本冲突1988年,澳大利亚国立大学。计算机科学家 Colin J. Fidge 正在研究一个看起来非常简单的问题:如果几台计算机分别记录事件,我们怎样知道这些事件究竟谁先发生、谁后发生?假设有两台服务器。服务器 A 记录:
text
事件 a:时间戳 100
服务器 B 记录:
text
事件 b:时间戳 105
从数字上看:100<105100<105我们似乎可以判断:事件 a 先于事件 b。但是,稍微仔细一想,就会发现三个问题。首先,两台机器的时钟可能不完全同步。其次,即使物理时钟非常准确,我们也只能知道事件在时间上的先后,不能仅凭时间判断 a 是否影响了 b。最后,如果系统根本没有可靠的全局物理时钟,我们是否还能知道事件之间的依赖关系?Fidge 研究的是第三个问题。1988年,他在第11届澳大利亚计算机科学会议上发表:Timestamps in Message-Passing Systems That Preserve the Partial Ordering《保留偏序关系的消息传递系统时间戳》。同一时期,德国计算机科学家 Friedemann Mattern 也独立研究了类似思想。Mattern 的研究于1988年在学术研讨会上报告,并在1989年正式发表为:Virtual Time and Global States of Distributed Systems《虚拟时间与分布式系统的全局状态》。值得注意的是,Mattern 自己列出的文献历史中,还包括一份1987年的早期技术报告。[加州大学尔湾分校计算机科学系][Friedemann Mattern][BibTeX Entry]他们提出的核心思想是:不要只用一个数字描述逻辑时间,而是用一个由多个数字组成的向量,记录每台计算机已经知道多少其他计算机的历史。这就是:Vector Clock——向量时钟。但要真正理解它,我们必须先回到十年前。因为这段故事的真正起点,是 Leslie Lamport 在1978年提出的一个深刻概念:Happened-Before,发生在……之前。

第一幕:1978年,Lamport 发现“先后”并不是一回事

Leslie Lamport

1941年出生 · 2013年度 ACM 图灵奖得主1978年发表关于分布式事件顺序与逻辑时钟的经典论文,此后又在 Paxos、并发正确性及形式化方法领域作出重要贡献。
1978年7月,Lamport 在 Communications of the ACM 发表:Time, Clocks, and the Ordering of Events in a Distributed System《分布式系统中的时间、时钟与事件排序》。这篇论文只有八页,却深刻改变了人们讨论分布式计算的方式。Lamport 在论文中指出:分布式系统里的时间顺序,不能仅仅依赖物理时钟。更重要的是事件之间是否存在能够传递影响的信息路径。[Microsoft Research][威斯康星大学计算机科学页面]有趣的是,根据 Lamport 后来本人的回忆,论文的一个研究来源,是 Paul Johnson 和 Bob Thomas 关于复制数据库维护的工作。Lamport 从消息时间戳的思想出发,又联系了他对相对论的理解,逐步形成了事件偏序的理论框架。[Microsoft Research]

什么叫“事件 A 发生在 B 之前”?

Lamport 不首先询问:“A 的物理时间是不是比 B 早?”而是定义三种能建立因果先后关系的情况。第一种:同一个进程中的程序顺序。假设进程 P 执行:
text
a:修改变量 x
b:发送消息
程序规定 a 在 b 之前。那么:a→ba\rightarrow b第二种:消息发送与接收。进程 A 发出一条消息。进程 B 收到这条消息。发送事件一定先于对应接收事件:send(m)→receive(m)send(m)\rightarrow receive(m)第三种:传递性。如果:a→ba\rightarrow b并且:b→cb\rightarrow c那么:a→ca\rightarrow c这三条规则共同定义了 Happened-Before 关系。

一个具体故事

Alice 在服务器 A 上发布一篇文章。服务器 A 把发布消息传给服务器 B。服务器 B 收到消息后,为文章生成通知。那么:
text
a:Alice 发布文章
        ↓
b:A 发送同步消息
        ↓
c:B 收到消息
        ↓
d:B 生成通知
因此:a→da\rightarrow d即使四个事件发生在不同机器上,我们仍然可以通过本地程序顺序和消息传递证明它们之间的因果关系。但如果服务器 C 同时独立处理一笔完全不相关的订单呢?该订单既没有依赖这篇文章,也没有向 A、B 发送或接收相关消息。那么它与文章发布之间,可能不存在任何 Happened-Before 路径。这时,我们称两个事件:Concurrent——并发。

第二幕:“并发”不等于现实时间恰好相同

这是整章最容易理解错误的地方。假设:Alice 在上午10:00:00发布文章。Bob 在上午10:00:01独立提交订单。从物理时间看,Alice 的操作更早。但如果两项操作之间没有信息传递,也没有同一进程中的顺序依赖,那么在 Lamport 的 Happened-Before 模型里,它们仍然可以属于并发事件。为什么?因为这里的“并发”,指的是:在所定义的事件和通信历史中,两者之间没有可推导的因果先后关系。而不是:“它们必须在完全相同的一纳秒发生。”用符号表示,两个事件 a、b 并发,当且仅当:¬(a→b)\neg(a\rightarrow b)并且:¬(b→a)\neg(b\rightarrow a)这也说明,分布式系统中的事件顺序不是天然的一条直线。更准确地说,它是一种:Partial Order,偏序关系。对于某些事件,我们能够比较先后。对于另一些事件,我们无法从因果结构中给出先后关系。
因果顺序与并发事件
进程 A进程 Babcd消息a 与 c 没有消息依赖,因此可能并发
查看 SVG 配图原件
示意图原件 25-003.svg
25-003.svg · 点击查看大图
示意图中各进程的事件在同一水平线上按从左到右的顺序执行,b 的消息影响 d,但两条进程线上不相关的事件不一定可比较。
这其实让我们想起了上一章 CRDT。为什么两个服务器可以并发修改一个集合?因为它们断网期间互相不知道对方的操作。没有信息流,就无法确定其中一项更新是基于另一项更新发生的。所以,理解因果关系,是理解 CRDT、并发版本与分布式冲突处理的基础。

第三幕:Lamport Clock——给每个事件一个数字

既然没有统一的物理时钟,Lamport 提出了一个简单办法:每个进程维护自己的逻辑时钟。它不表示真实的年月日或毫秒。它只是一个整数计数器。假设进程 A 当前的逻辑时钟为:LA=0L_A=0进程 B 也是:LB=0L_B=0

规则一:本地事件使时钟加1

A 执行一次事件:LA=1L_A=1再执行一次:LA=2L_A=2

规则二:发送消息时,附带当前逻辑时间

A 发送消息给 B。假设发送本身是一次新事件,于是:LA=3L_A=3消息带着:
text
message = "hello"
timestamp = 3

规则三:收到消息时,取较大值再加1

假设 B 此前只执行过一次本地事件:LB=1L_B=1现在收到 A 发送的时间戳3。则:LB=max⁡(1,3)+1=4L_B=\max(1,3)+1=4为什么必须加1?因为接收事件肯定发生在发送事件之后。因此,接收事件的逻辑时间必须大于发送事件的逻辑时间。
Lamport 逻辑时钟的消息传播
AB1314消息携带 3B 接收:max(1,3)+1 = 4
查看 SVG 配图原件
示意图原件 25-004.svg
25-004.svg · 点击查看大图
因此 Lamport 时钟满足:a→b  ⟹  L(a)<L(b)\boxed{a\rightarrow b\implies L(a)<L(b)}只要 a 在因果上先于 b,其逻辑时间就一定更小。但现在,我们遇到了这项算法最关键的局限。

反过来成立吗?

如果:L(a)<L(b)L(a)<L(b)是否就意味着:a→ba\rightarrow b不成立。例如:服务器 A 独立执行了两次事件。事件 a 的逻辑时间是2。服务器 B 独立执行了五次事件。事件 b 的逻辑时间是5。两台服务器之间没有任何消息传递。因此:L(a)=2<5=L(b)L(a)=2<5=L(b)但不能推出:a→ba\rightarrow b它们仍然可能是并发事件。这就是 Lamport 逻辑时钟最重要的限制:逻辑时间较小,是发生因果先后关系的必要表现,但仅凭两个逻辑时间的大小,不能证明存在因果关系。如果需要建立一个统一的、确定性的事件总顺序,可以再用进程 ID 等信息打破时间戳相同的情况。例如根据:(L(event),processId)(L(event),processId)按字典序排序。Lamport 的1978年论文就研究了如何把因果偏序扩展成一致的全序。但这种人为选择的总顺序,并不意味着其中所有先后排列都代表真实因果依赖。[威斯康星大学计算机科学页面]

第四幕:1988年,Fidge 提出了更强的答案

Colin J. Fidge · 澳大利亚国立大学Fidge 1988年的论文研究了同步与异步消息传递系统中的事件时间戳。他希望这种时间戳不只是能够得到某条统一排序,还要能够保留分布式系统原本的偏序结构。换句话说:一个时间戳,能不能告诉我们:事件 a 影响了事件 b。或者事件 b 影响了事件 a。或者两者之间根本没有因果先后?这一次,只使用一个整数已经不够。于是,研究者采用了一个由多个计数组成的向量。[加州大学尔湾分校计算机科学系]

为什么需要多个数字?

假设系统有三个进程:A、B、C。每个进程维护一个向量:V=[vA,vB,vC]V=[v_A,v_B,v_C]其中每个分量可以理解为:当前事件已经知道相应进程的多少个事件。例如:VA=[3,1,0]V_A=[3,1,0]表示 A 的当前因果历史里,包含了 A 自己的三个事件、B 的一个事件,但还不知道 C 的任何事件。再看:VB=[2,4,0]V_B=[2,4,0]表示 B 的当前历史里,包含 A 的两个事件、B 的四个事件,但同样还不知道 C。这两个向量代表的知识范围不同。A 知道更多自己的历史。B 知道更多 B 的历史。因此它们可能无法构成单一的因果先后关系。这正是我们需要的能力。

三条更新规则

下面使用今天教材中常见的一种标准向量时钟约定。原始 Fidge 论文对不同通信模型采用过特定的事件编号与更新约定,因此不应把下面的写法误认为它逐字使用的唯一公式。初始化:每个进程都从零向量开始:VA=VB=VC=[0,0,0]V_A=V_B=V_C=[0,0,0]本地事件或发送事件:进程只把自己的分量加1。例如 A:[0,0,0]→[1,0,0][0,0,0]\rightarrow[1,0,0]接收消息:先对本地向量与消息携带的向量逐分量取最大值,然后把接收者自己的分量加1。假设 B 当前为:VB=[0,1,0]V_B=[0,1,0]A 发来的消息携带:M=[2,0,0]M=[2,0,0]B 先合并:max⁡([0,1,0],[2,0,0])=[2,1,0]\max([0,1,0],[2,0,0])=[2,1,0]再增加自己的分量:VB=[2,2,0]\boxed{V_B=[2,2,0]}这是什么意思?B 已经知道:A 至少执行了两个事件。B 自己执行了两个事件,其中第二个就是接收消息。而 C 的事件还没有被 B 观察到。与上一章 G-Counter 的逐分量 max 类似,这里也使用最大值合并。但用途不同:G-Counter 保存累计贡献,向量时钟保存因果历史的进度信息。这两种结构虽然都使用向量与逐分量最大值,却不能因此认为它们具有完全相同的业务语义。

第五幕:向量时钟真正强大的地方——能够判断并发

假设我们有两个事件:V(a)=[2,1,0]V(a)=[2,1,0]V(b)=[3,4,0]V(b)=[3,4,0]逐分量比较:第一分量:2≤32\leq3第二分量:1≤41\leq4第三分量:0≤00\leq0所有分量都满足小于等于。而且至少一个分量严格小于。我们记作:V(a)<V(b)\boxed{V(a)<V(b)}在标准完整向量时钟模型中,这说明:a→b\boxed{a\rightarrow b}这比 Lamport 标量时钟更强。它不仅能保证因果事件的数字大小正确,还可以通过向量比较反向识别因果关系。

如果两个向量无法逐分量比较呢?

例如:V(a)=[3,1,0]V(a)=[3,1,0]V(b)=[2,4,0]V(b)=[2,4,0]第一分量:3>23>2第二分量:1<41<4所以:V(a)≰V(b)V(a)\not\leq V(b)而且:V(b)≰V(a)V(b)\not\leq V(a)这意味着:a∥b\boxed{a\parallel b}符号 ∥\parallel 表示并发。不是“时间完全相同”。而是:在系统记录的因果历史中,既不能证明 a 先于 b,也不能证明 b 先于 a。这就是向量时钟真正解决的问题。

向量比较的四种结果

比较结果意义
V(a)<V(b)V(a)<V(b)a 在因果上先于 b
V(b)<V(a)V(b)<V(a)b 在因果上先于 a
两者不可比较a 与 b 并发
两向量相等相同的向量值;在版本上下文中表示同样的已记录进度
严格说,最后一种情形不能无限推广为“两条任意事件就是同一个事件”。在为每个进程的每次事件都独立递增、且副本身份唯一的完整事件时间戳模型中,不同事件通常有不同向量值。但在数据库版本向量等工程变体中,同一个因果上下文可以被多个对象或状态快照使用,因此向量相等不必代表业务对象或操作身份完全相同。

第六幕:一条消息,怎样把三台计算机的历史连接起来?

现在我们不再只看两个抽象向量。我们让三台服务器真正运行一段程序。A、B、C 的初始向量都是:[0,0,0][0,0,0]每次本地事件加1。发送消息时,把当前向量附在消息上。接收时,先逐分量取最大值,再把自己的分量加1。以下是一段完全确定的执行历史:
事件进程动作事件向量
e1A本地计算[1,0,0]
e2B本地计算[0,1,0]
e3A向 B 发送 m1[2,0,0]
e4B接收 m1[2,2,0]
e5C本地计算[0,0,1]
e6B向 C 发送 m2[2,3,0]
e7C接收 m2[2,3,2]
e8A再次本地计算[3,0,0]
这里最值得研究的是 e4。B 在 e2 之后知道:[0,1,0][0,1,0]A 在 e3 发送:[2,0,0][2,0,0]B 收到消息,计算:max⁡([0,1,0],[2,0,0])=[2,1,0]\max([0,1,0],[2,0,0])=[2,1,0]再计入接收事件:[2,2,0][2,2,0]因此,e4 的向量同时包含了 A 的两个事件与 B 的两个事件。当 B 把消息 m2 发送给 C 时,这些知识又被转发出去。最终 C 的 e7 也能够证明自己在因果上晚于 A 的 e3。注意:C 并不需要直接收到 A 的消息。只要存在消息传递路径:A→B→CA\rightarrow B\rightarrow C因果知识就可以被逐步传播。

实验一:逐步执行三台服务器的八个事件

向量时钟事件模拟器

三个进程 · 八个事件 · 两条消息
0/8
进程 A

[0,0,0]

进程 B

[0,0,0]

进程 C

[0,0,0]

e1
A · 本地事件
[—]
e2
B · 本地事件
[—]
e3
A · 发送 m1 → B
[—]
e4
B · 收到 A 的 m1
[—]
e5
C · 本地事件
[—]
e6
B · 发送 m2 → C
[—]
e7
C · 收到 B 的 m2
[—]
e8
A · 本地事件
[—]
所有进程的向量时钟初始为[0,0,0]。
建议你特别观察 e7 和 e8。e7 是 C 收到 B 消息后的事件:V(e7)=[2,3,2]V(e7)=[2,3,2]e8 是 A 独立执行的下一个事件:V(e8)=[3,0,0]V(e8)=[3,0,0]它们谁先谁后?虽然在我们这份记录中,e8 被列在最后,但这只是观察者记录这段执行的方式。C 在 e7 时只知道 A 的前两个事件。A 在 e8 时也不知道 B、C 的历史。因此,两者的向量不可比较:V(e7)∥V(e8)V(e7)\parallel V(e8)全局观察日志里的先后排列,不等于能够由通信关系证明的因果先后。

第七幕:你来判断两次事件究竟是什么关系

现在让我们把向量比较独立做成一个实验。规则很简单。对于两个相同维度的向量:X=[x1,…,xn]X=[x_1,\ldots,x_n]Y=[y1,…,yn]Y=[y_1,\ldots,y_n]如果所有分量满足:xi≤yix_i\leq y_i并且至少一个分量严格小于,那么:X<YX<Y在完整事件向量时钟模型中,X 对应事件先于 Y。如果有些分量 X 大于 Y,另一些分量 X 小于 Y,那么它们不可比较,代表并发。

因果关系比较器

事件 X

[2,0,0]

事件 Y

[2,2,0]

X 因果先于 Y

所有分量都满足 V(e3) ≤ V(e4),且至少一项严格小于。
试一试这几组。e1 与 e2:两台机器分别进行第一次独立计算,没有消息依赖,因此并发。e3 与 e7:A 发送 m1,B 收到后又发送 m2,C 最终收到,因此 e3 在因果上先于 e7。e7 与 e8:虽然 e8 在事件表中排在后面,但它既不知道 e7,也没有因果路径从 e7 传到 A,因此两者并发。这三组比较,基本涵盖了向量时钟最核心的能力。

第八幕:Mattern 发现,时间向量还能构成一种特殊的数学空间

Friedemann Mattern · 德国凯泽斯劳滕大学Mattern 的独立研究还有一个十分漂亮的数学特点。在他的论文 Virtual Time and Global States of Distributed Systems 中,时间不再只是一条数轴。而是一个多维的、带有偏序关系的空间。论文还讨论了它与相对论时空中因果结构的类比。[华盛顿大学计算机科学系主页]假设现在只有两个进程。事件时间戳就是一个二维向量:V=[vA,vB]V=[v_A,v_B]我们可以把它画在二维坐标系上。横轴表示 A 的事件进度。纵轴表示 B 的事件进度。例如:P=[2,1]P=[2,1]和:Q=[4,3]Q=[4,3]Q 在两个分量上都不小于 P。因此:P<QP<Q但如果:R=[3,4]R=[3,4]S=[5,2]S=[5,2]那么 R 在第二个分量上更大,S 在第一个分量上更大。两者不可比较。
二维向量时间:不是所有点都能排成先后顺序
A 的进度B 的进度PQRSP 在 Q 的分量偏序之前
查看 SVG 配图原件
示意图原件 25-006.svg
25-006.svg · 点击查看大图
示意图中的四个坐标用于说明向量偏序;它们不一定全部取自同一段具体的事件执行历史。
这个模型最有意思的地方在于:我们不再强迫所有事件都找到一个唯一的“第一名、第二名、第三名”。因为分布式系统的真实信息依赖,原本就不是完整的线性顺序。Mattern 在论文中还将向量偏序与格结构联系起来,并讨论如何描述分布式系统的全局状态。这又与前一章 CRDT 的数学基础产生了联系。两者都利用了:分量偏序。逐分量最大值。信息的合并。但差别同样关键。CRDT 的状态偏序服务于可收敛的数据类型设计。向量时钟的偏序服务于事件因果历史的表示。同样的数学工具,可以因为所表示的信息不同,而解决不同的系统问题。

第九幕:2007年,Amazon 为什么把向量时钟用于购物车?

我们已经在第十五章研究过 Amazon Dynamo。现在再次回到那篇2007年的论文。Dynamo 需要在多个节点之间复制数据。为了提高可用性,它允许某些更新在不同副本尚未完全同步的情况下进行。因此可能产生多个数据版本。问题是:当不同副本报告不同版本时,服务器怎样判断:这是一个已经被更新版本取代的旧版本?还是两个独立产生的并发版本?Dynamo 的经典设计利用向量时钟记录对象版本之间的因果关系。如果一个版本的向量时钟被另一个版本支配,系统可以识别前者在因果上更旧。如果两个版本的向量不可比较,就说明存在并发修改,系统可能需要同时保留它们,等待应用按照业务语义协调。[Riak 文档][All Things Distributed]

举一个具体的购物车例子

最初购物车:
text
{"苹果"}
初始版本:V0=[1,0]V_0=[1,0]A 收到用户的一次修改:
text
{"苹果", "香蕉"}
A 的新版本:VA=[2,0]V_A=[2,0]与此同时,B 也基于旧版本收到另一次修改:
text
{"苹果", "牛奶"}
B 的新版本:VB=[1,1]V_B=[1,1]比较:[2,0][2,0]与:[1,1][1,1]第一分量:2>12>1第二分量:0<10<1所以:VA∥VBV_A\parallel V_B两者是并发版本。此时,如果直接采用“后到者覆盖先到者”的策略,就可能丢失香蕉或牛奶。一种更合理的方案,是保留两个版本及其因果信息。然后由业务层按既定规则合并。不过,合并不是简单取集合并集就必然正确。如果用户的意图包含删除商品、修改数量或清空购物车,简单并集可能重新引入本应删除的内容。这正是为什么 Amazon Dynamo 原始论文特别讨论由应用进行冲突协调,以及购物车业务会遇到的语义问题。

向量时钟只负责判断关系

它能够回答:某个版本是否明确继承了另一个版本的历史?两个版本是否并发产生?但它不能自动回答:最终业务值应该是什么?例如:
text
A:商品数量改成2
B:商品数量改成5
如果是并发版本,最终应该采用2、5,还是把操作解释成两次独立增量?这需要数据类型语义和业务规则。因此:向量时钟帮助我们发现冲突;CRDT 则可以为某些类型的并发操作提供确定的合并语义。这是第23、24、25章之间最重要的一条知识连接。

第十幕:为什么不能只看操作系统时间戳?

现在我们将时间分成三种。

物理时钟:现在几点?

例如:
text
2026-10-27 16:00:00.000
这种时间戳适合日志审计、监控、超时、过期时间和现实时间相关的业务。但不同机器的时钟可能存在偏差。而且物理时间更早,并不意味着两项操作之间一定存在程序因果依赖。

Lamport 逻辑时钟:有因果关系时,数字必须保持先后

例如:
text
A 的事件:L=5
B 的事件:L=8
如果我们已经知道 A 的事件影响 B 的事件,那么 Lamport 时钟必须保持5小于8。但只知道5小于8,不能反向证明两者存在因果关系。

向量时钟:两段事件历史能否比较?

例如:
text
A:[3,1]
B:[2,4]
通过逐分量比较,可以判断两个事件在给定模型下是否存在因果先后,或者是否并发。
时钟主要信息能否仅凭时间戳判断因果并发
物理时间现实时间估计不能
Lamport 标量逻辑时钟与因果关系相容的标量顺序不能精确判断
完整向量时钟各参与者因果历史进度可以,在对应模型和前提下
但是向量时钟不是没有代价。如果系统有 NN 个固定参与者,那么一个完整向量通常需要:O(N)O(N)的元数据空间。每次合并或比较,也需要:O(N)O(N)的时间。当系统只有三台服务器时:
text
[4,2,7]
非常简单。但如果系统拥有几万台参与者,为每个对象版本保存几万个计数分量,就可能不现实。因此,实际系统需要考虑:稀疏表示。版本向量。动态成员管理。因果上下文压缩。以及 Dotted Version Vectors 等改进技术。不过,这些优化都需要明确前提。随意删掉向量里的一个分量,可能导致原本能够区分的因果关系被误判为并发,甚至把并发版本错误认作旧版本。元数据压缩必须保留系统真正需要的因果语义,而不是简单截断几个数字。

第十一幕:自己用 Python 实现一个向量时钟

现在开始编程实践。我们先实现最经典的固定参与者模型。每个进程知道参与者 ID 集合。内部使用字典保存各分量。
python
class VectorClock:
    def __init__(self, process_id, process_ids):
        if process_id not in process_ids:
            raise ValueError("Unknown process ID")

        self.process_id = process_id

        self.clock = {
            pid: 0 for pid in process_ids
        }

    def tick(self):
        """执行一次本地事件"""
        self.clock[self.process_id] += 1

    def send(self):
        """发送消息也算一次事件"""
        self.tick()

        # 返回附在消息上的时间戳副本
        return self.clock.copy()

    def receive(self, timestamp):
        """先合并时间戳,再计入接收事件"""
        if self.clock.keys() != timestamp.keys():
            raise ValueError("Clock dimensions differ")

        for pid in self.clock:
            self.clock[pid] = max(
                self.clock[pid],
                timestamp[pid]
            )

        self.tick()

    def snapshot(self):
        return self.clock.copy()
再实现比较函数:
python
def compare_clocks(a, b):
    if a.keys() != b.keys():
        raise ValueError("Different process sets")

    a_le_b = all(
        a[pid] <= b[pid]
        for pid in a
    )

    b_le_a = all(
        b[pid] <= a[pid]
        for pid in a
    )

    if a_le_b and b_le_a:
        return "equal"

    if a_le_b:
        return "before"

    if b_le_a:
        return "after"

    return "concurrent"
这里的 before 表示第一个向量在因果偏序中严格先于第二个。after 表示相反方向。concurrent 表示不可比较。

重现三台服务器的历史

python
ids = ["A", "B", "C"]

a = VectorClock("A", ids)
b = VectorClock("B", ids)
c = VectorClock("C", ids)

# e1:A 本地事件
a.tick()
e1 = a.snapshot()

# e2:B 本地事件
b.tick()
e2 = b.snapshot()

# e3:A 发消息 m1
m1 = a.send()
e3 = a.snapshot()

# e4:B 收到 m1
b.receive(m1)
e4 = b.snapshot()

# e5:C 本地事件
c.tick()
e5 = c.snapshot()

# e6:B 发消息 m2
m2 = b.send()
e6 = b.snapshot()

# e7:C 收到 m2
c.receive(m2)
e7 = c.snapshot()

# e8:A 再次本地事件
a.tick()
e8 = a.snapshot()
检查输出:
python
print(e3)
# {'A': 2, 'B': 0, 'C': 0}

print(e7)
# {'A': 2, 'B': 3, 'C': 2}

print(compare_clocks(e3, e7))
# before

print(compare_clocks(e7, e8))
# concurrent
这一段代码与你前面操作的交互模拟器,使用的是同一套规则。

为什么收到消息时必须把自己的分量再加1?

假设 B 收到来自 A 的消息:M=[2,0,0]M=[2,0,0]B 原本:VB=[0,1,0]V_B=[0,1,0]如果只执行最大值合并:[2,1,0][2,1,0]那么它还没有为接收这个消息的事件本身计数。如果我们希望每一次本地计算、发送和接收都拥有独立的事件时间戳,就必须计入接收事件:[2,2,0][2,2,0]这使发送事件与接收事件具有严格的向量先后关系。另外,有些文献采用不同的事件编号和递增约定。比较算法时,必须统一其时间戳定义,不能将不同约定的公式随意拼接。

第十二幕:为什么向量时钟能够表达因果,却不能重建所有消息?

这里还有一个非常值得深入的理论区别。向量时钟能够告诉我们:a→ba\rightarrow b也就是说,事件 a 属于事件 b 的因果历史。但这并不等于它能够告诉我们:究竟是哪一条消息直接把 a 的信息传给了 b。假设:A 先后发送两条消息 m1、m2。B 先收到 m2,再收到 m1。由于 m2 已经携带了 A 在发送 m1 时的部分历史,B 接收 m2 后,可能已经间接知道 m1 对应的发送事件。即使后来又接收到 m1,向量时钟所表达的因果偏序也不一定能够唯一重建原始消息网络中的每一条通信边。这是因为不同的直接通信关系,可以具有相同的传递闭包。因果顺序记录的是哪些事件能够通过信息路径互相影响,而不是一份完整的网络消息抓包记录。所以,如果我们要开发一个分布式调试系统,还可能需要记录:消息 ID。发送者和接收者。消息发送与接收事件的对应关系。Trace ID 与 Span ID。网络重试信息。而向量时钟则负责补充事件之间的因果偏序。它们不是互相替代的工具。

第十三幕:什么时候应该使用向量时钟?

设想你正在设计一个分布式应用。不同业务可能需要不同类型的时间与顺序信息。
业务需求更相关的技术
显示操作发生在北京时间几点物理时钟
给事件构造与因果关系兼容的标量顺序Lamport 逻辑时钟
判断两个复制版本是否并发产生版本向量、向量时钟
确保评论不会先于依赖的原消息显示因果关系追踪与因果交付协议
对所有副本提交相同日志顺序Raft、Paxos 等共识协议
让可交换的数据修改自动合并CRDT
验证数据库的实际读写是否线性一致操作历史记录与一致性检查
这也解释了为什么向量时钟无法取代第十六章的 Raft。向量时钟可以发现两个操作是并发的。但它不会自动把两个并发操作变成统一决定。它也不会自动保证余额非负、库存不超卖。同样,Raft 可以协调日志顺序,却不一定需要给每一个业务事件都维护完整向量时钟。好的分布式系统,不是把所有高级算法都放进去,而是让每种算法解决它真正擅长的问题。

终章挑战:两个向量,一个数字更大,就代表发生得更晚吗?

假设有两个副本。事件 X 的向量时间戳是:V(X)=[5,2,0]V(X)=[5,2,0]事件 Y 的向量时间戳是:V(Y)=[4,3,1]V(Y)=[4,3,1]它们究竟是什么关系?
请选择正确答案

尾声:1988年,两位研究者让计算机开始理解“我知道你知道什么”

1978年,Lamport 提出了分布式事件的 Happened-Before 关系与标量逻辑时钟。这使研究者能够摆脱对统一物理时钟的依赖,描述事件之间的逻辑顺序。但 Lamport 标量时钟仍然存在局限:时间戳较小,并不能反向证明因果关系。1988年,Colin Fidge 发表研究,使用向量时间戳保留消息传递系统中的偏序结构。Friedemann Mattern 也独立发展了向量时间模型。他的相关初步研究可以追溯到1987年的技术报告,1988年进行学术报告,正式论文发表于1989年。此后,向量时钟的思想被应用到分布式版本管理、因果关系追踪、复制系统和一致性协议中。2007年,Amazon Dynamo 进一步展示了如何利用向量时钟区分对象版本的继承与并发关系。从这段历史中,我们可以看到一种非常优雅的演进。普通物理时钟回答:“现在几点?”Lamport 逻辑时钟回答:“怎样给事件一个符合因果约束的时间编号?”向量时钟则能够回答:“当这个事件发生时,它已经知道哪些参与者的历史?另一个事件是否建立在这些历史之上?”这就是为什么一个包含多个数字的向量,比单独一个数字更有表达力。它保存的不是简单的时间大小,而是一段分布式计算中的因果知识。

第二十五章知识总结

概念核心思想
Happened-Before根据进程顺序、消息传递与传递性建立因果关系
Partial Order有些事件能够比较因果先后,有些不能
Concurrent Events两个事件之间不存在可推导的因果先后
Lamport Clock用标量记录符合因果要求的逻辑时间
Vector Clock用多维计数描述事件的因果历史
Component-wise Maximum消息到达时合并各进程已知的历史进度
V(a)<V(b)V(a)<V(b)在完整事件模型中表示 a 因果先于 b
Incomparable Vectors两个事件处于并发关系
Version Vector在复制版本管理中表示版本进度与继承关系
Causal Consistency不颠倒存在因果依赖的操作
CRDT借助预先定义的数学合并规则实现状态收敛

本章原始研究资料

Lamport(1978):《Time, Clocks and the Ordering of Events in a Distributed System》及本人历史回顾Lamport 1978年原始论文 PDFFidge(1988):《Timestamps in Message-Passing Systems That Preserve the Partial Ordering》论文链接Friedemann Mattern 官方学术论文目录:1987年初稿与1989年正式发表记录Mattern(1989):《Virtual Time and Global States of Distributed Systems》完整论文 PDFWerner Vogels(2007):Amazon Dynamo 与向量时钟的应用历史Rutgers 分布式系统课程:向量时钟更新和比较规则
下一章预告

第二十六章:分布式快照——1985年,为什么计算机不必同时暂停,也能拍下一张全局一致的照片?

1985年2月,计算机科学家 K. Mani Chandy 与 Leslie Lamport 在 ACM Transactions on Computer Systems 发表:Distributed Snapshots: Determining Global States of Distributed Systems。《分布式快照:确定分布式系统的全局状态》。这篇论文提出了后来著名的 Chandy–Lamport 分布式快照算法。[APICe]想象现在有三台服务器。A 已经从账户中扣除了100元。但对应的转账消息还在网络里。B 尚未收到消息,因此还没有增加100元。如果我们此时直接分别保存 A、B 的本地状态,似乎会发现:总金额少了100元。这笔钱去哪了?实际上,它并没有丢失。它只是还在网络上传输。因此,如果要记录整个分布式系统的状态,不能只保存所有计算机的内存。还必须考虑:网络中正在传输、尚未被接收的消息。Chandy 和 Lamport 的算法提出了一种巧妙的方法:不必让所有计算机在完全相同的物理时刻同时暂停,也能够构造一个一致的全局状态快照。下一章,我们将亲自模拟 Marker 消息的传播过程,研究一致性切割(Consistent Cut)、通道状态和在途消息。最精彩的问题是:当没有一台机器能够直接看到整个系统时,它们究竟怎样合作,才能得到一份正确的全局快照?