《后端架构设计·分布式与数据一致性》2.3 混合逻辑时钟与版本向量在 arch-lab 的落地

两处同时修改同一订单,物理时间只差 200ms,该信谁?本文实现 HLC 的物理打底与逻辑计数规则,在墙上时钟回拨时仍保持单调;再用版本向量校验订单更新的 base 版本,把并发修改变成 ErrConflict,并给出事件流排序与冲突合并的边界。

两处同时修改同一订单,该信谁

订单 order-1 处于 INVENTORY_RESERVED。20:00:05.00,上海节点 A 修改收货地址;20:00:05.20,新加坡节点 B 发起取消。两台机器的墙上时钟相差 200ms,B 的时钟恰好慢 200ms,于是两个事件看起来都发生在 20:00:05.00。纯墙上时间排序无法区分;纯向量时钟能判定并发,却不能回答“事件流在审计里该排在什么位置”。

更隐蔽的是第三种情况:B 的时钟慢 3 秒,取消事件的墙上时间看起来早于地址修改,但取消在因果上可能已经看到了地址修改之后的状态。只用物理时间会把它排到前面;只用版本向量能判断是否并发,但事件流仍然需要一个接近人类时间、且不会因回拨而倒退的排序键。

HLC(hybrid logical clock,混合逻辑时钟)就是为这个空档设计的:物理时间打底,逻辑计数打破平局和覆盖回拨。它不替代版本向量;两者解决不同问题。站内 数据一致性 讲过业务不变量与一致性模型的选择,本节把两个工具放进 arch-lab 的订单写入路径。

先纠正两个常见直觉:

  • “HLC 时间戳大,就说明事件因果更后。”不成立。两个并发事件在同一毫秒可能得到完全相同的 HLC,必须靠节点 ID 或版本向量补充。
  • “版本向量能解决冲突。”它只检测并发,不决定保留哪个状态。取消优先还是支付优先,是业务规则,不是时钟规则。

HLC 的物理打底与逻辑计数

HLC 的时间戳是 (Wall, Logical) 二元组,按字典序比较。每次本地事件或接收事件时,先取三个值的最大值:上一次的 Wall、远程时间戳的 Wall、当前物理毫秒。然后按四种情况更新 Logical:

  1. 三者都等于上一次的 Wall 和远程 Wall:取两者 Logical 的较大值再加一。
  2. 只等于上一次的 Wall:上一次 Logical 加一。
  3. 只等于远程 Wall:远程 Logical 加一。
  4. 物理时间严格更大:Logical 归零。

这个规则保证两件事。第一,因果单调:如果事件 A 因果先于 B,那么 HLC(A) < HLC(B)。第二,墙上时钟回拨不会让时间戳倒退:回拨后 Wall 保持高水位,Logical 继续加一。注意 HLC 的 Wall 不再是“事件真实发生的物理时刻”,而是“本节点见过的最大的物理毫秒”,所以它只能用于近似排序和审计,不能用于计费时间。

实现里的 Clock 用互斥锁保护 last,可以安全并发调用。Now 与 Send 都是本地事件,Receive 合并远程时间戳。max 使用 Go 内建函数,不引入第三方依赖。

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

import (
	"fmt"
	"sync"
	"time"
)

// Timestamp is a hybrid logical clock reading: physical milliseconds plus a
// logical counter that breaks ties and covers clock regression.
type Timestamp struct {
	Wall    int64
	Logical uint32
}

// Compare returns -1, 0 or 1 in lexicographic (Wall, Logical) order.
func (t Timestamp) Compare(other Timestamp) int {
	switch {
	case t.Wall < other.Wall:
		return -1
	case t.Wall > other.Wall:
		return 1
	case t.Logical < other.Logical:
		return -1
	case t.Logical > other.Logical:
		return 1
	default:
		return 0
	}
}

// Before reports whether t is strictly older than other.
func (t Timestamp) Before(other Timestamp) bool { return t.Compare(other) < 0 }

// String renders a timestamp for logs.
func (t Timestamp) String() string { return fmt.Sprintf("%d.%d", t.Wall, t.Logical) }

// Clock is a hybrid logical clock. It never returns a timestamp smaller than
// one it has already returned, even when the wall clock moves backwards.
type Clock struct {
	mu   sync.Mutex
	last Timestamp
	wall func() time.Time
}

// NewClock builds a clock from a wall-time source. A nil source uses time.Now.
func NewClock(wall func() time.Time) *Clock {
	if wall == nil {
		wall = time.Now
	}
	return &Clock{wall: wall}
}

// Now records a local event and returns its timestamp.
func (c *Clock) Now() Timestamp { return c.observe(Timestamp{}) }

// Send records a send event. It is separate from Now only for readability at
// call sites.
func (c *Clock) Send() Timestamp { return c.Now() }

// Receive merges a remote timestamp and records the receive event.
func (c *Clock) Receive(remote Timestamp) Timestamp { return c.observe(remote) }

// Last returns the most recent timestamp without advancing the clock.
func (c *Clock) Last() Timestamp {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.last
}

func (c *Clock) observe(remote Timestamp) Timestamp {
	c.mu.Lock()
	defer c.mu.Unlock()

	now := c.wall().UnixMilli()
	wall := max(c.last.Wall, remote.Wall, now)

	var logical uint32
	switch {
	case wall == c.last.Wall && wall == remote.Wall:
		logical = max(c.last.Logical, remote.Logical) + 1
	case wall == c.last.Wall:
		logical = c.last.Logical + 1
	case wall == remote.Wall:
		logical = remote.Logical + 1
	default:
		logical = 0
	}
	c.last = Timestamp{Wall: wall, Logical: logical}
	return c.last
}

本机测试用可注入的墙上时钟验证三条性质:物理时间回拨后时间戳仍严格递增;接收事件排在发送事件之后;同一毫秒的并发事件可以得到相同时间戳。三组读数都来自 go test -v 的原始输出,不是估算。

=== RUN   TestClockMonotonicUnderWallRollback
    hlc_test.go:24: timestamps: 1000.0 1000.1 1000.2
=== RUN   TestReceivePreservesCausality
    hlc_test.go:46: sent=1000.0 received=1001.0 local=1001.1
=== RUN   TestSameWallConcurrentEventsTie
    hlc_test.go:62: node-a=1000.0 node-b=1000.0 equal=true
--- PASS   (all three)

第一行里物理时间先到 1000,再被拨回 900,但时间戳从 1000.0 走到 1000.2,没有倒退。第二行说明 B 接收 A 的 1000.0 后得到 1001.0,再看本地事件得到 1001.1。第三行是边界:两个节点在同一毫秒各产生一个并发事件,HLC 完全相等;此时需要节点 ID 做确定性兜底,但兜底顺序不代表因果。

版本向量检测同一订单的并发修改

HLC 负责“事件流怎么排”,版本向量负责“这次写入是否基于最新状态”。arch-lab 的订单记录保存一个 VersionVector,每次成功修改由执行节点把自己的计数器加一。写入方必须先读取当前状态和版本,提交时把 base 版本一并带上;Update 只有在 base 与当前版本完全相等时才允许状态机推进,否则返回 ErrConflict。

Compare 与向量时钟相同:逐分量偏序。不同点是这里的向量表示“记录的修改历史”,不是事件历史的全序。两个副本交换状态时,MergeViews 先比较版本:祖先关系直接取后代;并发关系则报告冲突,按业务规则合并。arch-lab 的规则是取消优先——支付可以退款,取消不能被静默覆盖。

// file: internal/platform/hlc/vv.go
// tier: L1
// verify: go test ./internal/platform/hlc/ -run TestMerge -count=1
package hlc

// VersionVector counts mutations per writer node.
type VersionVector map[string]uint64

func (v VersionVector) Clone() VersionVector {
	out := make(VersionVector, len(v))
	for node, counter := range v {
		out[node] = counter
	}
	return out
}

func (v VersionVector) Increment(node string) uint64 {
	v[node]++
	return v[node]
}

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

// Equal reports whether two vectors describe the same mutations.
func (v VersionVector) Equal(other VersionVector) bool {
	for node, counter := range v {
		if other[node] != counter {
			return false
		}
	}
	for node, counter := range other {
		if v[node] != counter {
			return false
		}
	}
	return true
}

// VersionRelation classifies two version vectors.
type VersionRelation int

const (
	VersionEqual VersionRelation = iota
	VersionBefore
	VersionAfter
	VersionConcurrent
)

// Compare classifies two vectors using the same component-wise order as a
// vector clock.
func (v VersionVector) Compare(other VersionVector) VersionRelation {
	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 VersionEqual
	case lessOrEqual:
		return VersionBefore
	case greaterOrEqual:
		return VersionAfter
	default:
		return VersionConcurrent
	}
}

订单记录的完整实验还包含状态机与 MergeViews。测试先让节点 A 用空 base 把订单推进到 INVENTORY_RESERVED,版本变成 {node-a:1};随后节点 B 仍用空 base 提交取消,被 ErrConflict 拒绝;B 改用新 base 提交支付成功,版本变成 {node-a:1,node-b:1}。最后尝试从 PAID 直接取消,返回 ErrInvalidState。另一组测试让两个并发副本分别支付与取消,合并报告 conflict=true,并按业务规则选择 CANCELLED。

$ go test ./internal/platform/hlc/ -run 'TestOrderUpdate|TestMerge' -v -count=1
    hlc_test.go:76: after reserve: version=map[node-a:1]
    hlc_test.go:86: after pay: version=map[node-a:1 node-b:1]
    hlc_test.go:106: conflict=true status=CANCELLED version=map[node-a:1 node-b:1]
    hlc_test.go:124: conflict=false status=INVENTORY_RESERVED version=map[node-a:1]
--- PASS

两条冲突路径要分开处理。base 不一致是陈旧写入,调用方应该重新读取状态、重新执行业务校验后重试;版本并发是真正的并发修改,重试不能解决,必须走业务合并或人工队列。把两者都映射成“重试”会把取消覆盖掉。

写入情况版本比较处理
base 等于当前版本VersionEqual允许状态机推进并递增计数器
base 是当前版本的祖先VersionBefore拒绝并重新读取,陈旧写入
当前版本是 base 的祖先VersionAfter说明调用方版本过新,拒绝
两边各有新分量VersionConcurrent报告冲突,走业务合并或人工处置

把两者放进订单写入路径

一次完整的订单修改按下面的顺序执行:

  1. 读取订单快照:status、version、当前 HLC 水位。
  2. 在领域层校验状态机是否允许 next;不允许立即返回 ErrInvalidState。
  3. 调用 hlc.Now() 生成事件时间戳,把事件写入 outbox 或事件表。
  4. 在同一事务里用 version 做条件更新,成功后把执行节点的计数器加一。
  5. 条件更新失败说明快照已过期,回到第 1 步;不要用新事件覆盖旧状态。

HLC 与版本向量不能互相替代:HLC 给事件流一个近似物理时间且因果单调的排序键;版本向量给记录一个精确的并发检测器。两者都不提供幂等性,重试仍需幂等键或去重表。下一章的单主复制会用提交位点解决“读己之写”,那是另一层机制。

常见错误与边界

  • 把 HLC 的 Wall 当真实事件时间写进账单。它是“见过的高水位”,在时钟超前的节点上可能远大于真实时间;计费时间应使用业务确认时间并单独存储。
  • 认为 HLC 能检测并发。同一毫秒的两个并发事件可以得到完全相同的 (Wall, Logical),必须依赖版本向量或节点 ID 补充。
  • 版本向量与订单状态分两个存储写入。条件更新和版本递增必须在同一个原子操作里完成,否则崩溃窗口会让版本与状态不一致。
  • 节点重建后把版本计数器清零。新节点 ID 或持久化计数器是必须的;否则旧版本会看起来比新版本更大,冲突检测失效。

小结

HLC 用物理毫秒打底、逻辑计数兜底,解决了“时间戳接近人类时间且不因回拨倒退”的问题,但它仍然不是因果检测器。版本向量逐分量记录修改历史,能判定同一订单的两次写入是祖先还是并发,并把并发暴露成 ErrConflict。把两者放进同一条写入路径,才能同时得到可审计的事件顺序和可靠的状态机保护。

下一篇进入复制:单主如何用复制日志、提交位点和应用位点保证副本顺序,以及“读己之写”为什么需要会话令牌而不是简单重试。

阅读导航:上一节:逻辑时钟与向量时钟:因果关系怎么编码 · 下一节:单主复制:复制日志、位点与读己之写 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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