土法炼钢兴趣小组的算法知识备份

【分布式系统百科】混合逻辑时钟与 TrueTime:在物理和逻辑之间找到平衡

文章导航

分类入口
distributed
标签入口
#hlc#truetime#cockroachdb#spanner#clockbound#distributed-systems#time#hybrid-clock

目录

混合逻辑时钟与 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 个计数器

分布式数据库的需求很具体:

  1. 因果序:如果事务 A 的提交在事务 B 开始之前,A 的时间戳必须小于 B 的时间戳。
  2. 物理时间近似:时间戳要和挂钟时间大致对应,这样日志排序、TTL 过期、调试才有意义。
  3. 恒定大小:不能随节点数膨胀。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)

时间戳的全序比较:先比 ll 相同再比 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=10pt.B=8pt.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)

看这个例子的两个关键点:

  1. 因果序保持:A 发送 (10, 0) → B 接收 (10, 1),时间戳严格递增。
  2. 物理时间近似:每个时间戳的 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 不会偏离真实时间太远;而 cl 每次推进时都会重置为 0,所以 c 不会无限增长。

具体来说,可以从两个方向理解 HLC 的正确性:

  1. 因果序为什么能保持? 考虑一个消息从 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 的 cmax(c_B, c_A) + 1 > c_A。无论哪种情况,接收方的时间戳严格大于发送方。因果链上的每一步时间戳都严格递增,传递性自然成立。

  2. 物理时间为什么不会漂太远? 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, c.f) > (l.e, c.e)

情况 3:传递性。 如果 e → gg → 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 或自己的物理时钟推高。

通过归纳法:

这意味着 HLC 的物理时间部分永远不会偏离真实时间超过 ε。ε 就是 NTP 的最大误差。

性质三:c 的有界性

定理:在任何两次物理时间推进之间,c 最多递增到系统内的事件总数。

实际工程中,物理时钟的粒度远粗于事件频率的倒数——毫秒级时钟下,每毫秒能发生的事件有限,所以 c 不会太大。CockroachDB 使用纳秒级 WallTimec 大部分时候都是 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
}

注意判断逻辑:不是”和任何一个节点偏差过大就退出”,而是”和多数节点偏差过大才退出”。这避免了单个节点时钟异常导致误杀健康节点。

CockroachDB 时钟偏移处理流程

不确定性窗口与读路径

HLC 保证因果序,但不保证实时序(linearizability)。两个没有因果关系的事件,时间戳的大小关系不反映真实发生顺序。这在读路径上会造成问题。

考虑这个场景:

  1. 客户端在节点 A 执行 WRITE x = 1,获得时间戳 t_w = 100
  2. 客户端立刻在节点 B 执行 READ x,B 的物理时钟偏慢,分配了时间戳 t_r = 90
  3. 因为 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),分两类:

  1. GPS master:配备 GPS 接收器和天线,从 GPS 卫星获取 UTC 时间。精度约 1 微秒。弱点:GPS 天线故障、信号干扰、闰秒处理。
  2. 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。置信区间的计算过程:

  1. timeslave 同时查询多台 GPS master 和原子钟 master。
  2. 丢弃明显偏离的响应(检测出局部故障)。
  3. 对剩余响应取加权平均,得到估计的物理时间。
  4. 根据网络往返时延(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 的事务提交协议变得直观:

  1. 事务提交时获取 TrueTime 区间 [t_earliest, t_latest]
  2. 选择提交时间戳 s = t_latest(区间上界)。
  3. 等待直到 TT.after(s) 返回 true——即真实时间肯定超过了 s
  4. 此时可以安全提交:任何在此事务之后开始的事务,获取的时间戳一定 > 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 由两部分组成:

  1. ClockBound daemon:运行在每台 EC2 实例上的守护进程。定期查询 Amazon Time Sync Service(AWS 基于 GPS + 原子钟的 NTP 服务),维护本地的时钟误差估计。
  2. 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 不是免费的。每次重启意味着:

  1. 当前的读操作丢弃结果
  2. 以更高的时间戳重新开始
  3. 如果事务已经读了其他 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 方案的唯一障碍就只剩硬件成本了。


参考资料

论文

  1. 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.
  2. Corbett, J. C. et al. “Spanner: Google’s Globally-Distributed Database.” OSDI, 2012.
  3. Lamport, L. “Time, Clocks, and the Ordering of Events in a Distributed System.” Communications of the ACM, 1978.
  4. Yingchareonthawornchai, S. et al. “Analysis of Bounds on Hybrid Vector Clocks.” IEEE IPDPS, 2018.

源码

  1. CockroachDB HLC 实现:cockroachdb/cockroach, pkg/util/hlc/
  2. CockroachDB 时钟偏移监控:cockroachdb/cockroach, pkg/rpc/clock_offset.go
  3. CockroachDB 不确定性处理:cockroachdb/cockroach, pkg/kv/kvserver/uncertainty/
  4. AWS ClockBound:aws/clock-bound, GitHub

文档

  1. CockroachDB 官方文档:“Architecture: Transaction Layer” — Uncertainty & Confidence Intervals
  2. Google Cloud Spanner 文档:TrueTime and External Consistency

上一篇:逻辑时钟 | 下一篇:一致性模型全景

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-25 · distributed

【分布式系统实战】分布式事务不是你以为的那个 2PC

「用 2PC 就行了」——说这话的人大概没在生产环境里被 Coordinator 挂掉后全员阻塞的锁堵过三小时。2PC 的真实失败模式、Percolator 的精妙设计、Saga 与 TCC 的工程取舍,分布式事务远比教科书复杂。

2026-04-13 · distributed-systems

【分布式系统百科】物理时钟的谎言:NTP、PTP 与时钟漂移的工程现实

分布式系统的物理时钟从来不精确:石英振荡器每天最多漂移 8.6 秒,NTP 校准依赖对称网络假设,闰秒可以在凌晨击垮 Linux 内核。本文拆解石英漂移的物理根源、NTP/PTP 协议的校准机制、时钟跳变对超时逻辑的破坏、Spanner TrueTime 和 AWS Clockbound 的工程方案,以及工程师应该遵守的墙上时钟与单调时钟使用规范。

2026-04-13 · distributed

【分布式系统百科】成员协议:SWIM 与 Gossip 的工程实现

从 Gossip 协议的 SI 传播模型出发,深入拆解 SWIM 故障检测协议的直接探测、间接探测和怀疑机制,分析 HashiCorp Memberlist 的源码实现,对比 Serf 与 Consul 的成员管理策略,并提供基于 Memberlist 构建集群成员管理的完整 Go 代码示例。


By .