Skip to content

一致性模型与时钟:从线性一致性到向量时钟

本文是分布式系统系统学习系列的 L1 入门篇。前置:26. from-monolith-to-distributed-cap-base。 学完可以配合面试题食用:24-vector-clock-lamport18-distributed-clock-truetime-spanner-hlc

为什么需要严格的一致性

上一篇文章说 CAP 在网络分区时 C 和 A 只能二选一。但"一致性"本身不是一个开关,而是一条光谱——有的系统要求所有看见完全一样,有的只要求最终看得见就行。拿银行转账来说:A 扣 100 元,B 加 100 元,如果第一个人看到余额已扣、第二个人看到还没到账,就会产生资金纠纷。系统事件之间的先后顺序,就是一致性模型要回答的问题。

java
// 最小模型:两个客户端同时写共享变量
// 客户端1:写 x = 1
// 客户端2:写 x = 2
// 客户端3:读 x → 如果读到 1,说明客户端1的操作"先发生"
// 问题:没有全局时钟,怎么判断谁先谁后?

一致性光谱:从强到弱

一致性不是"严格/不严格"的二元对立,而是一系列强度递减的模型,每个模型放宽了某些约束来换取性能或可用性。

线性一致性(Linearizability)

最强模型。操作完成后,所有后续读都能看到这个结果。听起来理所当然,但分布式环境下代价极高:每个写操作要等所有副本确认,读操作也要走多数派或 leader 去拿最新值。etcd 的线性读做了 ReadIndex 优化后才敢用。

并发写的例子:两个客户端同时给同账户充值。用户 A 充值 100,用户 B 充值 200。在线性一致性下,读到的余额要么是 +100,要么是 +200,但绝不会出现 +100 和 +200 之间的中间状态。

顺序一致性(Sequential Consistency)

每个进程内部的操作顺序不变,但不同进程的操作可以交错。读不一定要拿到最新值,只要求所有进程看到相同的操作交错顺序。例子:A 写 x=1,B 写 x=2,所有人都必须看到 x=1 在 x=2 之前(或 x=2 在 x=1 之前),但允许一个人看到 x=1 后被延迟。

因果一致性(Causal Consistency)

只有有因果关系的操作才需要按顺序被看到。不相关的并发操作可以任意顺序出现。A 发了一条朋友圈,B 评论了它——这个评论依赖那条朋友圈,所以谁看到 B 的评论时也必须看到 A 的朋友圈。但两条无关联的朋友圈,不同人看到顺序可以不同。

最终一致性(Eventual Consistency)

最弱模型。如果没有新写入,副本最终会收敛到一致值。DNS 就是典型:更新一个域名解析记录,全球 DNS 缓存可能需要几分钟到几小时才能全部刷新。这段时间内各节点看到的值不同,但最终会一致。

物理时钟为什么靠不住

给事件打时间戳看起来是最直接的办法:用墙上的钟确定先后。但分布式环境下,物理时钟有三个致命问题:

  1. NTP 漂移:机器之间的系统时钟可能差几十毫秒甚至几百毫秒。NTP 同步后,如果时钟回调(闰秒、NTP 跳跃),可能导致时间戳"倒流"
  2. 精度差异:一个事件的 timestamp 1 比另一个事件的 timestamp 2 小,你不能确定 1 真的先发生——可能只是两台机器时钟不同步
  3. 不可靠区间:在物理时钟下,事件 A 和 B 之间的因果关系无法仅靠时间戳判断

Lamport 在 1978 年提出一个关键观察:我们不需要全局时钟,只需要知道"谁先发生"的偏序关系

Lamport 逻辑时钟:先发生关系

Lamport 逻辑时钟给每个节点维护一个计数器(逻辑时间戳),节点之间通过消息传递互相更新:

1. 每个节点维护一个整数 clock,初始 0
2. 每个节点内部事件:clock += 1
3. 发送消息时:clock += 1,把 clock 附在消息里
4. 接收消息时:clock = max(本地 clock, 消息里的 clock) + 1

这样,如果 A 事件发生在 B 事件之前(happened-before),那么 A 的 Lamport 时间戳一定小于 B 的。但反过来不成立——时间戳小不代表真的先发生,这叫"反向不成立"。

mermaid
sequenceDiagram
    participant N1 as 节点1
    participant N2 as 节点2
    
    Note over N1: clock=1
    N1->>N1: 内部事件 (clock=2)
    N1->>N2: 发送消息 (clock=3)
    Note over N2: 收到, clock=max(1,3)+1=4
    N2->>N2: 内部事件 (clock=5)
    N2->>N1: 回复消息 (clock=6)
    Note over N1: 收到, clock=max(3,6)+1=7

缺陷:Lamport 时钟只能知道两个事件有没有先后顺序,不能检测并发冲突。如果两个事件没有因果关系(不是 happen-before),它们可能是并发的,但 Lamport 时钟无法表达这一点。

向量时钟:检测并发冲突

向量时钟在每个节点维护一个向量(size = 节点数),每个分量对应该节点的逻辑时钟。谁更新了,谁的分量就加 1。

三个节点 A、B、C,初始向量 [0, 0, 0]

A 发生内部事件 → [1, 0, 0]
A 发消息给 B,附上 [1, 0, 0]
B 收到后取 max,然后 B 分量 +1 → [1, 1, 0]
B 内部事件 → [1, 2, 0]
C 内部事件 → [0, 0, 1]

比较规则:

  • 如果向量 V1 的每个分量 <= V2 的对应分量,则 V1 先于 V2
  • 如果 V1 某分量大于 V2、V2 某分量大于 V1,则两者并发
  • 如果两者相等,则同一事件(现实不会重复)
python
# 向量时钟推演:两个客户端、三个节点
# 客户端 X 写 key="balance" value=100
# 客户端 Y 写 key="balance" value=200
# 展示并发冲突检测

from dataclasses import dataclass
from typing import Dict, List, Tuple

@dataclass
class VectorClock:
    vec: Dict[str, int]  # node_id -> count
    
    def increment(self, node: str):
        self.vec[node] = self.vec.get(node, 0) + 1
    
    @staticmethod
    def merge(a: 'VectorClock', b: 'VectorClock') -> 'VectorClock':
        """合并两个向量时钟,每个分量取最大值"""
        all_keys = set(a.vec.keys()) | set(b.vec.keys())
        merged = {k: max(a.vec.get(k, 0), b.vec.get(k, 0)) for k in all_keys}
        return VectorClock(merged)
    
    def compare(self, other: 'VectorClock') -> str:
        """返回 'before', 'after', 'concurrent', 'equal'"""
        keys = set(self.vec.keys()) | set(other.vec.keys())
        le = all(self.vec.get(k, 0) <= other.vec.get(k, 0) for k in keys)
        ge = all(self.vec.get(k, 0) >= other.vec.get(k, 0) for k in keys)
        if le and ge:
            return 'equal'
        if le:
            return 'before'
        if ge:
            return 'after'
        return 'concurrent'

# 模拟两个客户端并发修改
c1 = VectorClock({})
c1.increment("client-X")  # X 写 balance=100
c1.increment("node-A")    # A 节点确认

c2 = VectorClock({})
c2.increment("client-Y")  # Y 写 balance=200
c2.increment("node-B")    # B 节点确认

# 冲突检测:两个向量各分量互不包含
print(c1.compare(c2))  # concurrent

向量时钟的代价:每个时钟向量的大小等于节点数。100 个节点的集群,每个事件附带 100 个整数的向量,元数据膨胀严重。DynamoDB 和 Riak 用过,但实际生产更常用混合方案。

混合逻辑时钟 HLC:工程折中

HLC(Hybrid Logical Clock)结合了物理时钟和逻辑时钟的优点:大部分情况下用物理时间戳,但通过逻辑计数器保证因果关系不反

HLC = (物理时间戳, 逻辑计数器)

1. 物理时钟走了,就取物理时钟的值,计数器复位
2. 物理时钟没变,则计数器递增
3. 收到消息时,取 max(本地物理, 远程物理),如果相等则取 max(本地计数器, 远程计数器) + 1

HLC 的值接近物理时钟(误差在时钟漂移范围内),所以可以做时间范围查询。CockroachDB 的 MVCC 时间戳就是用 HLC 实现的。代价是比纯逻辑时钟多了一个物理时间分量,但比向量时钟小得多(只有两个值)。

Google TrueTime:花钱买一致性

TrueTime 是 Spanner 依赖的全局时钟设施。思路很直接:用硬件(GPS 原子钟)把时间不确定性缩小到可接受范围

TrueTime 返回一个区间 [earliest, latest]
真实时间保证在这个区间内
当前区间宽度约 1-7ms

Spanner 在事务提交时,等待一个"commit wait"(≤ 区间宽度),确保物理时间已经超过所有副本的 last commit。这本质上是用时间停顿线性一致性

为什么大部分系统不学 TrueTime?因为硬件成本极高:每个数据中心要部署 GPS 接收器和原子钟,跨数据中心还要光纤直连。Google 做这个是因为全球数据一致性对搜索索引和广告系统够值,但大部分公司几十台服务器的规模,用 Raft 加租约就够了。

动手实操:向量时钟推演与 HLC 伪代码

python
# HLC 规则实现
class HLC:
    def __init__(self, pt: int = 0, l: int = 0):
        self.pt = pt  # 物理时间
        self.l = l    # 逻辑计数器
    
    def now(self) -> Tuple[int, int]:
        """生成当前 HLC 时间戳"""
        now_pt = get_physical_time()
        if now_pt > self.pt:
            self.pt = now_pt
            self.l = 0
        else:
            self.l += 1
        return (self.pt, self.l)
    
    def recv(self, msg_pt: int, msg_l: int) -> Tuple[int, int]:
        """收到消息时更新 HLC"""
        now_pt = get_physical_time()
        self.pt = max(now_pt, msg_pt)
        if self.pt == msg_pt == now_pt:
            self.l = max(self.l, msg_l) + 1
        elif self.pt == msg_pt:
            self.l = max(self.l, msg_l) + 1
        elif self.pt == now_pt:
            self.l += 1
        else:
            self.l = 0
        return (self.pt, self.l)
    
def get_physical_time() -> int:
    import time
    return int(time.time() * 1000)  # 毫秒

建议自己跑一遍这个脚本,在三个节点之间模拟 10 个事件,观察 HLC 的时间戳变化。关键观察:节点间时间差很小时,HLC 等价于物理时钟;时间差大时(比如 node-A 比 node-B 快 2s),HLC 会自动追平,不会产生反因果的时间戳。

常见误区与小结

常见误区

  • "线性一致性才能保证不丢数据":错。很多系统用最终一致+CRDT 也能保证不丢不冲突,比如协作编辑应用的 OT/CRDT 方案。
  • "Lamport 时钟能判断所有事件的先后顺序":错。Lamport 时钟只能判断"先发生"关系,不能判断"并发"。两个并发事件的 Lamport 时间戳可能一大一小,但实际没有先后关系。
  • "HLC 就是物理时钟":错。HLC 的物理时间部分受 NTP 漂移影响,但逻辑计数器保证偏序关系,所以永远不会有反因果。物理时间只做"范围查询"用,不保证全局准确性。
  • "TrueTime 的区间就是真实时间":错。TrueTime 返回的是包含真实时间的区间,不是精确值。commit wait 是为了保证所有副本的时钟都过了这个区间。
  • "向量时钟能解决所有冲突":错。向量时钟只能检测冲突,不能解决冲突。解决冲突需要应用层语义(CRDT、LWW 等)。

小结

一致性模型是分布式系统"选型"的前提:选线性一致性你就要接受更慢的写入和更复杂的 coordinator;选最终一致你就要处理冲突和补偿。时钟是这一切的基础设施——物理时钟不靠谱,逻辑时钟只保偏序,向量时钟空间大,HLC 是现实的折中。下一篇文章会基于时钟和一致性模型,继续深入共识算法(Paxos → Raft),看分布式系统是怎么在不可靠的时钟上达成可靠共识的。

参考

参考:Lamport, "Time, Clocks, and the Ordering of Events in a Distributed System" (1978) — 经典论文,40 页看懂逻辑时钟诞生 参考:CockroachDB HLC 实现 — https://github.com/cockroachdb/cockroach/blob/master/pkg/util/hlc/hlc.go 参考:Google Spanner — "Spanner: Becoming a SQL System" (2017) 里 TrueTime 的工程收益与代价

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1