本文和 Raft 实现拆解:etcd 的共识算法到底长什么样 属于同一系列。共识算法解决”谁说了算”,时钟解决”谁先谁后”。建议搭配阅读。
你在两台服务器上各写了一条日志。服务器 A 的日志时间戳是
10:00:00.123,服务器 B 的是
10:00:00.119。
问题来了:B 真的比 A 先发生吗?
答案是:你不知道。 因为 A 和 B 的时钟可能差了几十毫秒甚至几秒。物理时钟从来就没有”精确同步”这回事。但你的分布式数据库、你的事务系统、你的因果一致性保证,全部依赖于”事件的先后顺序”。
时钟对不齐,系统怎么判断因果关系?
这个问题从 1978 年 Lamport 的论文开始,经过 Vector Clock、HLC,一直到今天 CockroachDB 和 Spanner 的工程实现,走了快 50 年。这篇文章从头推导一遍。
一、物理时钟的不可靠
在讨论逻辑时钟之前,先搞清楚物理时钟为什么不行。
NTP 的精度极限
大多数服务器用 NTP(Network Time Protocol)同步时间。NTP 在局域网内的精度大约在 0.1 - 1 毫秒,跨广域网就到了 1 - 10 毫秒,甚至更高。
听起来还行?问题是分布式数据库的事务粒度经常在微秒级。1 毫秒的误差意味着你可能把”后写”的数据覆盖到”先写”的数据上去,然后数据就不一致了。
时钟漂移、闰秒、虚拟机时钟跳变
物理时钟有三类坑:
| 问题 | 原因 | 影响 |
|---|---|---|
| 时钟漂移 | 晶振频率不完美,每天漂移 10-100 微秒 | 长时间不同步后偏差积累 |
| 闰秒 | 地球自转不均匀,UTC 偶尔插入一秒 | 时间跳变或”涂抹”(smear) |
| 虚拟机时钟跳变 | VM 暂停/恢复、热迁移 | 时钟瞬间跳几百毫秒甚至几秒 |
Google TrueTime:你买不起的解法
Google Spanner 的解法是 TrueTime
API。原理:每个数据中心部署 原子钟 + GPS
接收器,提供一个时间区间
[earliest, latest],保证真实时间在这个区间内。误差通常在
1-7 毫秒。
Spanner 的事务提交时需要等待这个不确定性窗口过去(commit-wait),确保后续事务能看到之前的写入。
问题是:原子钟很贵,GPS 接收器需要天线,运维复杂。除了 Google 和少数大厂,没几家公司用得起这套方案。
所以我们需要不依赖硬件的时钟方案。
二、Lamport 时钟:逻辑时间的起点
1978 年,Leslie Lamport 发表了 “Time, Clocks, and the Ordering of Events in a Distributed System”。核心思想极简:别管物理时间了,用逻辑计数器追踪因果关系。
算法
每个进程维护一个计数器 L,规则只有两条:
- 本地事件或发送消息时:
L = L + 1 - 接收消息时:
L = max(L, 消息中的L) + 1
就这么简单。
因果序保证
Lamport 时钟保证:如果事件 a 因果先于事件 b(记作 a → b),那么 L(a) < L(b)。
但反过来不成立:L(a) < L(b) 不能推出 a → b。 两个并发事件可能碰巧有大小关系,但它们之间没有因果联系。
用逻辑符号说:a → b ⟹ L(a) < L(b),但
L(a) < L(b) ⟹ a → b
不成立。
这意味着 Lamport 时钟能追踪因果序,但不能判断两个事件是否并发。
Go 实现
package clock
import "sync"
// LamportClock 是最简单的逻辑时钟
// 只能保证因果序,不能区分并发事件
type LamportClock struct {
mu sync.Mutex
counter uint64
}
// Tick 本地事件发生时调用
func (c *LamportClock) Tick() uint64 {
c.mu.Lock()
defer c.mu.Unlock()
c.counter++
return c.counter
}
// Send 发送消息时调用,返回要附在消息中的时间戳
func (c *LamportClock) Send() uint64 {
return c.Tick() // 发送 = 本地事件 + 附带时间戳
}
// Receive 收到消息时调用,传入消息中的时间戳
func (c *LamportClock) Receive(msgTimestamp uint64) uint64 {
c.mu.Lock()
defer c.mu.Unlock()
if msgTimestamp > c.counter {
c.counter = msgTimestamp
}
c.counter++
return c.counter
}20 行核心逻辑。简单到你可能觉得”这也算发明?“——但 1978 年能把物理时间和逻辑时间的关系想清楚,是一件很了不起的事。
三、Vector Clock:完整的因果关系
Lamport 时钟的问题:不能判断并发。如果你需要知道”这两个写操作是不是冲突的”(比如 Dynamo 风格的最终一致性系统),Lamport 时钟不够用。
算法
每个节点维护一个长度为 N(N = 节点总数)的向量,第 i 个分量表示”我知道的节点 i 的最新计数”。
规则:
- 本地事件:自己那个分量 +1
- 发送消息:自己那个分量 +1,把整个向量附在消息中
- 接收消息:逐分量取 max,然后自己那个分量 +1
判断因果关系
给定两个向量 V1 和 V2:
- V1 ≤ V2:V1 的每个分量都 ≤ V2 对应分量(V1 因果先于 V2)
- V1 和 V2 不可比:V1 某些分量更大,V2 某些分量更大(并发)
这就是 Lamport 时钟做不到的:Vector Clock 可以明确判断两个事件是并发的。
正确性(简化版)
为什么 Vector Clock 能判断因果?直觉上:
- V[i] 代表”我直接或间接知道了节点 i 的前 V[i] 个事件”
- 如果 V1 ≤ V2,说明 V2 知道了 V1 知道的一切,所以 V1 在因果上先于 V2
- 如果 V1 和 V2 互相有不知道的信息,它们就是并发的
形式化地说:a → b ⟺ V(a) < V(b)。注意这里是当且仅当,不是
Lamport 时钟的单向蕴含。
致命缺点
向量大小 = 节点数。3 个节点的集群,向量 3 维,没问题。1000 个节点的集群呢?每条消息要携带 1000 个计数器。10 万个客户端呢?
Vector Clock 在大规模系统中不可行。 Amazon Dynamo 论文用了 Vector Clock,后来实际系统也因为这个原因做了妥协(限制向量长度、定期垃圾回收)。
Go 实现
package clock
import "sync"
// VectorClock 为每个节点维护一个计数器向量
// 可以判断因果序和并发关系,但向量大小随节点数线性增长
type VectorClock struct {
mu sync.Mutex
nodeID string
vector map[string]uint64
}
func NewVectorClock(nodeID string) *VectorClock {
return &VectorClock{
nodeID: nodeID,
vector: map[string]uint64{nodeID: 0},
}
}
// Tick 本地事件:自己的分量 +1
func (vc *VectorClock) Tick() map[string]uint64 {
vc.mu.Lock()
defer vc.mu.Unlock()
vc.vector[vc.nodeID]++
return vc.copyVector()
}
// Send 发送消息:自己的分量 +1,返回整个向量(附在消息中)
func (vc *VectorClock) Send() map[string]uint64 {
return vc.Tick()
}
// Receive 接收消息:逐分量取 max,然后自己的分量 +1
func (vc *VectorClock) Receive(msgVector map[string]uint64) map[string]uint64 {
vc.mu.Lock()
defer vc.mu.Unlock()
for node, ts := range msgVector {
if ts > vc.vector[node] {
vc.vector[node] = ts
}
}
vc.vector[vc.nodeID]++
return vc.copyVector()
}
func (vc *VectorClock) copyVector() map[string]uint64 {
cp := make(map[string]uint64, len(vc.vector))
for k, v := range vc.vector {
cp[k] = v
}
return cp
}
// Compare 比较两个向量的因果关系
// 返回值:-1 表示 v1 < v2(v1 先于 v2)
// 1 表示 v1 > v2(v1 后于 v2)
// 0 表示并发(不可比)
func Compare(v1, v2 map[string]uint64) int {
less, greater := false, false
// 收集所有 key
allKeys := make(map[string]bool)
for k := range v1 { allKeys[k] = true }
for k := range v2 { allKeys[k] = true }
for k := range allKeys {
a, b := v1[k], v2[k]
if a < b { less = true }
if a > b { greater = true }
}
switch {
case less && !greater: return -1 // v1 < v2
case greater && !less: return 1 // v1 > v2
default: return 0 // 并发
}
}注意 Compare 函数返回 0 的情况——这就是
Vector Clock
的独特能力:明确告诉你两个事件是并发的。
四、HLC:混合逻辑时钟
2014 年,Kulkarni 等人提出了 Hybrid Logical Clock(HLC)。核心问题:有没有一种时钟,既能像 Lamport 时钟一样保证因果序,又保留物理时间信息,还不像 Vector Clock 那样随节点数膨胀?
答案是 HLC。
核心思想
HLC 的时间戳是一个二元组 (pt, lc):
- pt(physical time):物理时钟值,取当前已知的最大物理时间
- lc(logical counter):逻辑计数器,物理时间相同时用来区分事件顺序
关键设计:pt 永远不会倒退。 如果本地物理时钟突然倒退了(NTP 校正),HLC 不会跟着倒退,而是增加 lc。
算法
三条规则,对应三种事件:
本地事件或发送消息:
old_pt = hlc.pt
hlc.pt = max(old_pt, 物理时钟())
if hlc.pt == old_pt:
hlc.lc++ // 物理时间没变,逻辑计数器推进
else:
hlc.lc = 0 // 物理时间推进了,重置逻辑计数器
接收消息(消息携带 msg.pt, msg.lc):
old_pt = hlc.pt
hlc.pt = max(old_pt, msg.pt, 物理时钟())
if hlc.pt == old_pt && hlc.pt == msg.pt:
hlc.lc = max(hlc.lc, msg.lc) + 1
else if hlc.pt == old_pt:
hlc.lc++
else if hlc.pt == msg.pt:
hlc.lc = msg.lc + 1
else:
hlc.lc = 0
性质
- 因果序:a → b ⟹ HLC(a) < HLC(b)(先比 pt,再比 lc)
- 物理时间近似:pt 始终 ≥ 真实物理时间,且差距有界(取决于 NTP 同步质量)
- 大小恒定:不管多少节点,时间戳始终是两个整数
- 单调递增:本地生成的时间戳严格递增,不会因为物理时钟倒退而倒退
和 Lamport 时钟一样,HLC 不能判断并发。但在大多数工程场景中,你要的是因果序 + 物理时间近似,而不是完整的并发检测。
Go 实现
package clock
import (
"sync"
"time"
)
// HLC 混合逻辑时钟
// 时间戳 = (物理时间, 逻辑计数器)
// 保证因果序,保留物理时间信息,大小恒定
type HLC struct {
mu sync.Mutex
pt uint64 // 物理时间部分(毫秒级 Unix 时间戳)
lc uint32 // 逻辑计数器
now func() uint64 // 可注入的物理时钟函数(方便测试)
}
// Timestamp 是 HLC 生成的时间戳
type Timestamp struct {
PT uint64 // 物理时间
LC uint32 // 逻辑计数器
}
// Less 比较两个时间戳的因果顺序
func (t Timestamp) Less(other Timestamp) bool {
if t.PT != other.PT {
return t.PT < other.PT
}
return t.LC < other.LC
}
func NewHLC() *HLC {
return &HLC{
now: func() uint64 {
return uint64(time.Now().UnixMilli())
},
}
}
// Tick 本地事件或发送消息时调用
func (h *HLC) Tick() Timestamp {
h.mu.Lock()
defer h.mu.Unlock()
physicalNow := h.now()
oldPT := h.pt
if physicalNow > oldPT {
h.pt = physicalNow
h.lc = 0 // 物理时间推进,重置逻辑计数器
} else {
// 物理时钟没推进(或倒退了),只推进逻辑计数器
h.lc++
}
return Timestamp{PT: h.pt, LC: h.lc}
}
// Receive 接收消息时调用
func (h *HLC) Receive(msg Timestamp) Timestamp {
h.mu.Lock()
defer h.mu.Unlock()
physicalNow := h.now()
oldPT := h.pt
// 三方取 max:本地 pt、消息 pt、物理时钟
h.pt = max3(oldPT, msg.PT, physicalNow)
switch {
case h.pt == oldPT && h.pt == msg.PT:
// 三方物理时间一样:取较大的 lc + 1
if msg.LC > h.lc {
h.lc = msg.LC
}
h.lc++
case h.pt == oldPT:
// 本地 pt 最大:沿用本地 lc + 1
h.lc++
case h.pt == msg.PT:
// 消息 pt 最大:沿用消息 lc + 1
h.lc = msg.LC + 1
default:
// 物理时钟最大:重置
h.lc = 0
}
return Timestamp{PT: h.pt, LC: h.lc}
}
func max3(a, b, c uint64) uint64 {
m := a
if b > m { m = b }
if c > m { m = c }
return m
}这 50 多行是 HLC 的完整实现。核心逻辑就是
Tick 和 Receive 两个方法——和
Lamport 时钟结构一样,只是多了物理时间的参与。
HLC 的精度
HLC 的 pt 永远 ≥ 真实物理时间。差距有多大?取决于你的 NTP 同步质量。
假设 NTP 误差 ε 毫秒,那么
pt - 真实时间 ≤ ε。这意味着 HLC
时间戳虽然不完美,但误差是有界的。对于需要”大致知道事件发生在什么时间”的场景(日志排序、调试、监控),这已经够用了。
精度推导:HLC 的最大时间戳偏差
上面只给了直觉。现在严格推导一下。
前提条件:
- NTP 最大单向误差为 ε(即任意节点的物理时钟与真实时间的偏差不超过 ε)
- HLC 的逻辑计数器 lc 在物理时间推进前最多累积 L 次 tick
- 逻辑计数器的粒度为 δ(在排序时,lc 的 1 次递增等价于 δ 时间单位的偏移)
推导:
考虑节点 A 在真实时间 t_real 产生一个 HLC 时间戳
(pt_A, lc_A)。
pt_A = max(pt_A_old, physical_clock_A())physical_clock_A()与真实时间的偏差 ≤ ε,所以pt_A ≤ t_real + ε(物理时钟可能快 ε)- 但 pt_A 也可能被之前收到的消息推高——如果消息来自时钟偏快
ε 的节点,
pt_A可能比真实时间大 ε - 逻辑计数器 lc_A 在两次物理时间推进之间最多累积 L 次
因此,HLC 时间戳与真实时间的最大偏差为:
max_offset = ε + L × δ
其中: - ε = NTP 最大误差(典型值:局域网 ~1ms,广域网 ~10-150ms) - L = 两次物理时钟推进之间的最大事件数(取决于吞吐量) - δ = 逻辑计数器的时间粒度(通常亚毫秒级,可忽略)
工程含义:
以 CockroachDB
为例,max_offset = 500ms(默认配置)。这意味着:
- 一个读事务看到的数据可能”落后”真实时间最多 500ms
- 读事务遇到时间戳在
[read_ts, read_ts + 500ms]范围内的写入时必须处理不确定性 - 读过期性(read staleness)上界 = max_offset = 500ms
如果你能把 NTP 误差压到 10ms(比如用 chrony
+ 局域网 NTP 服务器),max_offset 可以设到
50ms,不确定性窗口缩小 10 倍,读重启概率大幅降低。
公式小结:
max_offset = ε + max_logical_ticks × tick_granularity
实际中 max_logical_ticks × tick_granularity ≪ ε,所以:
max_offset ≈ ε(NTP 误差主导)
HLC 与 TSO 的混合迁移策略
生产中有一个棘手的场景:你的系统当前用 TSO(比如 TiDB),想迁移到 HLC(比如 CockroachDB),或者反过来。两种时钟方案的语义不同,不能直接切换。
为什么不能直接切换?
- TSO 的时间戳是严格全序的——任意两个时间戳都能比大小,且这个大小关系反映真实因果
- HLC 的时间戳是因果序但非全序——两个并发事件的时间戳大小关系没有语义
- 如果直接从 TSO 切到 HLC,那些依赖”时间戳大 = 事件更新”语义的查询可能返回错误结果
双写 + 时间戳调和方案:
阶段 1:双写(Shadow Mode)
- 所有写入同时走旧系统(TSO)和新系统(HLC)
- 读取仍走旧系统
- 比对两侧的写入结果,验证一致性
时间戳调和:
旧系统写入时间戳 = tso_ts
新系统写入时间戳 = hlc_ts
记录映射表:(tso_ts, hlc_ts, key) 用于回溯
阶段 2:影子读验证
- 读请求同时发到两侧,比对结果
- 重点关注:时间戳序不同是否导致可见性差异
- 如果差异率 < 阈值(如 0.01%),进入下一阶段
阶段 3:切流
- 读流量逐步切到新系统(灰度 1% → 10% → 50% → 100%)
- 旧系统保持双写,作为回滚安全网
阶段 4:退役旧系统
- 停止旧系统写入
- 保留映射表一段时间(用于事后审计)
回滚安全:
关键在于阶段 3 的回滚能力。如果新系统出问题,需要能快速切回旧系统。双写期间旧系统数据是完整的,所以回滚只需要切流量。但如果已经停止双写(阶段 4),回滚就需要从新系统反向同步——这时候时间戳调和映射表就是救命稻草。
什么时候 HLC 赢,什么时候 TSO 赢?
| 场景 | HLC 更优 | TSO 更优 |
|---|---|---|
| 跨区域多数据中心 | ✅ 无中心节点,本地生成时间戳 | ❌ 跨区域请求 TSO 延迟高 |
| 单区域高吞吐 | ❌ 不确定性窗口增加读重启 | ✅ 时间戳全序,无不确定性 |
| 容忍中心节点 | N/A | ✅ 实现简单,推理容易 |
| 严格外部一致性 | ❌ 只保证因果一致 | ✅ 全序 ≈ 外部一致性(单点内) |
| 运维复杂度 | ✅ 无单点,NTP 即可 | ❌ TSO 节点需要高可用 |
五、CockroachDB 的时钟实现
理论推导到这里差不多了。看看工程上怎么用。
CockroachDB 是一个分布式 SQL 数据库,用 HLC 作为时间戳方案。为什么选 HLC?因为 CockroachDB 要跑在普通硬件上——没有原子钟,没有 GPS,只有 NTP。
HLC 在 CockroachDB 的角色
CockroachDB 的每个事务都有一个 HLC 时间戳。读写操作通过时间戳来确定可见性:
- 写入时获取 HLC 时间戳
- 读取时用事务的时间戳决定能看到哪些版本
- 所有节点的 HLC 通过 RPC 消息自动同步(消息携带 HLC 时间戳)
max_clock_offset:不确定性窗口
CockroachDB
有一个关键参数:--max-offset,默认
500ms。
这个参数的含义是:任意两个节点的物理时钟之差不超过 500ms。 如果某个节点的时钟偏差超过了这个值,CockroachDB 会让它自杀(主动退出集群)。
为什么需要这个参数?因为 HLC 保证因果序,但不保证实时序。考虑这个场景:
- 客户端在节点 A 写入 key=x,时间戳 t1
- 客户端(不经过 A)直接去节点 B 读 key=x,时间戳 t2
- 如果 B 的物理时钟比 A 慢,t2 可能 < t1,导致读不到刚写的值
CockroachDB 的解法:读操作遇到时间戳在
[read_ts, read_ts + max_offset]
范围内的写入时,会等待不确定性窗口过去,或者重启事务到更高的时间戳。
// CockroachDB 简化版不确定性检查逻辑
func (r *Reader) checkUncertainty(writeTS, readTS hlc.Timestamp) error {
// 写入时间戳在读事务的不确定性窗口内
if writeTS.Less(readTS.Add(maxOffset)) && readTS.Less(writeTS) {
// 不确定这个写入是在我的读之前还是之后
// 需要将读事务推到 writeTS 之后重试
return &UncertaintyError{
ExistingTimestamp: writeTS,
MaxTimestamp: readTS.Add(maxOffset),
}
}
return nil
}为什么 CockroachDB 不需要原子钟但 Spanner 需要
| 对比维度 | Google Spanner | CockroachDB |
|---|---|---|
| 时钟方案 | TrueTime(原子钟 + GPS) | HLC + NTP |
| 不确定性窗口 | ~1-7ms | ~500ms(可调) |
| 提交策略 | commit-wait(等窗口过去) | 读时检查不确定性 |
| 对外部一致性的保证 | 强:linearizability | 弱一些:serializable + 读等待 |
| 硬件要求 | 原子钟、GPS 天线 | 普通服务器 + NTP |
Spanner 的不确定性窗口只有几毫秒,commit-wait 的延迟可以忍受。CockroachDB 的窗口有 500ms,如果也用 commit-wait,每次写入至少等 500ms,完全不可接受。
所以 CockroachDB 把复杂度从写路径转移到了读路径:写入不等待,读取遇到不确定性时再处理。这个 trade-off 在工程上是合理的,因为大多数读操作不会碰到不确定性窗口内的写入。
与 TiDB 的 TSO 对比
TiDB 用了完全不同的方案:TSO(Timestamp Oracle)。一个中心化的时间戳分配服务(PD,Placement Driver),所有事务的时间戳都从这里获取。
| 对比维度 | CockroachDB (HLC) | TiDB (TSO) |
|---|---|---|
| 时间戳来源 | 去中心化(每个节点自己生成) | 中心化(PD 分配) |
| 单点故障 | 无 | PD 挂了就没时间戳(需要高可用) |
| 跨区域延迟 | 低(本地生成) | 高(远程请求 PD) |
| 一致性保证 | 因果一致 + 不确定性窗口 | 严格全序 |
| 实现复杂度 | 高(不确定性处理复杂) | 低(中心化天然有序) |
两种方案各有取舍。TiDB 的 TSO 简单粗暴但有效:中心化分配保证全局唯一递增,代价是每次事务开始和提交都要一次网络往返。CockroachDB 去中心化的设计在跨区域部署时延迟更低,但代码复杂度明显更高。
如果你想了解 CockroachDB 底层用的共识协议,可以看 Raft 实现拆解:etcd 的共识算法到底长什么样。CockroachDB 的每个 Range 都跑了一个 Raft 组。
六、时钟方案选型
最后做一个总览对比。
对比表
| 方案 | 因果保证 | 能否判断并发 | 时间戳大小 | 物理时间信息 | 硬件要求 | 适用场景 |
|---|---|---|---|---|---|---|
| Lamport Clock | a→b ⟹ L(a)<L(b) | ❌ 不能 | 1 个整数 | ❌ 无 | 无 | 全序排序、简单因果追踪 |
| Vector Clock | a→b ⟺ V(a)<V(b) | ✅ 能 | N 个整数(N=节点数) | ❌ 无 | 无 | 冲突检测、最终一致性系统 |
| HLC | a→b ⟹ HLC(a)<HLC(b) | ❌ 不能 | 2 个整数 | ✅ 近似 | NTP | 分布式数据库、事务系统 |
| TrueTime | 实时序 | N/A | 时间区间 | ✅ 精确(有界误差) | 原子钟+GPS | Google Spanner |
| TSO | 严格全序 | N/A(全序) | 1 个整数 | ✅ 近似 | 中心节点 | TiDB、中心化系统 |
怎么选
你需要判断并发吗?
- 是 → Vector Clock(小规模)或 Dotted Version Vector(改进版)
- 否 → 继续往下
你有原子钟吗?
- 是 → TrueTime(恭喜你是 Google)
- 否 → 继续往下
你能接受中心化时间戳服务吗?
- 是 → TSO(简单可靠,跨区域部署例外)
- 否 → HLC
大多数场景的答案是 HLC 或 TSO。Lamport 时钟太弱,Vector Clock 太重,TrueTime 太贵。HLC 在”够用”和”不贵”之间找到了甜蜜点。
延伸阅读:
- Lamport, “Time, Clocks, and the Ordering of Events in a Distributed System”, 1978
- Kulkarni et al., “Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases”, 2014
- Corbett et al., “Spanner: Google’s Globally-Distributed Database”, 2012
- Raft 实现拆解:etcd 的共识算法到底长什么样