同一笔订单在两个机房变成两个状态
订单 order-9 的归属单元是 sh-a,用户支付成功后订单状态从 CREATED 推进到 PAID。由于 DNS 缓存,支付回调被送到 sh-b;sh-b 为了“多活”,允许本地直接写订单,把状态改成了 REFUNDED 并开始退款。双向复制随后把这条记录同步回 sh-a,sh-a 上已经提交的 PAID 被覆盖。两边都认为自己写的是最新状态,最终对账才发现一笔已支付订单被退款。
错误直觉是“双向复制加最后写入获胜就能收敛”。收敛只保证两边最终看到同一个值,不保证这个值是对的。订单状态机是单向推进的,PAID → REFUNDED 不是一次普通的覆盖,而是资金操作;用时间戳选赢家,等于让网络延迟决定钱往哪走。
站内 多主复制与冲突 已经分析过最后写入获胜的丢数据问题,向量时钟 讲清了并发与因果的判定;本节只处理双向复制特有的三件事:回环、归属和冲突隔离。
回环:消息 ID 去重挡不住每一跳都换 ID 的转发
最简单的回环是 A 产生事件,复制到 B,B 又把它复制回 A。很多实现用消息 ID 去重:A 已经处理过这个 ID,第二次到达时直接丢弃。这在“消息 ID 全程不变”的链路里有效,但真实的复制中继通常会在每一跳生成新的消息 ID,用于追踪本地投递和重试。ID 变了,去重表就认不出来,事件会在 A、B 之间来回转发,直到跳数上限或队列积压把系统拖垮。
可靠的判定是记录事件的访问路径:事件从 A 出发时路径是 [A],B 应用后转发时路径变成 [A,B];A 再收到时发现路径里已经有自己,直接判定为回环并丢弃。路径长度随站点数增长,站点数量有限时可接受;如果站点很多,可以再加跳数上限和事件 TTL 作为兜底,但不要只靠 TTL,因为 TTL 到期前已经产生了无效流量。
回环检测和重复检测要分开计数。重复是正常的至少一次投递,回环说明拓扑或转发规则有问题,两者的告警级别不同。把回环计进重复,运维会以为只是消息队列重试,永远不会去查为什么事件被转回了源站。
归属:先决定谁能写,再谈怎么合并
双向复制不等于双向可写。每一笔订单必须有一个归属单元,只有归属单元能接受写请求并产生事件;非归属单元收到写请求时,应该返回“不是我的订单”,让调用方重新路由,而不是本地落库再等复制合并。归属规则把绝大多数冲突消灭在产生之前:同一笔订单在任一时刻只有一个写入者,版本自然单调。
归属信息本身也要有版本。故障转移时,如果两个单元同时认为自己被提升为归属,双方都会拒绝对方的事件,或者双方都接受本地写,系统会进入一种“各自都觉得自己对”的状态。这时需要归属 epoch 或租约,让提升成为一次有记录的条件更新;12.3 会把 epoch 放进切换状态机,本节只要求事件携带来源单元,接收方用当前归属表校验来源。
版本向量在这里的角色是兜底而不是主判据。归属正确时,同一订单的事件版本应该形成一条链,后一个版本支配前一个;如果出现两个互不支配的版本,说明归属元数据已经不一致,或者迁移期间短暂出现了两个写入者。此时正确动作是隔离并进入对账,而不是挑一个版本覆盖另一个。
三条不变量
实现围绕三条不变量:
- 任何改变订单状态的事件,其
Origin必须等于该订单当前的归属单元; - 事件的
Path不得包含接收站点,否则是回环; - 版本关系只允许三种结果:相等且内容相同视为重复,旧版本被支配则丢弃,互不支配则隔离为冲突。资金类字段不存在“赢家通吃”的合并规则。
这三条分别对应写路径、转发路径和存储路径。少任何一条,双向复制都会在故障或配置错误时退化成互相覆盖。
可运行实现
下面的副本实现在内存中模拟一个站点:归属表决定谁能写,LocalWrite 只允许归属站点调用,Receive 依次做回环、重复、归属、版本四类判定。成功应用后返回一个带新消息 ID 的中继事件,用来模拟每一跳重新编号的真实中继;接收方靠路径而不是消息 ID 识别回环。
// file: internal/platform/replication/bidirectional.go
// tier: L2
// verify: go test ./internal/platform/replication/ -count=1
package replication
import (
"errors"
"fmt"
)
// ErrNotOwner means an event was produced by a site that does not own the
// order. Such an event must never overwrite the owner's data.
var ErrNotOwner = errors.New("replication: writer is not the home site")
// SiteID identifies one data center.
type SiteID string
// Vector counts observed mutations per origin site.
type Vector map[SiteID]uint64
// Clone returns an independent copy.
func (v Vector) Clone() Vector {
out := make(Vector, len(v))
for site, n := range v {
out[site] = n
}
return out
}
// Get returns the counter for site. A missing site reads as zero.
func (v Vector) Get(site SiteID) uint64 {
return v[site]
}
// Dominates reports whether v has seen every mutation recorded in other.
func (v Vector) Dominates(other Vector) bool {
for site, n := range other {
if v[site] < n {
return false
}
}
return true
}
// Equal reports component-wise equality.
func (v Vector) Equal(other Vector) bool {
return v.Dominates(other) && other.Dominates(v)
}
// Concurrent reports whether neither vector dominates the other.
func Concurrent(a, b Vector) bool {
return !a.Dominates(b) && !b.Dominates(a)
}
// Merge returns the component-wise maximum.
func Merge(a, b Vector) Vector {
out := a.Clone()
for site, n := range b {
if n > out[site] {
out[site] = n
}
}
return out
}
// Event is one replication message. Path records every site that already
// applied it so a relayed event cannot loop forever.
type Event struct {
ID string
OrderID string
Origin SiteID
Path []SiteID
Version Vector
Payload string
}
// Kind classifies the outcome of Receive.
type Kind string
const (
KindApplied Kind = "applied"
KindDuplicate Kind = "duplicate"
KindLoop Kind = "loop"
KindStale Kind = "stale"
KindConflict Kind = "conflict"
KindNotOwner Kind = "not_owner"
)
// Record is the stored state of one order.
type Record struct {
Payload string
Version Vector
}
// Replica is one site's copy of the order data plus its ownership view.
type Replica struct {
Site SiteID
home map[string]SiteID
data map[string]Record
seen map[string]struct{}
Applied int
Duplicate int
Loop int
Stale int
Conflict int
NotOwner int
}
// New returns an empty replica. home must map every known order to one site.
func New(site SiteID, home map[string]SiteID) (*Replica, error) {
if site == "" {
return nil, fmt.Errorf("replication: empty site id")
}
for order, owner := range home {
if order == "" || owner == "" {
return nil, fmt.Errorf("replication: invalid ownership entry %q -> %q", order, owner)
}
}
r := &Replica{
Site: site,
home: make(map[string]SiteID, len(home)),
data: make(map[string]Record),
seen: make(map[string]struct{}),
}
for order, owner := range home {
r.home[order] = owner
}
return r, nil
}
// LocalWrite accepts a write only at the order's home site and returns the
// event to replicate. The local copy is updated before the caller sends it.
func (r *Replica) LocalWrite(orderID, payload string) (Event, error) {
if r.home[orderID] != r.Site {
return Event{}, fmt.Errorf("replication: site %s writing order %s owned by %s: %w",
r.Site, orderID, r.home[orderID], ErrNotOwner)
}
cur := r.data[orderID]
version := cur.Version.Clone()
if version == nil {
version = Vector{}
}
version[r.Site]++
id := fmt.Sprintf("%s-%s-%d", orderID, r.Site, version[r.Site])
event := Event{
ID: id,
OrderID: orderID,
Origin: r.Site,
Path: []SiteID{r.Site},
Version: version.Clone(),
Payload: payload,
}
r.data[orderID] = Record{Payload: payload, Version: version}
r.seen[id] = struct{}{}
r.Applied++
return event, nil
}
// Receive applies an event from a peer. It never applies data from a
// non-owner, a stale version, or a path that already visited this site. A
// successfully applied event yields a relay event with this site appended to
// its path and a new message id, mirroring per-hop message ids in real relays.
func (r *Replica) Receive(event Event) (Kind, *Event, error) {
if event.ID == "" || event.OrderID == "" || event.Origin == "" {
return "", nil, fmt.Errorf("replication: incomplete event %+v", event)
}
for _, site := range event.Path {
if site == r.Site {
r.Loop++
return KindLoop, nil, nil
}
}
if _, ok := r.seen[event.ID]; ok {
r.Duplicate++
return KindDuplicate, nil, nil
}
if r.home[event.OrderID] != event.Origin {
r.NotOwner++
return KindNotOwner, nil, nil
}
cur, exists := r.data[event.OrderID]
if exists {
switch {
case event.Version.Equal(cur.Version):
if event.Payload == cur.Payload {
r.Duplicate++
return KindDuplicate, nil, nil
}
r.Conflict++
return KindConflict, nil, nil
case cur.Version.Dominates(event.Version):
r.Stale++
return KindStale, nil, nil
case !event.Version.Dominates(cur.Version):
r.Conflict++
return KindConflict, nil, nil
}
}
r.data[event.OrderID] = Record{Payload: event.Payload, Version: event.Version.Clone()}
r.seen[event.ID] = struct{}{}
r.Applied++
path := append(append([]SiteID(nil), event.Path...), r.Site)
relay := Event{
ID: event.ID + "@" + string(r.Site),
OrderID: event.OrderID,
Origin: event.Origin,
Path: path,
Version: event.Version.Clone(),
Payload: event.Payload,
}
return KindApplied, &relay, nil
}
// Lookup returns the stored record for an order.
func (r *Replica) Lookup(orderID string) (Record, bool) {
record, ok := r.data[orderID]
return record, ok
}
实现里有两个取舍值得说明。第一,LocalWrite 直接更新本地副本并写入 seen,因为归属站点不需要再校验自己的事件;如果把它也送进 Receive,路径检查会把本地写误判成回环。第二,版本冲突不改变任何数据,只增加计数并返回 KindConflict;冲突内容需要进入隔离表或对账任务,不能让复制层临场决定业务语义。
本机运行结果
测试覆盖五种接收结果和一张版本关系表:A→B 正常应用、B 转发回 A 被路径拦截、同一事件重复投递、非归属站点伪造事件、陈旧版本不覆盖新值、并发版本被隔离。命令与原始输出:
$ go test ./internal/platform/replication/ -v -count=1
=== RUN TestBidirectionalLoopSuppressed
bidirectional_test.go:41: loop_suppressed a_applied=1 b_applied=1 a_loop=1 payload=PAID
=== RUN TestDuplicateDeliveryIsIdempotent
bidirectional_test.go:64: duplicate_delivery b_applied=1 b_duplicate=1
=== RUN TestNonOwnerWriteRejected
bidirectional_test.go:89: non_owner local_write_rejected=true remote_event=not_owner
=== RUN TestStaleEventIgnored
bidirectional_test.go:119: stale_ignored payload=FULFILLED stale_count=1
=== RUN TestConcurrentVersionsDetected
bidirectional_test.go:163: concurrent_detected merged=map[site-a:2 site-b:1] conflict_kept=FULFILLED
=== RUN TestVectorDominanceTable
bidirectional_test.go:193: vectors a=map[s1:2] b=map[s1:1] relation=a>b
bidirectional_test.go:193: vectors a=map[s1:2] b=map[s1:2] relation=equal
bidirectional_test.go:193: vectors a=map[s1:2] b=map[s2:1] relation=concurrent
bidirectional_test.go:193: vectors a=map[s1:1] b=map[s2:1] relation=concurrent
PASS
ok archlab/internal/platform/replication 0.258s
回环测试里,A 本地写一次,B 应用一次,B 生成的新消息 ID 带着路径 [A,B] 回到 A,A 靠路径判定回环,计数为 1,订单内容仍是 PAID;如果只靠消息 ID,这个新 ID 会被当成新事件再次应用。重复投递测试中,B 的第二次接收被去重,应用次数保持 1。非归属测试同时验证了两条路径:B 本地写被 ErrNotOwner 拒绝,伪造来源为 B 的远程事件被标记为 not_owner。
陈旧事件测试先应用版本 {A:1} 和 {A:2},再送一次旧的 {A:1},接收方判定陈旧并保留 FULFILLED。并发测试构造 {A:2} 与 {A:1,B:1} 两个互不支配的版本,接收方返回冲突且不覆盖,合并后的向量是 {site-a:2, site-b:1}。这些结果来自单进程内存模型,不涉及真实网络、时钟或跨机房复制,真实环境下的重复与乱序需要另行演练。
冲突处置决策表
| 判定 | 触发条件 | 处置 | 退出条件 |
|---|---|---|---|
| duplicate | 消息 ID 已见,或版本与内容都相同 | 直接丢弃,计数 | 计数稳定,无重复副作用 |
| loop | 事件路径包含当前站点 | 丢弃并告警 | 路径配置修复,回环计数归零 |
| not_owner | 事件来源不是当前归属单元 | 拒绝写入,返回重路由 | 归属表一致,来源与归属匹配 |
| stale | 当前版本支配事件版本 | 丢弃旧事件 | 版本单调推进 |
| conflict | 两个版本互不支配 | 隔离记录,进入对账,禁止自动覆盖 | 人工或对账任务给出唯一结论 |
这张表的核心是最后一行:版本向量负责“发现”冲突,不负责“裁决”冲突。支付、退款、库存扣减这类操作必须由业务规则或人工对账决定谁有效;把裁决交给复制层,等于把资金正确性交给一个不理解订单状态机的组件。
常见错误与边界
第一,只用消息 ID 去重。中继每跳换 ID 时,回环事件会被当成新消息反复应用;必须同时记录路径或跳数,二者不能互相替代。
第二,允许双向都可写。归属单元唯一是消灭冲突的前提;两边都能本地写,最后写入获胜会在支付状态上造成不可逆回退。
第三,把版本向量的合并结果直接落库。合并只给出“见过哪些变更”,不给出“哪个业务状态正确”;并发版本必须隔离,不能静默选一个。
第四,归属元数据没有版本。两个单元同时被提升时,双方都拒绝对方事件,系统看起来“没有冲突”,实际已经分裂;归属转移需要 epoch 或共识,并且要在演练中验证旧归属会被拒绝。
小结
双向复制先解决的是“谁可以写”和“事件会不会转回来”,然后才是“版本冲突怎么判”。归属把写路径收敛到单一单元,路径检查阻断逐跳换 ID 的回环,版本向量在归属元数据自身出错时提供最后的检测手段。三者都只保证不静默覆盖,不保证业务上自动正确;资金类冲突必须进入隔离和对账。
数据同步解决后,下一个问题是如何在故障发生时安全地把归属从一个单元转移到另一个单元,以及转移需要多长时间、会丢多少已确认的写。下一节把 RTO、RPO、切换状态机和演练脚本放在一起。
阅读导航:上一节:多活拓扑:单元化、双活与就近接入 · 下一节:容灾切换:RTO/RPO、演练与回切 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。