混合逻辑时钟与 TrueTime:在物理和逻辑之间找到平衡
你在 CockroachDB 集群上跑了一个事务。事务在节点 A
写了一行数据,时间戳
t1 = (1000, 0)。紧接着,客户端(不经过
A)去节点 B 读同一行。B 的物理时钟比 A 慢了
200ms,给这次读分配了时间戳 t2 = (800, 0)。
t2 < t1。读看不到刚才的写。
这不是理论问题。任何用 NTP 同步时钟的分布式数据库都会碰到。差别在于怎么处理它。
Google Spanner 的答案是砸钱:每个数据中心装原子钟和 GPS 接收器,把时钟误差压到 1-7 毫秒,然后每次写入等不确定性窗口过去。CockroachDB 的答案是算法:用混合逻辑时钟(Hybrid Logical Clock,HLC)保证因果序,在读路径上处理不确定性。两种方案背后是完全不同的工程哲学。
这篇文章从 HLC 算法逐步推导开始,经过数学正确性证明、CockroachDB 源码分析、TrueTime 工程架构,到 AWS Clockbound 开源方案,把”在物理和逻辑之间找平衡”这件事讲清楚。
前置知识:建议先阅读 逻辑时钟,了解 Lamport 时钟和 Vector Clock 的基本概念。
一、为什么需要混合时钟
先回顾一下前一篇文章的结论。我们有三种时钟方案,各有致命缺陷:
| 方案 | 能做什么 | 做不到什么 |
|---|---|---|
| 物理时钟(NTP) | 提供人类可读的时间 | 跨节点精确同步(误差 0.1-150ms) |
| Lamport 时钟 | 保证因果序
a → b ⟹ L(a) < L(b) |
无物理时间信息;无法判断并发 |
| Vector Clock | 因果序 + 并发检测 | 向量大小 O(N),N 个节点就要 N 个计数器 |
分布式数据库的需求很具体:
- 因果序:如果事务 A 的提交在事务 B 开始之前,A 的时间戳必须小于 B 的时间戳。
- 物理时间近似:时间戳要和挂钟时间大致对应,这样日志排序、TTL 过期、调试才有意义。
- 恒定大小:不能随节点数膨胀。1000 节点的集群不可能每条消息带 1000 个计数器。
Lamport 时钟满足 1 和 3,但不满足 2。Vector Clock 满足 1 和部分 2(不含物理信息),但不满足 3。物理时钟满足 2 和 3,但不满足 1。
HLC 同时满足这三个需求。 代价是放弃并发检测能力——但对数据库事务来说,因果序加物理时间近似已经够用了。
二、HLC 算法:逐步推导
Kulkarni 等人在 2014 年的论文 “Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases” 中提出了 HLC。核心思想:在 Lamport 时钟的递增规则里嵌入物理时间。
时间戳结构
HLC 时间戳是一个二元组 (l, c):
l(logical physical time):物理时间的”高水位”。取本地物理时钟、本地已知的最大l、收到消息中的l三者的最大值。关键性质:l永远不会倒退。c(counter):逻辑计数器。当l推进时重置为 0;当l不变时递增。作用是在物理时间粒度不够时区分事件。
时间戳的全序比较:先比 l,l
相同再比 c。
三类事件的处理规则
用伪代码精确描述。设节点 j 的 HLC 状态为
(l.j, c.j),节点 j 的物理时钟函数为
pt.j()。
规则 1:本地事件或发送消息
l'.j = l.j // 保存旧值
l.j = max(l'.j, pt.j()) // 物理时间和旧值取 max
if l.j == l'.j:
c.j = c.j + 1 // 物理时间没推进,计数器递增
else:
c.j = 0 // 物理时间推进了,计数器重置
// 时间戳 = (l.j, c.j)
规则 2:接收消息(消息携带
(l.m, c.m))
l'.j = l.j // 保存旧值
l.j = max(l'.j, l.m, pt.j()) // 三方取 max
if l.j == l'.j && l.j == l.m:
c.j = max(c.j, c.m) + 1 // 三方 l 相同,取较大计数器 + 1
else if l.j == l'.j:
c.j = c.j + 1 // 本地 l 最大
else if l.j == l.m:
c.j = c.m + 1 // 消息 l 最大
else:
c.j = 0 // 物理时钟最大,重置
// 时间戳 = (l.j, c.j)
具体例子
三个节点 A、B、C,物理时钟分别为
pt.A=10、pt.B=8、pt.C=12,初始
HLC 状态 (0, 0)。
步骤 1:A 发生本地事件。
l'.A = 0
l.A = max(0, 10) = 10 // 物理时钟推进 l
c.A = 0 // l 变了,重置
=> A 的时间戳:(10, 0)
步骤 2:A 向 B 发送消息,携带
(10, 0)。B 收到。
B 收到消息 (l.m=10, c.m=0)
l'.B = 0
l.B = max(0, 10, 8) = 10 // 消息的 l 最大
c.B = c.m + 1 = 0 + 1 = 1 // l.B == l.m,用消息的 c + 1
=> B 的时间戳:(10, 1)
注意 B 的物理时钟只有 8,但 HLC 给出了
(10, 1)。这保证了因果序:A 的发送
(10, 0) < B 的接收
(10, 1)。
步骤 3:C 发生本地事件。
l'.C = 0
l.C = max(0, 12) = 12 // C 的物理时钟最大
c.C = 0
=> C 的时间戳:(12, 0)
步骤 4:B 向 C 发送消息,携带
(10, 1)。C 收到。
C 收到消息 (l.m=10, c.m=1)
l'.C = 12
l.C = max(12, 10, 12) = 12 // 本地 l 和物理时钟并列最大
c.C = c.C + 1 = 0 + 1 = 1 // l.C == l'.C,本地 l 最大
=> C 的时间戳:(12, 1)
步骤 5:B 发生本地事件。此时 B 的物理时钟推进到
pt.B=11。
l'.B = 10
l.B = max(10, 11) = 11 // 物理时钟推进了
c.B = 0 // l 变了,重置
=> B 的时间戳:(11, 0)
看这个例子的两个关键点:
- 因果序保持:A 发送
(10, 0)→ B 接收(10, 1),时间戳严格递增。 - 物理时间近似:每个时间戳的
l都不会偏离物理时钟太远。B 的物理时钟只有 8,但收到 A 的消息后l被推到 10——这正好反映了”B 通过 A 的消息间接知道了物理时间至少是 10”。
Go 完整实现
package hlc
import (
"sync"
"time"
)
// Timestamp 是 HLC 时间戳,由物理时间部分和逻辑计数器组成。
// 全序比较:先比 WallTime,再比 Logical。
type Timestamp struct {
WallTime int64 // 纳秒级 Unix 时间戳(物理时间高水位)
Logical uint32 // 逻辑计数器
}
// Less 返回 t 是否因果先于 other。
func (t Timestamp) Less(other Timestamp) bool {
if t.WallTime != other.WallTime {
return t.WallTime < other.WallTime
}
return t.Logical < other.Logical
}
// Equal 判断两个时间戳是否相等。
func (t Timestamp) Equal(other Timestamp) bool {
return t.WallTime == other.WallTime && t.Logical == other.Logical
}
// Clock 是一个混合逻辑时钟实例。
// 线程安全。每个节点维护一个 Clock 实例。
type Clock struct {
mu sync.Mutex
maxOffset time.Duration // 最大允许的时钟偏移
state Timestamp // 当前 HLC 状态
physNow func() int64 // 物理时钟函数,可注入用于测试
}
// NewClock 创建一个新的 HLC 实例。
// maxOffset 是允许的最大时钟偏移,超过此值应触发告警或拒绝操作。
func NewClock(maxOffset time.Duration) *Clock {
return &Clock{
maxOffset: maxOffset,
physNow: func() int64 {
return time.Now().UnixNano()
},
}
}
// Now 处理本地事件或发送消息,返回新的 HLC 时间戳。
// 对应 Kulkarni et al. 2014 的规则 1。
func (c *Clock) Now() Timestamp {
c.mu.Lock()
defer c.mu.Unlock()
physicalNow := c.physNow()
oldL := c.state.WallTime
if physicalNow > oldL {
c.state.WallTime = physicalNow
c.state.Logical = 0
} else {
// 物理时钟没有推进(可能被 NTP 回拨,或在同一纳秒内多次调用)
// 保持 l 不变,递增计数器
c.state.Logical++
}
return c.state
}
// Update 处理接收到的消息时间戳,更新本地 HLC 状态。
// 对应 Kulkarni et al. 2014 的规则 2。
func (c *Clock) Update(msg Timestamp) Timestamp {
c.mu.Lock()
defer c.mu.Unlock()
physicalNow := c.physNow()
oldL := c.state.WallTime
// 三方取 max
newL := oldL
if msg.WallTime > newL {
newL = msg.WallTime
}
if physicalNow > newL {
newL = physicalNow
}
switch {
case newL == oldL && newL == msg.WallTime:
// 三方 l 相同:取较大的计数器 + 1
if msg.Logical > c.state.Logical {
c.state.Logical = msg.Logical
}
c.state.Logical++
case newL == oldL:
// 本地 l 最大
c.state.Logical++
case newL == msg.WallTime:
// 消息 l 最大
c.state.Logical = msg.Logical + 1
default:
// 物理时钟最大
c.state.Logical = 0
}
c.state.WallTime = newL
return c.state
}
// MaxOffset 返回配置的最大时钟偏移。
func (c *Clock) MaxOffset() time.Duration {
return c.maxOffset
}这段代码可以直接用在生产系统中。Now() 和
Update() 分别对应 HLC
论文的两条规则。physNow 可注入,方便测试。
三、HLC 正确性证明
先建立直觉
在进入形式化证明之前,先用一句话概括 HLC
为什么是对的:l 只会被 max 操作推高,而
max 的输入要么是物理时钟(有界偏差),要么是别人传来的
l(也满足同样的约束),所以 l
不会偏离真实时间太远;而 c 在 l
每次推进时都会重置为 0,所以 c
不会无限增长。
具体来说,可以从两个方向理解 HLC 的正确性:
因果序为什么能保持? 考虑一个消息从 A 发给 B。A 发送时的 HLC 是
(l_A, c_A)。B 接收时执行l_B = max(l_B, l_A, pt_B)——所以l_B >= l_A。如果l_B > l_A,那(l_B, *) > (l_A, *)无论c取什么值。如果l_B == l_A,B 的c取max(c_B, c_A) + 1 > c_A。无论哪种情况,接收方的时间戳严格大于发送方。因果链上的每一步时间戳都严格递增,传递性自然成立。物理时间为什么不会漂太远?
l只通过max被推高,而max的每个输入要么是某个节点的物理时钟读数(与真实时间的偏差不超过 NTP 误差 epsilon),要么是某条消息携带的l(消息发送时也满足同样的上界)。所以l的值永远不会超过”真实时间 + epsilon”。
有了这个直觉,下面的形式化证明只是把上述论证精确化。
性质一:因果序保持(Causal Consistency)
定理:对于任意两个事件 e 和 f,如果
e → f(e happens-before f),那么
(l.e, c.e) < (l.f, c.f)。
证明思路:
对 happens-before 关系的三种基本情况分别证明。
情况 1:e 和 f 是同一节点 j 上的连续事件。
根据规则 1,每次事件要么推进 l(此时
c 重置为 0),要么 l 不变但
c 递增。两种情况下 (l, c)
都严格递增。
情况 2:e 是节点 j 的发送事件,f 是节点 k 的对应接收事件。
发送时 j 的 HLC 状态为
(l.e, c.e),消息携带这个时间戳。
接收时 k 执行规则 2:
l.f = max(l.k, l.e, pt.k())
- 如果
l.f > l.e:(l.f, c.f) > (l.e, c.e)因为l.f > l.e。 - 如果
l.f == l.e:- 如果
l.f == l.k(三方相同):c.f = max(c.k, c.e) + 1 > c.e。 - 如果
l.f == l.e且l.f > l.k:c.f = c.e + 1 > c.e。
- 如果
所有情况下 (l.f, c.f) > (l.e, c.e)。
情况 3:传递性。 如果 e → g
且 g → f,由情况 1 和 2 知
(l.e, c.e) < (l.g, c.g) 且
(l.g, c.g) < (l.f, c.f),传递性成立。
性质二:l 与物理时间的有界偏差
定理:设 NTP 的最大单向误差为
ε,则对于任意事件 e,l.e ≤ pt(e) + ε,其中
pt(e) 是事件 e 发生时的真实物理时间。
证明思路:
每个节点的物理时钟 pt.j()
与真实时间的偏差不超过 ε。l 通过
max 操作传播——它只会被另一个节点的
l 或自己的物理时钟推高。
通过归纳法:
- 基础情况:初始时
l.j = 0 ≤ pt.j() ≤ pt_real + ε。 - 归纳步骤:假设到事件 e 之前,所有节点的
l都满足l ≤ pt_real + ε。事件 e 执行l.j = max(l'.j, l.m, pt.j())。由归纳假设,l'.j ≤ pt_real + ε且l.m ≤ pt_real + ε(消息发送时满足上界,到达时真实时间只会更大),pt.j() ≤ pt_real + ε。所以l.j ≤ pt_real + ε。
这意味着 HLC 的物理时间部分永远不会偏离真实时间超过 ε。ε 就是 NTP 的最大误差。
性质三:c 的有界性
定理:在任何两次物理时间推进之间,c
最多递增到系统内的事件总数。
实际工程中,物理时钟的粒度远粗于事件频率的倒数——毫秒级时钟下,每毫秒能发生的事件有限,所以
c 不会太大。CockroachDB 使用纳秒级
WallTime,c 大部分时候都是 0。
与 Lamport 时钟的比较
| 性质 | Lamport Clock | HLC |
|---|---|---|
| 因果序保持 | e → f ⟹ L(e) < L(f) |
e → f ⟹ (l.e, c.e) < (l.f, c.f) |
| 反向推断 | L(e) < L(f)
不能推出 e → f |
同样不能 |
| 并发检测 | 不能 | 不能 |
| 物理时间信息 | 无 | l
与物理时间偏差有界 |
| 时间戳大小 | 1 个整数 | 2 个整数 |
HLC 本质上是一个”带物理时间约束的 Lamport 时钟”。它用
l 锚定物理时间,用 c
处理物理时钟粒度不够的情况。
四、CockroachDB 的 HLC 实现源码分析
CockroachDB 是 HLC 在工业级数据库中最完整的实践。下面基于 CockroachDB 源码分析其时钟实现的关键设计。
时间戳编码
CockroachDB 的 hlc.Timestamp 定义在
pkg/util/hlc/timestamp.go 中:
// 以下代码经删减,仅保留关键字段。
// 源码:cockroachdb/cockroach, pkg/util/hlc/timestamp.go
type Timestamp struct {
WallTime int64 // 纳秒级 Unix 时间戳
Logical int32 // 逻辑计数器
// Synthetic 标记此时间戳是否为合成的(不直接来自 HLC)
Synthetic bool
}WallTime 使用纳秒精度,这让
Logical 在大多数情况下都是
0——同一纳秒内发生多个事件的概率很低。但当物理时钟被远端消息推高、或者
NTP 回拨时,Logical 就会开始工作。
时钟偏移追踪
CockroachDB 不只是被动地使用
HLC。它主动监控集群内所有节点的时钟偏移。核心组件是
RemoteClockMonitor,位于
pkg/rpc/clock_offset.go:
// 以下代码经删减,保留核心结构。
// 源码:cockroachdb/cockroach, pkg/rpc/clock_offset.go
type RemoteClockMonitor struct {
mu struct {
syncutil.Mutex
offsets map[roachpb.NodeID]RemoteOffset
}
maxOffset time.Duration
toleratedOffset time.Duration
}
type RemoteOffset struct {
Offset int64 // 估计的远端时钟偏移(纳秒)
Uncertainty int64 // 偏移测量的不确定性(纳秒)
MeasuredAt int64 // 测量时间
}每次节点间 RPC 通信时,双方交换 HLC
时间戳并计算偏移量。RemoteClockMonitor
维护一个滑动窗口,持续追踪集群内的时钟偏移分布。
max_offset 强制执行
当检测到某个节点的时钟偏移超过
max_offset(默认 500ms),CockroachDB
的处理很极端——直接让节点崩溃退出:
// 以下为简化后的逻辑。
// 源码:cockroachdb/cockroach, pkg/rpc/clock_offset.go
func (r *RemoteClockMonitor) VerifyClockOffset(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
var dominated int
for _, offset := range r.mu.offsets {
if offset.Offset+offset.Uncertainty > r.maxOffset.Nanoseconds() ||
offset.Offset-offset.Uncertainty < -r.maxOffset.Nanoseconds() {
// 这个节点和远端的时钟偏差超过了允许范围
dominated++
}
}
// 如果和大多数节点的偏差都超限,说明是自己的问题
if dominated > len(r.mu.offsets)/2 {
return errors.Errorf("clock offset exceeded: %v", r.maxOffset)
}
return nil
}注意判断逻辑:不是”和任何一个节点偏差过大就退出”,而是”和多数节点偏差过大才退出”。这避免了单个节点时钟异常导致误杀健康节点。
不确定性窗口与读路径
HLC 保证因果序,但不保证实时序(linearizability)。两个没有因果关系的事件,时间戳的大小关系不反映真实发生顺序。这在读路径上会造成问题。
考虑这个场景:
- 客户端在节点 A 执行
WRITE x = 1,获得时间戳t_w = 100。 - 客户端立刻在节点 B 执行
READ x,B 的物理时钟偏慢,分配了时间戳t_r = 90。 - 因为
t_r < t_w,按 MVCC 规则,这次读看不到x = 1。
这就是过期读(stale read)。CockroachDB 用不确定性窗口(uncertainty window)来处理。
每个读事务有一个不确定性区间
[read_ts, read_ts + max_offset]。扫描 MVCC
版本链时,如果发现一个写入的时间戳落在这个区间内,CockroachDB
不能确定这个写入是在读之前还是之后——于是触发不确定性重启(uncertainty
restart):
// 以下为简化后的逻辑。
// 源码:cockroachdb/cockroach, pkg/kv/kvserver/uncertainty/
func checkUncertainty(readTS, writeTS hlc.Timestamp, maxOffset time.Duration) error {
globalUncertaintyLimit := readTS.Add(maxOffset.Nanoseconds(), 0)
if readTS.Less(writeTS) && writeTS.LessEq(globalUncertaintyLimit) {
// 写入时间戳在不确定性窗口内
// 这个写可能发生在我的读之前(只是时钟偏差让我看不到)
return &kvpb.ReadWithinUncertaintyIntervalError{
ReadTimestamp: readTS,
ExistingTimestamp: writeTS,
GlobalUncertaintyLimit: globalUncertaintyLimit,
}
}
return nil
}收到 ReadWithinUncertaintyIntervalError
后,事务协调器将读事务的时间戳推高到冲突写入时间戳之后,然后重试。重试后的不确定性窗口缩小了——上界不变,但下界升高了——所以不会无限循环。
Observed Timestamps 优化
上面的方案有个性能问题:max_offset = 500ms
意味着每次读都要检查 500ms
窗口内的所有写入。对高写入吞吐的场景,uncertainty restart
的概率不低。
CockroachDB 的优化是 Observed Timestamps。当事务第一次接触一个节点时,记录该节点当前的 HLC 时间戳。后续在该节点上的读操作,不确定性窗口的上界缩小到这个 observed timestamp——因为我们已经知道了这个节点的时钟状态。
// 以下为简化后的逻辑。
// 事务在节点 N 的不确定性上界 = min(global_uncertainty, observed_ts[N])
func localUncertaintyLimit(
globalLimit hlc.Timestamp,
observedTS hlc.Timestamp,
) hlc.Timestamp {
if observedTS.Less(globalLimit) {
return observedTS // 缩小窗口
}
return globalLimit
}如果事务的所有读写都在同一个节点上,observed timestamp 就等于事务开始时的 HLC 时间戳,不确定性窗口缩小到 0——这时候等价于单机数据库,完全没有 uncertainty restart。
这是一个精妙的优化:跨节点事务才付出不确定性检查的代价,单节点事务几乎零开销。
五、TrueTime:用硬件解决时钟问题
Google 选了一条完全不同的路。与其在软件层面处理时钟不确定性,不如把不确定性压到足够小,小到可以直接等。
硬件架构
TrueTime 的核心是 GPS + 原子钟双冗余。每个 Google 数据中心部署多台时间主服务器(time master),分两类:
- GPS master:配备 GPS 接收器和天线,从 GPS 卫星获取 UTC 时间。精度约 1 微秒。弱点:GPS 天线故障、信号干扰、闰秒处理。
- Atomic clock master:配备铷原子钟或铯原子钟。不依赖外部信号,自主走时。短期稳定性极高(日漂移 < 1 微秒),但需要定期校准。弱点:长期漂移。
两种 master 的故障模式互补:GPS master 的故障来自外部(信号),原子钟 master 的故障来自内部(漂移)。同时失效的概率很低。
TrueTime API
TrueTime 对外暴露的 API 只有三个函数:
| 方法 | 返回值 | 含义 |
|---|---|---|
TT.now() |
TTinterval [earliest, latest] |
当前时间的置信区间 |
TT.after(t) |
bool |
真实时间是否肯定在 t 之后 |
TT.before(t) |
bool |
真实时间是否肯定在 t 之前 |
关键在 TT.now()
返回的不是一个点而是一个区间。Google
保证真实时间一定在这个区间内。区间宽度 ε
通常在 1-7 毫秒之间。
置信区间计算
每个 Spanner 节点运行一个 timeslave 守护进程,定期轮询多台 time master。置信区间的计算过程:
- timeslave 同时查询多台 GPS master 和原子钟 master。
- 丢弃明显偏离的响应(检测出局部故障)。
- 对剩余响应取加权平均,得到估计的物理时间。
- 根据网络往返时延(RTT)和时钟漂移模型,计算不确定性范围。
置信区间公式(简化):
ε = RTT/2 + drift_rate × time_since_last_sync
其中 RTT/2
是网络传输的不确定性,drift_rate × time_since_last_sync
是上次同步以来的晶振漂移。
Corbett 等人 2012 年论文(Spanner: Google’s Globally-Distributed Database)中的实测数据:ε 的 p99 约 6-7 毫秒,但在 time master 故障切换期间可能短暂升高。
Commit-Wait 协议
有了 TrueTime,Spanner 的事务提交协议变得直观:
- 事务提交时获取 TrueTime 区间
[t_earliest, t_latest]。 - 选择提交时间戳
s = t_latest(区间上界)。 - 等待直到
TT.after(s)返回 true——即真实时间肯定超过了s。 - 此时可以安全提交:任何在此事务之后开始的事务,获取的时间戳一定
>
s。
等待时间 = s - t_earliest ≈ 2ε。典型值:4-14
毫秒。
这就是 commit-wait 的代价——每个写事务延迟增加约 2ε。但 ε 只有几毫秒,对大多数应用来说可以接受。
为什么 CockroachDB 不能这样做?
CockroachDB 的 max_offset = 500ms。如果用
commit-wait,每次写入至少等
500ms。一个跨节点事务可能要提交多次,延迟直接到秒级——这对
OLTP 工作负载完全不可接受。
所以 CockroachDB 把复杂度从写路径转移到了读路径:写入不等待,读取遇到不确定性时再处理。
六、CockroachDB vs Spanner:两种工程哲学
两个系统解决同一个问题,但方案的哲学完全相反。
不确定性窗口 vs 等待策略
Spanner (TrueTime):
写入 → 获取 TT.now() → 提交时间戳 = latest → 等待 2ε → 提交
读取 → 直接按时间戳读 → 不需要不确定性检查
代价:写延迟 + 2ε(~4-14ms)
收益:读路径零额外开销
CockroachDB (HLC):
写入 → 获取 HLC.Now() → 直接提交 → 零额外延迟
读取 → 检查不确定性窗口 → 可能触发 uncertainty restart
代价:读路径可能重试
收益:写延迟零额外开销
详细对比
| 维度 | Spanner (TrueTime) | CockroachDB (HLC) |
|---|---|---|
| 不确定性窗口 | 1-7ms | 500ms(可调,实践中 250-500ms) |
| 写延迟开销 | +4-14ms(commit-wait) | 0 |
| 读延迟开销 | 0 | 通常 0,偶尔 uncertainty restart |
| 一致性保证 | 外部一致性(External Consistency) | 可串行化(Serializable) |
| 硬件依赖 | 原子钟 + GPS | NTP |
| 部署成本 | 极高(自建)或 Cloud Spanner 定价 | 普通服务器 |
| 跨区域延迟 | commit-wait 不随距离增长 | max_offset 不随距离增长 |
| 故障模式 | time master 故障导致 ε 升高 | NTP 故障导致节点退出 |
一致性语义差异
Spanner 提供外部一致性(external consistency):如果事务 T1 的提交在事务 T2 的开始之前(按挂钟时间),那么 T1 的提交时间戳一定小于 T2 的提交时间戳。这是因为 commit-wait 保证了”提交完成”意味着”真实时间已经过了提交时间戳”。
CockroachDB 提供可串行化隔离(serializable isolation),加上 uncertainty restart 来逼近外部一致性。但严格来说,两个没有因果关系的事务(比如两个不同客户端的独立操作),CockroachDB 不保证它们的时间戳顺序和挂钟顺序一致。uncertainty restart 只是减小了违反的概率,不能消除。
在实践中,对大多数应用来说,CockroachDB 提供的一致性已经够强了。两个”同时”发生的独立事务,哪个先哪个后通常没有业务含义。真正需要外部一致性的场景(比如全球金融交易的严格定序)很少。
七、AWS Clockbound:TrueTime 的开源替代
2021 年,AWS 开源了 ClockBound,提供了一个类似 TrueTime 的 API——在普通 EC2 实例上也能用。
架构
ClockBound 由两部分组成:
- ClockBound daemon:运行在每台 EC2 实例上的守护进程。定期查询 Amazon Time Sync Service(AWS 基于 GPS + 原子钟的 NTP 服务),维护本地的时钟误差估计。
- ClockBound client library:应用程序通过 Unix domain socket 与 daemon 通信,获取当前时间的置信区间。
API
// ClockBound API(Go 伪代码,基于官方 Rust 库的接口)
type ClockBound struct{}
// Now 返回当前时间的置信区间 [earliest, latest]
// 保证真实时间在此区间内
func (cb *ClockBound) Now() (earliest, latest time.Time, err error)
// Before 返回真实时间是否肯定在 t 之前
func (cb *ClockBound) Before(t time.Time) (bool, error)
// After 返回真实时间是否肯定在 t 之后
func (cb *ClockBound) After(t time.Time) (bool, error)和 TrueTime 的 API 一模一样。差别在精度:
| 指标 | TrueTime | ClockBound (EC2) |
|---|---|---|
| 典型 ε | 1-7 ms | 0.5-2 ms |
| 时钟源 | GPS + 原子钟(自建) | Amazon Time Sync Service |
| 部署方式 | 需要自建 time master | 开箱即用(AWS 提供服务) |
| 适用范围 | Google 内部 | 任何 EC2 实例 |
AWS 的 Amazon Time Sync Service 在 EC2 实例上通过 PTP(Precision Time Protocol,精确时间协议(Precision Time Protocol)) 和专用硬件提供亚毫秒级同步。ClockBound 的 ε 反而可能比 Google TrueTime 更小——因为数据中心内的 PTP 网络延迟极低。
误差计算原理
ClockBound daemon 维护一个误差模型:
bound = network_delay + drift_rate × time_since_sync
其中:
network_delay: PTP/NTP 同步的网络往返时延的一半
drift_rate: 本地晶振的漂移率(通常 ~200 ppb,即每秒 200 纳秒)
time_since_sync: 距离上次时钟同步的时间
每次同步后 bound 重置为
network_delay(很小),然后随时间线性增长。如果同步间隔是
1 秒,晶振漂移率 200 ppb,那么最大额外误差 =
200ns——可以忽略不计。
工程意义
ClockBound 的意义不在于替代 TrueTime,而在于降低了”有界时钟误差”方案的门槛。以前只有 Google 能做到”我知道我的时钟误差上界是多少”;现在任何 AWS 用户都可以。
配合 ClockBound,你可以在应用层实现
commit-wait——不需要修改数据库,只需要在提交后等待
2ε ≈ 1-4ms。对于使用 DynamoDB 或 Aurora
且需要更强一致性的场景,这是一个实用的方案。
八、时钟偏移对事务的影响
时钟偏移不是抽象概念。它直接影响你的事务行为。
过期读(Stale Read)
当读事务的时间戳低于实际已提交的写入时间戳,就发生过期读。
真实时间线:
t=100: 客户端 C1 在节点 A 写入 x=1,时间戳 (105, 0) // A 时钟偏快 5ms
t=101: 客户端 C1 在节点 B 读取 x,时间戳 (98, 0) // B 时钟偏慢 3ms
B 看到的情况:
read_ts = (98, 0)
write_ts = (105, 0)
write_ts > read_ts → 按 MVCC 规则,读看不到这个写
结果:C1 读不到自己 1ms 前刚写的数据。
没有 uncertainty restart 机制的话,这就是一个严重的一致性违反。
不确定性重启(Uncertainty Restart)
CockroachDB 的处理:
max_offset = 500ms
read_ts = (98, 0)
uncertainty_limit = (98 + 500ms, 0) = (598, 0)
扫描 MVCC 版本链时发现 write_ts = (105, 0)
(98, 0) < (105, 0) < (598, 0) → 在不确定性窗口内
触发 ReadWithinUncertaintyIntervalError
→ 将 read_ts 推高到 (105, 1)
→ 以新时间戳重试读取
→ 这次读到了 x=1
重试后的不确定性窗口: [(105, 1), (598, 0)] → 窗口缩小了
对性能的影响
uncertainty restart 不是免费的。每次重启意味着:
- 当前的读操作丢弃结果
- 以更高的时间戳重新开始
- 如果事务已经读了其他 key,可能需要重新验证
在高写入吞吐 + 大时钟偏移的场景下,restart 概率不低。降低
max_offset 能直接减少 restart
概率,但要求更严格的 NTP 同步。
实际调优建议:
| NTP 方案 | 典型偏差 | 推荐 max_offset | uncertainty restart 频率 |
|---|---|---|---|
| 公网 NTP | 10-150ms | 500ms(默认) | 较高 |
| 局域网 NTP | 0.1-1ms | 50-100ms | 低 |
| chrony + 局域网 | 0.01-0.1ms | 25-50ms | 极低 |
| PTP(精密时间协议) | 1-10μs | 10ms | 几乎为零 |
| ClockBound (AWS) | 0.5-2ms | 可用 commit-wait | 不适用 |
九、工程选型:什么场景用什么时钟
场景一:单区域、能接受中心化
推荐方案:TSO(Timestamp Oracle)
TiDB 的 PD(Placement Driver)就是一个 TSO 实现。所有时间戳从中心节点分配,天然全序,不需要处理不确定性。
优点:实现简单,推理容易,无 uncertainty restart。
代价:PD 是单点(需要高可用部署),跨区域请求 PD 延迟高。
适用:单区域部署、写入延迟敏感、能容忍中心节点的场景。
场景二:多区域、无特殊硬件
推荐方案:HLC + 严格 NTP
CockroachDB 的方案。每个节点本地生成时间戳,通过 HLC 保证因果序,通过 uncertainty restart 处理跨节点不确定性。
优点:去中心化,无单点,跨区域延迟低。
代价:需要仔细配置 NTP(推荐 chrony),max_offset 越大 uncertainty restart 概率越高。
适用:多区域部署、不愿引入中心化时间服务、能接受偶尔读重试的场景。
场景三:AWS 上需要强一致性
推荐方案:ClockBound + 应用层 commit-wait
利用 AWS 的 Amazon Time Sync Service 和 ClockBound 库,在应用层获取时间置信区间。写入后等待 2ε(约 1-4ms)再返回。
优点:在普通 EC2 上获得类似 TrueTime 的能力,不需要修改数据库。
代价:写延迟增加 1-4ms,依赖 AWS 基础设施。
适用:AWS 上部署、需要接近外部一致性、能接受小幅写延迟的场景。
场景四:需要严格外部一致性,预算充足
推荐方案:TrueTime / Cloud Spanner
如果你需要 Google 级别的外部一致性,直接用 Cloud Spanner。Google 帮你维护原子钟和 GPS 基础设施。
优点:最强的一致性保证,commit-wait 延迟只有几毫秒。
代价:Cloud Spanner 定价不便宜;自建 TrueTime 的硬件和运维成本更高。
适用:全球金融交易、需要严格定序的多区域写入、预算充足的场景。
场景五:只需要因果追踪,不需要物理时间
推荐方案:Lamport Clock
如果你只需要”事件 A 是不是在事件 B 之前”,不关心”事件 A 发生在几点几分”,Lamport 时钟就够了。一个整数,两条规则,20 行代码。
适用:日志因果排序、分布式调试跟踪、事件溯源系统。
场景六:需要检测并发冲突
推荐方案:Vector Clock 或 Dotted Version Vector
如果你的系统允许并发写入(比如 Dynamo 风格的最终一致性),需要在合并时检测冲突,Vector Clock 是正确选择。节点数 < 100 时 overhead 可控。更大规模可以考虑 Dotted Version Vector 或 Interval Tree Clock。
适用:最终一致性系统、CRDT 合并、多主复制冲突检测。
选型总结表
| 需求 | 方案 | 典型系统 |
|---|---|---|
| 全序 + 简单 | TSO | TiDB |
| 因果序 + 去中心化 | HLC | CockroachDB |
| 外部一致性 + 低延迟 | TrueTime | Spanner |
| 有界误差 + AWS | ClockBound | 自建 |
| 纯因果序 | Lamport Clock | 日志系统 |
| 并发检测 | Vector Clock | Dynamo, Riak |
十、超越 HLC:前沿进展
Hybrid Vector Clock(HVC)
Yingchareonthawornchai et al. 2018 年提出 Hybrid Vector Clock,在 HLC 基础上加入有限维度的向量分量。在 ε 有界的前提下,HVC 可以用远小于 N 的向量维度来检测并发——在 HLC(不能检测并发)和 Vector Clock(O(N) 维度)之间取得折中。
DeterministicTimestamp
Calvin 系统(Thomson et al. 2012)采用了完全不同的路线:先确定全局事务顺序,再执行。时间戳在事务执行前就已确定,不需要运行时的时钟同步。代价是所有事务必须提前声明读写集。
把 ε 压到极限
随着 PTP(精确时间协议)和 White Rabbit 协议在数据中心的普及,时钟同步精度从毫秒级进入亚微秒级。当 ε < 100 纳秒时,commit-wait 的延迟开销几乎为零——这时候 TrueTime 方案的唯一障碍就只剩硬件成本了。
参考资料
论文
- Kulkarni, S., Demirbas, M., Madeppa, D., Avva, B., Leone, M. “Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases.” Bulletin of the IEEE Computer Society Technical Committee on Distributed Processing, 2014.
- Corbett, J. C. et al. “Spanner: Google’s Globally-Distributed Database.” OSDI, 2012.
- Lamport, L. “Time, Clocks, and the Ordering of Events in a Distributed System.” Communications of the ACM, 1978.
- Yingchareonthawornchai, S. et al. “Analysis of Bounds on Hybrid Vector Clocks.” IEEE IPDPS, 2018.
源码
- CockroachDB HLC
实现:
cockroachdb/cockroach,pkg/util/hlc/ - CockroachDB
时钟偏移监控:
cockroachdb/cockroach,pkg/rpc/clock_offset.go - CockroachDB
不确定性处理:
cockroachdb/cockroach,pkg/kv/kvserver/uncertainty/ - AWS ClockBound:
aws/clock-bound, GitHub
文档
- CockroachDB 官方文档:“Architecture: Transaction Layer” — Uncertainty & Confidence Intervals
- Google Cloud Spanner 文档:TrueTime and External Consistency
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【分布式系统实战】混合时钟与因果一致性:Lamport → Vector → HLC
分布式系统里最难的问题之一:如何定义事件的先后?物理时钟对不齐,逻辑时钟丢信息,向量时钟太重。HLC 用物理时间 + 逻辑计数器找到了平衡点。从 Lamport 时钟一路推导到 CockroachDB 的工程实现。
【分布式系统实战】分布式事务不是你以为的那个 2PC
「用 2PC 就行了」——说这话的人大概没在生产环境里被 Coordinator 挂掉后全员阻塞的锁堵过三小时。2PC 的真实失败模式、Percolator 的精妙设计、Saga 与 TCC 的工程取舍,分布式事务远比教科书复杂。
【分布式系统百科】物理时钟的谎言:NTP、PTP 与时钟漂移的工程现实
分布式系统的物理时钟从来不精确:石英振荡器每天最多漂移 8.6 秒,NTP 校准依赖对称网络假设,闰秒可以在凌晨击垮 Linux 内核。本文拆解石英漂移的物理根源、NTP/PTP 协议的校准机制、时钟跳变对超时逻辑的破坏、Spanner TrueTime 和 AWS Clockbound 的工程方案,以及工程师应该遵守的墙上时钟与单调时钟使用规范。
【分布式系统百科】成员协议:SWIM 与 Gossip 的工程实现
从 Gossip 协议的 SI 传播模型出发,深入拆解 SWIM 故障检测协议的直接探测、间接探测和怀疑机制,分析 HashiCorp Memberlist 的源码实现,对比 Serf 与 Consul 的成员管理策略,并提供基于 Memberlist 构建集群成员管理的完整 Go 代码示例。