Chapter 02
时间、时钟与因果序
上一章确立了根本约束——节点无法区分"慢"和"死"、没有共享时钟。这一章正面应对"没有全局时钟"的现实:给出分布式系统里唯一可靠的排序工具——因果序(happens-before),以及三种递进的时钟机制。
本章你将建立的 schema
- 为什么物理时间戳不能用来排序——漂移、回拨、闰秒三条根因
- happens-before 偏序是分布式系统中唯一客观的顺序
- Lamport 时钟保住因果但看不见并发
- 向量时钟看得见并发、代价 O(N)
- HLC 如何兼得物理可读与因果安全
2.1物理时钟为什么不能用来排序
服务器的 wall-clock 会漂移、会被 NTP 回拨、会因闰秒跳变,跨节点几十毫秒偏差是常态。
如果物理时间戳可靠,分布式排序只需比较时间戳大小。工程师会在每次写入时打上 System.currentTimeMillis(),然后用"最后写入胜"(LWW)决定谁保留。问题是:这个假设在真实机器上不成立。
石英振荡器的频率误差约为 ±200 ppm,最坏情况下每天累积 17 秒偏差。NTP 修正这一偏差时有两种模式:slew(缓慢拉近,速率上限约 500 ppm)和 step(直接跳变)。step 向后跳时,同一进程内 end − start < 0 成立——时间倒流。闰秒(leap second)未被操作系统平滑时,秒计数会出现"23:59:60"或重复"23:59:59",导致整秒级跳变。
在 LWW(Last-Write-Wins)语义下,时钟快的节点会静默胜出:它的写入时间戳更大,即便那次写入在因果上更早。时钟慢节点的写入被丢弃,没有任何报错。
Cassandra 使用微秒级时间戳 LWW。一条删除操作(DELETE)在节点 A 执行,30 ms 后节点 B 收到一次旧的 INSERT——但 B 的时钟比 A 快 30 ms,所以 INSERT 的时间戳反而更大,删除被覆盖,被删记录"复活"。这是真实生产事故的根源,不是理论假设。
NTP 的 slew 模式能"缓慢纠正"时钟偏差,那它能消除 LWW 的风险吗?
展开答案(先停 10 秒)
不能。slew 只是让两个节点的时钟差距慢慢收窄,但任何时刻两者之间仍有残余偏差(典型 10–100 ms)。只要偏差存在,时间戳大的不一定因果上更晚。LWW 需要"时间戳等价于因果顺序",而 slew 只保证"偏差有界",两者之间有本质差距。
2.2happens-before:唯一客观的顺序
a→b 当且仅当:a、b 同进程且 a 在前;或 b 收到了 a 发出的消息;或上述的传递闭包;否则 a、b 并发。
物理时钟排序失效之后,工程师需要一个不依赖硬件时钟的顺序定义。happens-before 只依赖进程内顺序和消息传递这两个客观事实,因此在任何网络拓扑下都成立。
Lamport 1978 年给出的定义:关系 →(happens-before)是满足以下三条规则的最小关系:
- 若 a 和 b 在同一进程,且 a 在 b 之前执行,则
a → b。 - 若 a 是某条消息的发送事件,b 是该消息的接收事件,则
a → b。 - 传递性:若
a → b且b → c,则a → c。
当 a → b 和 b → a 均不成立时,a 与 b 并发(concurrent)。并发不是"同时",而是"没有因果路径"——系统中没有任何机制能区分它们的先后。
因果序是偏序(partial order),不是全序。两个没有因果路径的事件,客观上没有先后,任何强加的顺序都是人为的选择,而非观察到的事实。这一点是理解本章所有后续机制的基础。
happens-before 类似"相对论中的光锥":事件 a 能影响事件 b,当且仅当 a 的"信息"能沿消息链路到达 b。光锥之外的事件互不影响,分布式系统中因果路径之外的事件互不感知。类比的边界:分布式系统的"光速"是消息延迟,它不是常数,可以无上界。
图 2.2 中,事件 a 和事件 i 之间有 happens-before 关系吗?
展开答案(先停 10 秒)
有。路径:a → b(同进程)→ d(消息)→ h(同进程,P2 内 d 先于 f,但 h 是 P3 事件……需要再核实路径)。实际上 a→b→d,d→h,h→i(同进程),所以 a→i 成立。这说明因果关系可以跨越多跳消息传递形成传递闭包。
2.3Lamport 逻辑时钟
每进程一个整数计数器:本地事件 +1、发送带上当前值、接收取 max(本地, 收到)+1。
happens-before 是一个关系,不是一个数值。要把这个关系用数值编码(便于比较、存储、传输),需要一种给事件分配单调整数的方法,且该整数必须尊重因果序。
Lamport 时钟的更新规则(Clock Condition):
- 进程 P 执行本地事件:
L_P ← L_P + 1 - 进程 P 发送消息:带上
L_P,发出前执行上条规则 - 进程 P 接收消息(携带时间戳
m):L_P ← max(L_P, m) + 1
这套规则保证:若 a → b,则 C(a) < C(b)。但反过来不成立:C(a) < C(b) 不能推出 a → b。原因是 max 操作会把并发事件的计数器"对齐",两个完全无关的事件也会被赋予不同的 Lamport 值,看起来像有顺序,其实是人为的。
并发性在 Lamport 时间戳里不可见:工具提供了一个全序(通过打破平局)但丢失了"哪些事件其实并发"这个信息。
走查示例:进程 P1 和 P2 各自从 L=0 出发。P1 执行事件 a(L_P1=1),发出消息(带 L=1)给 P2;P2 接收后 L_P2 = max(0,1)+1 = 2,执行事件 d;P2 再执行事件 e(L_P2=3);与此同时 P1 继续执行 b(L_P1=2)。此时 L(e)=3,L(b)=2,但 e 和 b 是并发事件——Lamport 给了它们不同的值,却无法告知这一点。
两个副本各自把计数器推到 5:一个在 P1 上,一个在 P2 上。能判断这两个"5"谁先发生吗?
展开答案(先停 10 秒)
不能。Lamport 时钟只保证"若 a→b 则 L(a)<L(b)",但两个值相等(或一大一小)不能反推因果关系。L(P1)=5 和 L(P2)=5 可能是并发事件,也可能 P2 的 5 因果上晚于 P1 的 5——单凭时间戳无法区分。这正是向量时钟要解决的问题。
2.4向量时钟:看见并发
每节点维护长度 N 的计数器数组,只增自己那一格;两事件的向量谁分量全 ≥ 谁就是因果在后,互不支配则并发。
Lamport 时钟将并发事件强行赋予了先后,导致系统在合并冲突写入时无法判断"两个版本是否并发"——只有并发时才需要合并,有因果序的版本直接取更新的即可。向量时钟为每个版本编码了"它知道哪些事件发生了",从而让冲突检测变得精确。
向量时钟 VC 是一个长度为 N 的整数数组(N = 节点数)。更新规则:
- 节点 P_i 执行本地事件:
VC[i] ← VC[i] + 1 - 节点 P_i 发送消息:将当前 VC 附在消息上
- 节点 P_i 接收消息(携带向量
m_VC):逐分量取 max,再自增本格:VC[j] ← max(VC[j], m_VC[j])for all j,然后VC[i] ← VC[i] + 1
比较规则:向量 A 支配向量 B(即 A 因果晚于 B),当且仅当 A 的每个分量都 ≥ B 对应分量,且至少一个 >。若 A 不支配 B 且 B 不支配 A,则两事件并发。
代价:每条消息要携带 O(N) 的数组。5000 节点的集群里,每个事件的向量时钟就是 5000 个整数。Amazon Dynamo 在商品推荐系统(购物车服务)中使用向量时钟,但向量过长时对其剪枝(按时间戳删除最老的条目),牺牲精确性换可用性——剪枝后会把并发误判为有序。
向量时钟类似"每节点带一本日志,记着自己见过多少自己的、以及从别人那里传递来的更新"。两个版本的向量比较,相当于看"其中一个是否见过另一个见过的所有事件"。边界:这要求节点集合固定,动态扩缩容会让向量维度变化,需要特殊处理。
购物车示例:用户在客户端 A 加入商品 X,在客户端 B 加入商品 Y,两个写入并发发到服务器。若用 LWW,某一个写会被静默丢弃。向量时钟检测到两个版本并发(向量互不支配),将冲突交给应用层合并——购物车包含 X 和 Y,没有数据丢失。
2.5HLC 混合逻辑时钟
64 位时间戳 = 物理毫秒 l + 逻辑计数 c,同毫秒内的因果事件靠 c 区分,且 l 永不回拨。
纯逻辑时钟(Lamport/向量)对运维不友好——时间戳与实际时间毫无关系,调试时无法确认"这个版本大约是几点发生的"。物理时钟有回拨风险。HLC 在同一个 64 位字段里兼顾两者。
HLC 由一对 (l, c) 组成,l 是毫秒级物理时间,c 是同一毫秒内的计数器。更新算法:
// 本地事件或发送消息前:
pt = 当前物理时间(毫秒)
l' = max(l, pt)
if l' == l:
c = c + 1
else:
l = l'
c = 0
// 接收消息(携带 lm, cm):
pt = 当前物理时间(毫秒)
l' = max(l, lm, pt)
if l' == l == lm:
c = max(c, cm) + 1
elif l' == l:
c = c + 1
elif l' == lm:
c = cm + 1
else:
c = 0
l = l'
关键性质:l 始终 ≥ 物理时间,且偏差有界——l 不会超过各节点物理时钟最大值加上最大时钟偏差 ε。因此在小偏差条件下,按 HLC 排序与按物理时间排序近似等价;同毫秒内回退到逻辑计数器 c 保持因果序;l 永不回拨,消除了 NTP step 向后的问题。
HLC 的物理时间部分不需要全序——它只需要是"偏差有界的物理时钟",用 GPS 接收器或原子钟硬件(如 Google Spanner 的 TrueTime)能把 ε 压到微秒级,但普通 NTP 同步的数据中心(ε ≈ 10 ms)也够用。
CockroachDB 在每个 MVCC 行版本上存储 HLC 时间戳,用 HLC 实现跨节点的快照隔离;YugabyteDB 同样采用 HLC。相比 Google TrueTime,HLC 的优势是无需 GPS + 原子钟硬件。代价是在极端时钟偏差(ε 超过假设上界)时安全性降低——第 08 章的前沿部分会回到这个取舍。
HLC 的 c(逻辑计数器)在什么情况下归零?这说明了什么?
展开答案(先停 10 秒)
当物理时间 pt 超过了当前 l(即新的毫秒到来)时,c 归零,l 更新为 pt。这说明 HLC 优先使用物理时间推进事件序列,只有当物理时间没有前进(同一毫秒内有多个事件)时才依靠 c 来保持因果序。物理时钟的推进在 HLC 里自动"重置"了逻辑部分。
2.6三种时钟机制对比
物理时钟、Lamport 时钟、向量时钟、HLC 各有适用边界——没有一种工具全能。
| 机制 | 能检测因果序 | 能检测并发 | 物理可读 | 消息开销 | 典型使用 |
|---|---|---|---|---|---|
| 物理时钟 (wall-clock) | 否 | 否 | 是 | O(1) | 日志时间戳(不用于排序) |
| Lamport 时钟 | 单向(a→b ⟹ L(a)<L(b)) | 否 | 否 | O(1) | 全序广播、分布式快照 |
| 向量时钟 | 是(双向) | 是 | 否 | O(N) | Dynamo 版本谱系、Git |
| HLC | 是(双向) | 否(降级为 Lamport) | 近似是 | O(1) | CockroachDB, YugabyteDB |
自测
- 服务器 A 的时钟比服务器 B 快 50 ms。两台服务器同时收到写入请求,A 写入 X、B 写入 Y,都使用 wall-clock 时间戳 LWW。为什么不能用这两个时间戳判断谁的写入"最后"?
- Lamport 时钟下,观察到 C(a) < C(b),能推出 a happens-before b 吗?
- 向量时钟比 Lamport 时钟多告诉你什么信息?代价是什么?
- HLC 分别修补了物理时钟和纯逻辑时钟(Lamport)的哪个缺点?
展开答案(先独立作答再看)
1. A 的时钟快,所以 A 的时间戳更大,X 会胜出——即使 Y 在因果上更晚(例如 Y 的写入依赖了 X 的读取结果)。时间戳排序只反映时钟值,不反映因果关系;50 ms 偏差足以颠倒任何合理的因果顺序。
2. 不能。Lamport Clock Condition 只保证单向:a→b ⟹ C(a)<C(b)。逆命题不成立——C(a)<C(b) 可能是 a→b,也可能是 a ∥ b(并发)。Lamport 时钟通过 max 操作"对齐"了并发事件的计数器,丢失了并发信息。
3. 向量时钟能区分"因果在后"和"并发"——两个向量互不支配时,两事件客观并发。Lamport 无法表达这一点。代价:每条消息附带 O(N) 的数组,N = 节点数,在大型集群(如数千节点)中不可接受。
4. HLC 修补了物理时钟的"会回拨(step 向后)"缺点——l 分量单调递增;修补了纯逻辑时钟的"不可读(与物理时间无关)"缺点——l 始终接近物理时间且偏差有界,方便调试和 TTL 计算。
跨 DC 全局排序:HLC 在什么情况下失效?
两个数据中心要给跨 DC 的写定一个全局顺序,又不想部署 Google 的 GPS + 原子钟(TrueTime)。HLC 看起来是一个合理的替代——它"偏差有界",且不需要特殊硬件。
请分析:HLC 依赖的"时钟偏差有界(ε)"假设,在什么情况下被打破?假设被打破时,跨 DC 排序会发生什么错误?这个错误的严重程度与 ε 超出多少有关吗?
提示(卡住再展开)
想想:某个节点的物理时钟突然快出超过 ε(例如 NTP 服务器配置错误,时钟跳快 500 ms)。此时该节点的 l 值会怎样?它发出的消息会把其他节点的 l 拉高多少?ε 的上界假设被打破后,事务的"快照"范围会不会包含它不该看到的未来数据?