《后端架构设计·分布式与数据一致性》2.2 逻辑时钟与向量时钟:因果关系怎么编码

同一订单的取消与支付回调并发到达两个节点,Lamport 时钟给出 2 和 1,看起来像有先后,实际上谁也没见过谁。本文用表驱动测试判定 equal、before、after、concurrent,验证向量时钟合并与带水位剪枝,并给出剪枝会悄悄改坏因果关系的边界。

并发取消与支付回调被排成了先后

订单 order-1 在 20:00 进入 INVENTORY_RESERVED。接下来两件事几乎同时发生:用户在节点 A 发起取消,支付回调在节点 B 把订单推进到 PAID。节点 A 的取消没有见过支付回调,节点 B 的支付回调也没有见过取消;它们并发。

如果只用一个整数逻辑时钟,A 的取消可能拿到 2,B 的支付回调拿到 1。排序逻辑看到 2 大于 1,就把取消排在支付之后,于是按状态机执行“支付后取消”,触发退款;可实际上支付回调可能根本没有见过取消。另一种情况是两个事件拿到相同的 2,系统反而无法区分“并发”和“同一个事件被重复观察”。

常见错误直觉是“Lamport 时间戳更大,就说明事件发生得更晚或因果上更后”。Lamport 时钟只保证:如果 A 因果先于 B,那么 L(A) < L(B);反过来不成立。L(A) < L(B) 可能只是两个并发事件在各自节点上计数不同。

站内 一致性模型:线性一致 讨论过实时顺序与线性一致点的关系。本节先解决更基础的一层:怎样在不共享时钟的前提下,把“谁见过谁”编码进事件的元数据。

Lamport 时钟只保证单向蕴含

Lamport 时钟的规则只有两条:

  1. 本地发生事件时,计数器加一。
  2. 收到一个带计数器 r 的消息时,先把本地计数器更新为 max(local, r),再加一。

这样,消息从 A 到 B 时,B 的接收事件一定大于 A 的发送事件;因果链上的每一步都保持严格递增。但两个从未通信的节点可以产生相同的计数器,也可以产生一高一低。测试里两个独立节点各执行一次 Tick,都得到 1;节点 A 再执行一次得到 2,但节点 B 的 1 与 A 的 2 仍然并发。

=== RUN   TestLamportOrdersButCannotProveConcurrency
    vector_test.go:12: independent events: node-a=1 node-b=1 equal=true
    vector_test.go:21: node-a later=2 node-b=1
--- PASS: TestLamportOrdersButCannotProveConcurrency (0.00s)

所以 Lamport 时钟适合做“因果一致的排序键”:排序结果不会把因果顺序弄反。它不适合做冲突检测:看到两个不同的数字,不能判断是先后还是并发;看到相同的数字,只能说明两个本地事件没有交换过消息,不能说明它们是同一个事件。

向量时钟把“见过谁”写进时间戳

向量时钟给每个节点一个计数器:{node-a: 2, node-b: 1} 表示这个历史里包含 A 的 2 个事件和 B 的 1 个事件。规则同样简单:

  • 本地事件:把自己的计数器加一。
  • 收到远程向量:对每个节点取 max,然后把自己的计数器加一。
  • 合并两个向量:逐节点取 max。

比较时逐节点看偏序:所有分量都小于等于且至少一个严格小于,就是 before;反向是 after;两边各有更大的分量就是 concurrent;完全相同是 equal。缺失节点按 0 处理,这要求向量没有被剪枝过。

下面的实现把 Lamport 时钟和向量时钟放在同一个包,便于对照。Compare 是核心:它不做任何时间戳减法,只做逐分量偏序判断,因此不受时钟漂移影响。

// file: internal/platform/causality/vector.go
// tier: L1
// verify: go test ./internal/platform/causality/ -count=1
package causality

import "sort"

// Lamport is a scalar logical clock. It orders causally related events but
// cannot prove that two events are concurrent.
type Lamport struct{ n uint64 }

func (l *Lamport) Tick() uint64 { l.n++; return l.n }

func (l *Lamport) Observe(remote uint64) uint64 {
	if remote > l.n {
		l.n = remote
	}
	l.n++
	return l.n
}

func (l *Lamport) Value() uint64 { return l.n }

// VectorClock counts events per node.
type VectorClock map[string]uint64

func (v VectorClock) Tick(node string) uint64 {
	v[node]++
	return v[node]
}

func (v VectorClock) Observe(node string, remote uint64) uint64 {
	if remote > v[node] {
		v[node] = remote
	}
	v[node]++
	return v[node]
}

// Merge takes the pointwise maximum of two clocks.
func (v VectorClock) Merge(other VectorClock) {
	for node, counter := range other {
		if counter > v[node] {
			v[node] = counter
		}
	}
}

// Nodes returns node IDs in sorted order for stable output.
func (v VectorClock) Nodes() []string {
	nodes := make([]string, 0, len(v))
	for node := range v {
		nodes = append(nodes, node)
	}
	sort.Strings(nodes)
	return nodes
}

// Relation is the result of comparing two vector clocks.
type Relation int

const (
	Equal Relation = iota
	Before
	After
	Concurrent
)

func (r Relation) String() string {
	switch r {
	case Equal:
		return "equal"
	case Before:
		return "before"
	case After:
		return "after"
	case Concurrent:
		return "concurrent"
	default:
		return "unknown"
	}
}

// Compare classifies two unpruned clocks. Missing nodes count as zero.
func (v VectorClock) Compare(other VectorClock) Relation {
	lessOrEqual, greaterOrEqual := true, true
	for node, a := range v {
		b := other[node]
		if a > b {
			lessOrEqual = false
		}
		if a < b {
			greaterOrEqual = false
		}
	}
	for node, b := range other {
		a := v[node]
		if a > b {
			lessOrEqual = false
		}
		if a < b {
			greaterOrEqual = false
		}
	}
	switch {
	case lessOrEqual && greaterOrEqual:
		return Equal
	case lessOrEqual:
		return Before
	case greaterOrEqual:
		return After
	default:
		return Concurrent
	}
}

表驱动测试覆盖六种关系。注意 {a:1} 与 {b:1} 被判为并发,而不是“a 比 b 小”,因为两个分量互不包含;这正是 Lamport 标量做不到的。

$ go test ./internal/platform/causality/ -run TestVectorClockRelations -v -count=1
=== RUN   TestVectorClockRelations/equal
    vector_test.go:44: map[a:2 b:1] compare map[a:2 b:1] = equal
=== RUN   TestVectorClockRelations/before
    vector_test.go:44: map[a:1 b:1] compare map[a:1 b:2 c:1] = before
=== RUN   TestVectorClockRelations/after
    vector_test.go:44: map[a:1 b:2 c:1] compare map[a:1 b:1] = after
=== RUN   TestVectorClockRelations/concurrent
    vector_test.go:44: map[a:2 b:1] compare map[a:1 b:2] = concurrent
=== RUN   TestVectorClockRelations/disjoint
    vector_test.go:44: map[a:1] compare map[b:1] = concurrent
=== RUN   TestVectorClockRelations/empty_vs_event
    vector_test.go:44: map[] compare map[a:1] = before
--- PASS: TestVectorClockRelations (0.00s)

合并与剪枝

合并是逐分量取最大值,因此满足交换律、结合律和幂等律。节点 A 的 {a:2,b:1} 与节点 B 的 {b:3,c:1} 合并得到 {a:2,b:3,c:1},两个副本得到同一个向量,也就能继续用同一套偏序判断。

合并测试的原始输出是 merged=map[a:2 b:3 c:1] nodes=[a b c],节点顺序按字典序固定,便于比较。

剪枝是向量时钟真正的工程难点。节点数越多、历史越长,向量越大;如果直接把小于水位的项删掉,缺失节点会被当成 0,比较结果可能从 after 翻转成 before。下面的实现要求水位不能高于任何现存计数器:只有当所有节点都已经越过 floor 时,才能把等于 floor 的项折叠成“缺失即 floor”。

// file: internal/platform/causality/prune.go
// tier: L1
// verify: go test ./internal/platform/causality/ -run TestPrune -count=1
package causality

import (
	"errors"
	"fmt"
)

// ErrUnsafeFloor means the floor is above a counter still present in the clock.
var ErrUnsafeFloor = errors.New("causality: prune floor exceeds a known counter")

// ErrFloorMismatch means two pruned clocks are from different epochs.
var ErrFloorMismatch = errors.New("causality: pruned clocks have different floors")

// PrunedClock replaces entries at or below Floor with the floor itself.
type PrunedClock struct {
	Floor  uint64
	Counts VectorClock
}

// Prune drops counters equal to floor and rejects counters below it.
func (v VectorClock) Prune(floor uint64) (PrunedClock, error) {
	for node, counter := range v {
		if counter < floor {
			return PrunedClock{}, fmt.Errorf("%w: node %s counter %d floor %d",
				ErrUnsafeFloor, node, counter, floor)
		}
	}
	counts := make(VectorClock)
	for node, counter := range v {
		if counter > floor {
			counts[node] = counter
		}
	}
	return PrunedClock{Floor: floor, Counts: counts}, nil
}

// Get returns a counter, treating an absent node as the floor.
func (p PrunedClock) Get(node string) uint64 {
	if counter, ok := p.Counts[node]; ok {
		return counter
	}
	return p.Floor
}

// Compare classifies two clocks pruned at the same floor.
func (p PrunedClock) Compare(other PrunedClock) (Relation, error) {
	if p.Floor != other.Floor {
		return Equal, fmt.Errorf("%w: %d != %d", ErrFloorMismatch, p.Floor, other.Floor)
	}
	nodes := make(map[string]struct{}, len(p.Counts)+len(other.Counts))
	for node := range p.Counts {
		nodes[node] = struct{}{}
	}
	for node := range other.Counts {
		nodes[node] = struct{}{}
	}
	lessOrEqual, greaterOrEqual := true, true
	for node := range nodes {
		a, b := p.Get(node), other.Get(node)
		if a > b {
			lessOrEqual = false
		}
		if a < b {
			greaterOrEqual = false
		}
	}
	switch {
	case lessOrEqual && greaterOrEqual:
		return Equal, nil
	case lessOrEqual:
		return Before, nil
	case greaterOrEqual:
		return After, nil
	default:
		return Concurrent, nil
	}
}

测试验证了两条边界:{a:5,b:3} 与 {a:6,b:3} 在水位 3 剪枝后仍是 before;而 {a:5,b:2} 用水位 3 剪枝会返回 ErrUnsafeFloor,因为 b 的 2 会被错误地抬成 3。不同水位的向量也不能比较。

=== RUN   TestPrunePreservesComparison
    vector_test.go:79: pruned a=map[a:5] b=map[a:6] relation=before
=== RUN   TestPruneRejectsFloorAboveCounter
    vector_test.go:95: prune error=causality: prune floor exceeds a known counter: node b counter 2 floor 3
=== RUN   TestPrunedCompareRejectsFloorMismatch
    vector_test.go:111: compare error=causality: pruned clocks have different floors: 3 != 4
--- PASS

选型判据

场景推荐机制原因
单写者、单分区订单状态本地序列号最便宜,序就是提交序
只需要因果一致的日志排序Lamport 时钟每个事件一个整数,排序不反因果
需要检测并发修改向量时钟能区分 before/after/concurrent
需要与物理时间接近且检测并发HLC + 版本向量物理时间打底,逻辑计数打破平局
节点多、写少、冲突必须人工处理向量时钟 + 冲突队列空间换判定能力
节点数很大且拓扑动态剪枝向量或 dotted version vector必须显式维护水位与成员

向量时钟的代价是每个事件携带 O(nodes) 个计数器。节点数上千时,元数据可能比订单本身还大;这时要么按分片限制节点集合,要么改用只保留最近写者的 dotted version vector。剪枝不是免费的:它把“精确的因果历史”换成“带水位的近似”,所有比较者必须共享同一个水位语义。

常见错误与边界

  • 把 Lamport 时间戳当作并发检测器。两个事件时间戳不同不代表有因果关系;只有 L(A) < L(B) 且已知消息路径时,才能推出 A 可能先于 B。
  • 合并向量时只取本地较大的分量,却忘了先接收远程分量再递增。接收事件的计数器必须严格大于远程发送事件,否则接收事件与发送事件会被判成并发。
  • 直接删除向量里的小计数器来“节省空间”。缺失分量按 0 解释,会让偏序翻转;必须引入水位并拒绝水位高于任何现存计数器的剪枝。
  • 用向量时钟做业务合并策略。它只回答“是否并发”,不回答“并发时保留哪个值”;取消优先、支付优先、金额取大都是业务规则,必须单独写测试。

小结

Lamport 时钟解决“排序不能反因果”,向量时钟解决“判断是否并发”。前者是一个整数,后者是每个节点一个计数器;两者都不依赖墙上时钟,因此不受 NTP 回拨影响。代价是元数据随节点数增长,剪枝必须带水位契约,否则会把因果关系改坏。

下一篇把两者合起来:用 HLC 让时间戳接近物理时间、又保持因果单调,再把版本向量放进 arch-lab 的订单记录,检测同一订单的并发修改并拒绝陈旧写入。

阅读导航:上一节:物理时钟、NTP 与时钟漂移的可见后果 · 下一节:混合逻辑时钟与版本向量在 arch-lab 的落地 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查