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

混合时钟与因果一致性:Lamport → Vector → HLC

目录

本文和 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,规则只有两条:

  1. 本地事件发送消息时:L = L + 1
  2. 接收消息时: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
  2. 发送消息:自己那个分量 +1,把整个向量附在消息中
  3. 接收消息:逐分量取 max,然后自己那个分量 +1

判断因果关系

给定两个向量 V1 和 V2:

这就是 Lamport 时钟做不到的:Vector Clock 可以明确判断两个事件是并发的。

正确性(简化版)

为什么 Vector Clock 能判断因果?直觉上:

形式化地说: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 永远不会倒退。 如果本地物理时钟突然倒退了(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

性质

  1. 因果序:a → b ⟹ HLC(a) < HLC(b)(先比 pt,再比 lc)
  2. 物理时间近似:pt 始终 ≥ 真实物理时间,且差距有界(取决于 NTP 同步质量)
  3. 大小恒定:不管多少节点,时间戳始终是两个整数
  4. 单调递增:本地生成的时间戳严格递增,不会因为物理时钟倒退而倒退

和 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 的完整实现。核心逻辑就是 TickReceive 两个方法——和 Lamport 时钟结构一样,只是多了物理时间的参与。

HLC 的精度

HLC 的 pt 永远 ≥ 真实物理时间。差距有多大?取决于你的 NTP 同步质量。

假设 NTP 误差 ε 毫秒,那么 pt - 真实时间 ≤ ε。这意味着 HLC 时间戳虽然不完美,但误差是有界的。对于需要”大致知道事件发生在什么时间”的场景(日志排序、调试、监控),这已经够用了。

精度推导:HLC 的最大时间戳偏差

上面只给了直觉。现在严格推导一下。

前提条件:

推导:

考虑节点 A 在真实时间 t_real 产生一个 HLC 时间戳 (pt_A, lc_A)

  1. pt_A = max(pt_A_old, physical_clock_A())
  2. physical_clock_A() 与真实时间的偏差 ≤ ε,所以 pt_A ≤ t_real + ε(物理时钟可能快 ε)
  3. 但 pt_A 也可能被之前收到的消息推高——如果消息来自时钟偏快 ε 的节点,pt_A 可能比真实时间大 ε
  4. 逻辑计数器 lc_A 在两次物理时间推进之间最多累积 L 次

因此,HLC 时间戳与真实时间的最大偏差为:

max_offset = ε + L × δ

其中: - ε = NTP 最大误差(典型值:局域网 ~1ms,广域网 ~10-150ms) - L = 两次物理时钟推进之间的最大事件数(取决于吞吐量) - δ = 逻辑计数器的时间粒度(通常亚毫秒级,可忽略)

工程含义:

以 CockroachDB 为例,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),或者反过来。两种时钟方案的语义不同,不能直接切换。

为什么不能直接切换?

双写 + 时间戳调和方案:

阶段 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 时间戳。读写操作通过时间戳来确定可见性:

max_clock_offset:不确定性窗口

CockroachDB 有一个关键参数:--max-offset,默认 500ms

这个参数的含义是:任意两个节点的物理时钟之差不超过 500ms。 如果某个节点的时钟偏差超过了这个值,CockroachDB 会让它自杀(主动退出集群)。

为什么需要这个参数?因为 HLC 保证因果序,但不保证实时序。考虑这个场景:

  1. 客户端在节点 A 写入 key=x,时间戳 t1
  2. 客户端(不经过 A)直接去节点 B 读 key=x,时间戳 t2
  3. 如果 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、中心化系统

怎么选

你需要判断并发吗?

你有原子钟吗?

你能接受中心化时间戳服务吗?

大多数场景的答案是 HLCTSO。Lamport 时钟太弱,Vector Clock 太重,TrueTime 太贵。HLC 在”够用”和”不贵”之间找到了甜蜜点。


延伸阅读:

时钟方案对比图

By .