服务 P 修改一条记录,再发消息通知服务 Q;Q 收到通知后更新索引。两份日志的墙上时间却显示,索引更新比记录修改早了五毫秒。这个排序能证明系统违反了业务顺序吗?不能。机器时钟可能有偏差,但消息接收必须晚于相应发送,这条约束来自通信过程。

上一篇的网络 KV 与持久化恢复处理单机服务的请求顺序和故障恢复。跨越进程之后,一条本地日志不再包含全部事件,恢复出来的记录也未必足以比较另一台机器的操作。本篇从事件和消息定义因果偏序,用 Lamport 时钟保持这个偏序,再用向量时钟识别模型内的并发。物理时间仍有用途,只是它需要另一组假设。

顺序从哪些事实产生

假定系统有固定的三个进程 P、Q、R。每个进程内部的事件按一个顺序执行,跨进程的影响全部通过显式消息传递;发送和接收是不同事件。消息可以延迟和乱序,讨论到的接收事件都有对应发送。进程不重启回退计数器,不复用身份,整数不溢出。若应用通过共享数据库等其他途径通信,也必须把那条路径纳入模型。

在这个模型中,a → b 表示 a happens-before b,定义来自三条规则:同一进程上先发生的事件在后发生事件之前;消息发送在相应接收之前;关系具有传递性。它是严格偏序,不包含 a → a。两个不同事件之间,若两个方向都不存在这条关系,就称它们并发,记作 a ∥ bLamport 1978 年论文在第 559 页给出这些定义。

这里的“并发”不要求两个 CPU 在同一物理时刻执行。即使两条操作相隔一小时,只要模型内没有通信路径建立先后关系,仍可能是并发事件。相反,两次操作即使墙上时间相同,也可能通过消息有明确先后。

1
2
3
4
5
局部顺序:P: a → b
Q: c → d → e
R: f → g → h
消息匹配:m1: a → d
m2: e → g

消息边表示 a → de → g;三行局部顺序不表示不同进程间的物理时间距离。沿路径可得 a → g;b 和 g 之间没有任何方向的路径,因而并发。这张图也给出实验输入:每个进程的局部顺序,加上两条消息匹配边。

happens-before 描述信息传播可能形成的影响。它不证明某个数据值在业务逻辑上实际改变了另一个数据值。Q 收到消息后完全忽略其内容,发送与接收之间依然存在协议层的先后关系。反过来,日志没有记录共享存储上的通信,也不能据此宣布两个业务操作互不相关。

一个整数能保留什么

Lamport 时钟为每个进程维护整数 L,初始为零。本地事件和发送事件把 L 加一;消息携带发送事件的 L;接收带有时间 t 的消息时执行 L = max(L,t) + 1。每个事件记录更新后的值。这里每次增加一是具体实现,原论文的时钟条件只要求相应顺序严格递增。

证明 a → b 推出 L(a) < L(b),只需检查定义中的三种来源。同一进程每发生一个事件都增加时钟,所以局部先后保持严格小于。接收时取最大值再增加,接收时钟必定大于发送时钟。任意因果路径由这些边组成,而整数的严格小于具有传递性,结论就扩展到整条路径。论文第 560 页的 Clock Condition 与 IR1、IR2 是这一推导的依据。

逆命题不成立。图中的 b 是 P 的第二个事件,L(b)=2;g 接收 Q 在 L(e)=3 时发出的消息,因此 L(g)=4。虽然 2<4,b 与 g 仍没有因果路径。g 的较大数值可能来自另一个进程的活动,不能据此推出它接收过 b 的信息。

相同的 Lamport 值也不能为同一条因果链标号。由于有因果关系必定严格递增,两个不同事件的时钟相等足以排除它们的两个因果方向。但时钟不同并不能排除并发,所以标量时钟只能给出部分否定证据,不能完整判定因果关系。

若需要确定性的排序键,可比较 (L,进程 ID),先比较时钟,再用固定的进程 ID 顺序打破平局。同一进程的事件本来就有不同的 L。这个排序扩展了因果偏序,但它也为并发事件人为选择了顺序。

排序键不自动形成共识或全序广播。节点刚看到 (4,Q) 时,仍可能尚未收到另一条 (3,P) 消息;若立即输出,晚到消息就会破坏按键排序。哪些消息已经收齐、何时可以交付、失效节点是否还会发来更小的消息,都需要额外协议。Lamport 论文第 561 页的全序构造,不能直接替代这些交付条件。

向量保存各进程的事件前缀

为每个进程分配一个固定分量,维护三维向量 V,初始为 [0,0,0]。本地或发送事件只将自己的分量加一;接收消息时,先对本地向量与消息向量逐分量取最大值,再把自己的分量加一。事件记录更新后的向量,发送时复制整个向量,不能让消息继续引用以后会被修改的数组。

例如 d 收到 a 的 [1,0,0] 时,Q 已经执行 c,自己的向量为 [0,1,0]。合并得到 [1,1,0],再增加 Q 分量,d 的时间戳为 [1,2,0]。Q 的下一个事件 e 得到 [1,3,0]。R 在接收前已经执行 f,因此 g 的结果是 [1,3,2],不能忽略本地已有事件而写成 [1,3,1]

定义向量 u < v 为:每个分量都有 u[i] ≤ v[i],且至少一个分量严格小于。不能使用编程语言里的字典序代替这个定义。[2,0,0][1,3,2] 各有大于对方的分量,两者不可比,正好对应 b 与 g 并发。

标准向量算法在上述模型下具有双向性质:对不同事件 a、b,a → b 当且仅当 V(a) < V(b)。这一结论见 Mattern 的 Virtual Time and Global States of Distributed Systems §7–8,尤其 Theorem 10。文章发表于 1989 年会议论文集;链接是有少量编辑修订的授权重印版本,印刷页码与初版不同。

证明可以用事件前缀解释。给进程 i 的事件按本地顺序编号,V(e)[i] 表示截至 e 的因果过去中,进程 i 已经到达的最大事件编号;若 e 本身属于 i,也把 e 包含在这个集合里。进程内的因果过去是连续前缀,因为知道第 k 个事件必定包含其全部局部前驱。

这个不变量从全零初始状态开始成立。本地事件增加自己的编号;接收消息把发送方已包含的各个前缀合并进来,逐分量取最大值恰好是前缀并集。最后增加接收方自己的事件编号。消息只沿通信边传播,所以合并不会凭空加入没有因果路径的事件。

a → b,a 包含的所有前缀都进入 b,故每个分量不减;沿途至少有一次事件发生,向量严格增加。反向设 a 属于进程 i、编号为 k,且 V(a)<V(b)。于是 V(b)[i]≥k,按照不变量,b 的因果过去已包含 a。排除 a=b 后得到 a → b。这也证明了向量不可比等价于两个方向都没有因果关系。

这一证明依赖完整的事件和通信模型。若进程重启后把自己的分量归零,编号 k 就可能被重复使用;若进程身份动态加入,却没有定义新分量和旧身份的处理,向量也失去统一含义。工程上需要持久化计数、区分进程 incarnation,或采用有清楚成员规则的其他版本表示。不能只复制三行更新公式,就宣称获得任意部署环境的因果检测。

能判定偏序,不代表能复原每条消息

Fidge 的 1988 年原始论文也研究向量时间戳,但其异步规则 RA4 和事件比较 EA1 的具体写法与上面的标准记法不同。涉及原公式时应保留其事件约定,不能把另一篇论文的更新规则接到它的比较条件上。原文扫描可见 Timestamps in Message-Passing Systems That Preserve the Partial Ordering,异步规则在第 58–59 页,证明在附录 A。

后来的限制性结果直接关系到故障追踪。Fidge 1998 年的论文说明,向量时间戳足以表示 happens-before,却未必能唯一恢复原通信图;消息乱序会让某条消息边在传递闭包中变得冗余。

考虑两个进程。P 依次发送 m1、m2,对应 e、f;Q 先接收 m2,再接收 m1,对应 g、h。按照标准算法,四个向量依次为 [1,0][2,0][2,1][2,2]。其中已经有 e → f → g → h,所以额外的 m1 边 e → h 不再增加可达关系。

再构造另一段执行,把 e 和 h 改为纯本地事件,只保留 f 到 g 的消息。四个时间戳完全一样,事件偏序也一样,实际发送的消息却少了一条。因此,仅凭向量时间戳不能知道 h 到底收到 m1 还是仅执行了本地操作。这个简化反例使用不同事件类型;Fidge 原文进一步讨论了即使附加事件类型仍存在的恢复限制。

这不反驳向量时钟的双向定理:两段执行的因果闭包本来就相同。若追踪工具需要还原“哪次发送对应哪次接收”,应同时记录唯一消息 ID,或记录接收时携带的发送向量和接收向量等匹配信息。业务请求 ID、消息身份、事件时间戳解决的是不同问题。

物理钟与单调钟的工程边界

墙上时间适合展示日期、跨系统对照时间区间,但比较它需要了解同步误差、时钟调整和时间来源。仅有两个机器的时间戳大小,无法排除偏差造成的反转。逻辑时钟保留消息关系,却不表示实际经过的秒数;将 L 的差当作网络延迟也没有意义。

本地测量耗时通常采用单调时钟。Go 1.27.0 time 文档的 Monotonic Clocks 节说明,time.Now() 返回的 Time 可以同时包含墙上时间与单调读数。两个值都带单调读数时,SubBeforeAfterEqual 等使用该读数;任一个不带时,操作回到墙上时间。

Go 的 JSON、Gob、二进制和文本序列化不保存单调读数,因为它在当前进程外没有意义。time.Datetime.Parsetime.Unix 产生的值也不含单调读数;Round(0) 是显式去掉该部分的方法。因而把起点序列化后传到另一个服务,再调用 Sub,不能声称继承了起始进程的单调耗时保证。== 还比较位置和内部表示,比较时间点通常应使用 Equal

某些系统休眠时单调钟会暂停,具体耗时需求仍应核对平台行为。单调读数也不提供跨机器统一原点,更不能单独保证租约安全:租约需要说明时钟速率或误差约束、服务暂停、请求延迟,以及持有者失效后旧操作怎样被拒绝。后续涉及租约与 TrueTime 时,这些假设必须重新列出。

实验:独立事件图核验所有事件对

代码在 examples/distributed-systems/clocks05/main.go,只使用 Go 标准库。它接收写在代码里的拓扑事件序列:事件 ID、进程 ID,以及接收事件对应的发送 ID。输入序列中 a 排在 c 前面只是计算时的拓扑安排,不会自动生成 a 到 c 的因果边。

程序先按每个进程的前后事件构造局部边,再按消息 ID 构造发送到接收的边,用 Floyd–Warshall 计算传递闭包。这条路径不读取任何时间戳。另一条路径独立执行时钟更新,最后比较图可达性与时钟关系,避免把待检验的向量关系本身当作判定答案。

在仓库的实验模块目录运行:

1
2
3
cd examples/distributed-systems
GOCACHE=/private/tmp/ds-go-cache go run -race ./clocks05
GOCACHE=/private/tmp/ds-go-cache go vet ./clocks05

本地实际得到的八个事件如下。R 的 g 是第二个事件,因此向量第三项为 2。

事件 操作 Lamport 值 向量值
a P 发送 m1 1 [1,0,0]
b P 本地事件 2 [2,0,0]
c Q 本地事件 1 [0,1,0]
d Q 接收 m1 2 [1,2,0]
e Q 发送 m2 3 [1,3,0]
f R 本地事件 1 [0,0,1]
g R 接收 m2 4 [1,3,2]
h R 本地事件 5 [1,3,3]

程序遍历 56 个不同事件的有序对,检查因果推出标量严格递增、因果与向量小于等价、并发与向量不可比等价。随后显式检查 b、g 的标量反例,并运行上一节的乱序消息例子,确认不同直接消息边可以产生相同时间戳和因果闭包。

1
2
3
PASS pairs=56 scalar-implication=true vector-equivalence=true concurrent-equivalence=true
COUNTEREXAMPLE b,g scalar=2<4 causal=false vectors-incomparable=true
PASS overtaking same-clocks=true same-happens-before=true different-message-edges=true

负例开关会删除接收时的向量合并,但仍增加本地分量;图构造与标量逻辑保持正常。执行以下命令应失败,这次本地执行也确实以非零状态退出:

1
GOCACHE=/private/tmp/ds-go-cache go run ./clocks05 -omit-receive-merge
1
2
vector equivalence failed a,d: reachable=true vectors=[1 0 0],[0 2 0]
exit status 1

程序还执行真实的 Go 时间 JSON 往返,检查 Equal 所表示的时间点未改变;单调字段被省略的保证来自官方 API 文档,不从 Equal 成功反推内部字段。另一个注入模型令墙上读数从 1000 变为 900、单调读数从 50 变为 60,得到 wall-delta=-100monotonic-delta=10。它只展示回拨对减法的影响,没有修改操作系统时钟,也没有观察真实 NTP 调整。

这些是单进程确定性模型与标准库调用的本地验证。race 检查通过不等于验证了真实分布式网络;固定八事件穷举也不是所有可能执行的形式化证明。一般性保证由前面的不变量推导支撑,实验负责暴露实现与推导之间的偏差。

两个推导练习

练习一: 在八事件图中,把 b 改为发送事件,并让 R 在 g 之后、h 之前接收它。b 与原 g 还并发吗?新增接收事件和 b 又是什么关系?

原 g 的因果过去不会因为未来多收一条消息而改变,所以 b 与 g 仍并发。新增接收事件记为 x,合并 g 的 [1,3,2] 和 b 的 [2,0,0] 后,再增加 R 分量,得到 [2,3,3],此时 b → x。若 h 紧接 x,则 h 为 [2,3,4]。已发生事件的时间戳不能在后来补写,否则记录就不再对应当时的因果过去。

练习二: 若两个独立进程都用 (Lamport 值,进程 ID) 排序消息,且比较函数完全相同,是否足以让它们执行相同命令序列?

还不够。P 已收到键为 (4,Q) 的命令并执行,R 先收到 (3,P) 再收到 (4,Q),两者暂时拥有不同集合。相同比较函数只能保证对相同集合计算相同排序,不能保证集合何时完整、消息何时可交付。若还允许崩溃或消息永久丢失,等待策略又面临活性问题。下一篇的一致性模型将把“按因果排序”“保持单个客户端程序顺序”和“尊重真实时间先后”等要求分别写成可检查的历史条件。

参考资料与核验记录

MIT 6.852J Fall 2009 的课程阅读表在 Lecture 11 将时钟、状态机模拟和向量时间戳放在同一教学单元,并列出 Lamport 与 Mattern。这是对系列 MIT 2026、Stanford 2024 主课程结构的补充,不把旧课程单元冒称为当年新版安排。

核心来源包括 Lamport《Time, Clocks, and the Ordering of Events in a Distributed System》(CACM,1978 年 7 月,558–565 页),Fidge 1988 年原论文,Mattern 1989 年论文及链接重印版本,以及 Fidge《A limitation of vector timestamps for reconstructing distributed computations》(1998 年预印本、同年 Information Processing Letters 68(2) 正式发表)。Go API 行为按 Go 1.27.0 固定版本官方文档和本机该版本 src/time/time.go 的包文档核对,查阅日期为 2026-09-19。

论断与证据核验状态记录页节定位、模型差异和反向限制检索;实验验证记录记录命令、实际输出和未验证事项。Fidge 1998 年的限制是追踪能力边界,不应写成“向量时钟的因果定理被推翻”。