并发取消与支付回调被排成了先后
订单 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 时钟的规则只有两条:
- 本地发生事件时,计数器加一。
- 收到一个带计数器
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 的落地 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。