分布式系统里的时间问题
在单机上写代码,时间是理所当然的。System.currentTimeMillis() 返回一个数,sleep(1000) 等一秒,事件有先后顺序,日志按时间戳排列,因果清晰。
一旦把系统拆成多台机器,这个”理所当然”就碎掉了。
物理时钟会漂移
每台机器都有硬件时钟,靠晶振产生频率。晶振不是原子钟,受温度、电磁干扰、老化影响,每台机器的时钟走得快慢不完全一致。典型的服务器时钟漂移在每天几十毫秒到几百毫秒之间。
NTP(网络时间协议)是标准的修正手段。服务器定期向 NTP 上游同步,把本地时钟拨快或拨慢。但 NTP 本身受网络延迟影响,同步精度通常在几毫秒到几十毫秒级别——在数据中心内部可以优化到亚毫秒,但跨机房、跨地域时就没那么稳了。
这带来一个工程上很具体的问题:你不能相信两台机器的时间戳能精确比较大小。
机器 A 在 T=100ms 写入了一条记录,机器 B 在 T=99ms 写入了另一条,但 B 的时钟比 A 快了 5ms,这意味着 A 的操作实际上更早,但时间戳却更大。如果你用时间戳来决定冲突解决的结果,你会得到错误的答案。
“先后”是什么意思
1978 年,Leslie Lamport 在一篇论文里提出了这个问题的本质:在没有共享时钟的分布式系统里,”事件 A 发生在事件 B 之前”这个命题,严格来说需要重新定义。
Lamport 给出的定义是 happened-before 关系(→):
- 如果 A 和 B 在同一个进程里,A 发生在 B 之前,则 A → B
- 如果 A 是一条消息的发送,B 是该消息的接收,则 A → B
- 如果 A → B 且 B → C,则 A → C(传递性)
这个定义不依赖物理时钟。它描述的是因果关系:A 能影响 B 吗?如果能,A → B。
两个事件如果既不满足 A → B 也不满足 B → A,就是并发的(concurrent)——它们之间没有因果联系,谁先谁后在逻辑上没有意义。
这个框架把”时间”从物理概念转变成了因果概念,是分布式系统理论的基石。
逻辑时钟
基于 happened-before 关系,Lamport 设计了逻辑时钟:
每个进程维护一个计数器 C。规则是:
- 每次本地事件发生,C 自增
- 发送消息时,把当前 C 附在消息里
- 收到消息时,C = max(本地C, 消息C) + 1
这样就保证了:如果 A → B,那么 C(A) < C(B)。
但注意,反向不成立。C(A) < C(B) 不意味着 A → B,只是不排除 A → B。
Lamport 时钟可以检测”这两个事件之间可能有因果关系”,但无法检测”这两个事件一定是并发的”。如果你需要后者,需要向量时钟(Vector Clock)。
向量时钟为 N 个进程各维护一个计数器,形成一个长度为 N 的向量 V。每个进程 i 只增自己的 V[i];发送时附带整个向量;接收时逐位取 max 再自增。
比较两个向量时钟 V1 和 V2:
- 如果 V1 每一位都 ≤ V2,且至少一位 <,则 V1 < V2,即事件 1 happened-before 事件 2
- 如果两个向量既不满足 V1 < V2 也不满足 V2 < V1,则两事件并发
代价是显而易见的:向量随节点数线性增长,在大规模系统里开销不可忽视。这是向量时钟在工程实践里使用受限的主要原因。
物理时钟的工程极限:GPS + 原子钟
Lamport 时钟解决了”定义先后”的理论问题,但在某些场景里你还是需要物理时钟——比如对外的时间戳、跨系统的协调、或者”某条记录是否在合同签署之前写入的”这类审计需求。
逻辑时钟无法回答这类问题,因为它的数值没有物理时间含义。
Google 在 2012 年发表的 Spanner 论文里给出了一个工程解:TrueTime API。
Spanner 在每个数据中心部署了两种时间源:GPS 接收器和原子钟。两者相互校验,GPS 提供绝对时间参考,原子钟提供稳定的短期频率。Spanner 的 TrueTime API 返回的不是一个时间点,而是一个区间 [earliest, latest],表示”真实时间以 99.9% 的概率落在这个区间内”,区间宽度通常在 7ms 以内。
这个设计的关键在于 不确定性的显式化。与其假装时间是精确的,不如把误差范围直接暴露给上层。
Spanner 的事务提交协议利用了这个区间:提交一个事务时,系统会等待足够长的时间(让 now > latest),确保任何将来的事务都能看到一个更大的时间戳。这个”commit wait”的代价是延迟增加了大约 7-14ms,换来的是全球范围内严格的外部一致性——任何两个事务,只要在现实时间里有先后,Spanner 就能正确地反映这个顺序。
这是工程上少见的”花钱买物理定律合规”案例。精确时钟很贵,但它让一类本来需要复杂分布式协议才能解决的问题,变成了等一等的问题。
工程现实:大多数系统用的是折中方案
绝大多数分布式数据库没有 Spanner 那样的硬件时间基础设施,它们用的是各种折中。
CockroachDB 使用混合逻辑时钟(HLC,Hybrid Logical Clocks)。HLC 在 NTP 同步的物理时钟基础上叠加逻辑计数器,既保留了物理时间的可读性(你拿到的时间戳可以和现实时间对应),又用逻辑部分消除了时钟偏斜带来的歧义。代价是需要在节点间同步最大时钟值,偶尔会触发”uncertainty restart”——事务感知到时间不确定时重试。
Cassandra 和许多 eventual consistency 系统的做法更直接:用 Last Write Wins(LWW),时间戳大的写入覆盖时间戳小的。这在时钟偏斜下会丢数据。Cassandra 默认不承诺单调写入,这是它设计上的取舍,不是 bug。
etcd/Raft 这类系统绕开了时钟问题:通过选举 leader,所有写入经过 leader 排序,因此有全局的逻辑顺序,不需要比较时间戳。代价是写入必须走 leader,有吞吐和延迟的天花板。
对工程师的影响
这些理论在工作中的实际影响,比大多数人意识到的要多。
日志排序不可靠。 两台机器的日志,不能直接用时间戳排序来重建事件序列。如果你在排查跨服务的问题,需要意识到日志里的时间戳可能有几十毫秒的偏差。分布式 tracing 系统(Jaeger、Zipkin)之所以用 trace ID 而不是时间戳来串联请求,就是因为这个原因。
不要用时间戳作为冲突解决的唯一依据。 除非你有 TrueTime 级别的时钟精度,否则”时间戳更大的版本获胜”在并发写入场景下会丢数据,丢了还不知道。
分布式锁里的 fencing token。 用 ZooKeeper 或 Redis 做分布式锁时,标准的安全实践是使用 fencing token(单调递增的版本号),而不是依赖锁的过期时间来判断操作是否仍然有效。原因是客户端可能因为 GC pause 或时钟跳跃,在锁已经过期后仍然认为自己持有锁。
数据库的 read-your-own-writes。 写入一个数据库副本后立即读取,可能读到的是旧值——如果读请求路由到了另一个尚未同步的副本。这不是 bug,是 eventual consistency 的定义,但不了解这个的工程师会写出有 race condition 的代码。
结尾
时间在分布式系统里不是背景,是一等公民。它有误差,有方向性,有因果语义。
Lamport 在 1978 年写那篇论文时,互联网还在早期,分布式系统主要是学术课题。四十多年后,这套框架成了每一个做分布式数据库、消息队列、分布式存储的工程师都需要理解的基础知识。
不同的是,Lamport 讨论的是理论模型,今天的工程师还需要在这个模型上叠加 NTP 漂移、网络分区、GC pause、机器宕机——每一个都是对”时间”这个概念的新一层挑战。
这个问题没有完美的解,只有根据系统一致性需求做出的取舍。理解取舍,才能设计出真正可靠的系统。