两处同时修改同一订单,该信谁
订单 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:
- 三者都等于上一次的
Wall和远程Wall:取两者Logical的较大值再加一。 - 只等于上一次的
Wall:上一次Logical加一。 - 只等于远程
Wall:远程Logical加一。 - 物理时间严格更大:
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 | 报告冲突,走业务合并或人工处置 |
把两者放进订单写入路径
一次完整的订单修改按下面的顺序执行:
- 读取订单快照:
status、version、当前 HLC 水位。 - 在领域层校验状态机是否允许
next;不允许立即返回ErrInvalidState。 - 调用
hlc.Now()生成事件时间戳,把事件写入 outbox 或事件表。 - 在同一事务里用
version做条件更新,成功后把执行节点的计数器加一。 - 条件更新失败说明快照已过期,回到第 1 步;不要用新事件覆盖旧状态。
HLC 与版本向量不能互相替代:HLC 给事件流一个近似物理时间且因果单调的排序键;版本向量给记录一个精确的并发检测器。两者都不提供幂等性,重试仍需幂等键或去重表。下一章的单主复制会用提交位点解决“读己之写”,那是另一层机制。
常见错误与边界
- 把 HLC 的
Wall当真实事件时间写进账单。它是“见过的高水位”,在时钟超前的节点上可能远大于真实时间;计费时间应使用业务确认时间并单独存储。 - 认为 HLC 能检测并发。同一毫秒的两个并发事件可以得到完全相同的
(Wall, Logical),必须依赖版本向量或节点 ID 补充。 - 版本向量与订单状态分两个存储写入。条件更新和版本递增必须在同一个原子操作里完成,否则崩溃窗口会让版本与状态不一致。
- 节点重建后把版本计数器清零。新节点 ID 或持久化计数器是必须的;否则旧版本会看起来比新版本更大,冲突检测失效。
小结
HLC 用物理毫秒打底、逻辑计数兜底,解决了“时间戳接近人类时间且不因回拨倒退”的问题,但它仍然不是因果检测器。版本向量逐分量记录修改历史,能判定同一订单的两次写入是祖先还是并发,并把并发暴露成 ErrConflict。把两者放进同一条写入路径,才能同时得到可审计的事件顺序和可靠的状态机保护。
下一篇进入复制:单主如何用复制日志、提交位点和应用位点保证副本顺序,以及“读己之写”为什么需要会话令牌而不是简单重试。
阅读导航:上一节:逻辑时钟与向量时钟:因果关系怎么编码 · 下一节:单主复制:复制日志、位点与读己之写 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。