《后端架构设计·分布式与数据一致性》3.2 多主与无主复制:冲突检测与合并

用两地同时修改同一订单收货地址的场景,说明多主写入的冲突来源与最后写入获胜的丢更新问题;实现版本向量并发检测、PN-Counter 与 add-wins OR-Set 合并,并用枚举验证 R+W>N 的读写重叠条件,说明为何它只是必要条件而非强一致证明。

两地同时改地址,谁赢

用户在上海把订单收货地址改到浦东,几乎同时,客服在成都把同一订单改到徐汇。两个区域都是主库,各自本地提交成功,随后异步互相同步。最终用户看到浦东,客服看到徐汇,下一次同步后只剩一个版本,另一个人的修改无声消失。

多主复制的第一个误区是“只要时间戳大的赢就正确”。时间戳只说明哪台机器的时钟更靠前,不说明哪次修改在因果上更晚;跨区域的时钟偏移、NTP 回拨都能让后发生的修改带上更小的值。第二个误区是“冲突很少,人工处理即可”,但冲突量随写入 QPS 和复制延迟线性增长,人工队列很快会变成新的写入瓶颈。

这一节把冲突处理拆成两件事:先检测两次修改是否并发,再决定合并规则。检测靠版本向量,合并靠可交换、可结合、幂等的结构;两者不能互相替代。

冲突从哪里来

来源例子是否可避免
多主同时写上海与成都都改地址无法避免,只能检测
离线写入骑手端断网后补交签收无法避免
客户端重试支付回调超时后重复提交可用幂等键消除
双写迁移新旧库同时接受订单更新迁移期必然出现
删除与更新竞争一边取消订单,一边修改地址需要 tombstone 规则

版本向量:并发不是时间先后

每个副本维护一个 map[replica]counter。本地写之前把自己的计数加一;收到远端状态时逐项取最大值。比较两个向量:所有分量都不大于且至少一个更小,就是因果先后;两个方向各有更大的分量,就是并发。

订单地址的两个版本 {sh:2, cd:1} 与 {sh:1, cd:2} 互相都不包含,检测结果为 Concurrent。这时不能悄悄选一个,必须交给合并规则,或把两个候选返回给用户确认。版本向量不能告诉你“哪个更好”,只能告诉你“需不需要决定”。

最后写入获胜的陷阱

LWW(last-write-wins)实现简单:比较时间戳,大的覆盖小的,相等时用节点 ID 打破平局。它能保证所有副本最终收敛到同一个值,但收敛不等于正确。

第一,丢更新。上海的真实修改发生在 10:00:05,时钟慢 2 秒;成都的修改发生在 10:00:04,时钟准。LWW 选上海,看似正确;反过来就会丢掉真实更晚的修改。没有可信时钟时,LWW 的“最后”只是“时间戳最大”。

第二,删除被更新覆盖。订单地址被删除(或置空)后,一个更早时间戳的更新仍可能复活它,除非删除也作为带时间戳的 tombstone 参与比较。

CRDT:把合并规则写进数据结构

CRDT 的思路不是“选一个赢家”,而是让合并本身可交换、可结合、幂等,任意顺序、任意次数合并都得到同一结果。

结构合并规则订单场景代价
G-Counter每个节点只增,逐项取最大累计下单次数不能减
PN-Counter增量与减量分开逐项取最大库存预占与释放的净额需要两个向量
OR-Setadd 带唯一 tag,remove 只删已见 tag订单权益标签、赠品元数据随 tag 增长
LWW-Register时间戳大者胜心跳位置等可覆盖字段会丢并发更新

PN-Counter 用于“预占 + 释放”很自然:每个区域只记录自己增加和减少了多少,合并取每个节点的最大值,净额等于所有增量减所有减量。它不保证净额不为负,业务不变量仍要在下单路径检查。

OR-Set 的 add-wins 语义适合权益:上海给订单加“赠品包装”,成都同时删除“赠品包装”;删除方只见过自己副本上的 tag,合并后上海的 tag 仍在,权益保留。若删除方已经观察到两个 tag,再删除就会把两个都 tombstone 掉。这个“观察到才删”的规则,比一个全局删除标记更接近业务语义。

R+W>N 不是口号

无主复制把同一份数据放在 N 个副本,写要求 W 个确认,读要求 R 个响应。只要 R+W>N,任意写集合与任意读集合必然相交,读至少能碰到一个持有新值的副本。反过来,R+W<=N 时一定存在不相交的读写集合,读可能完全错过新值。

NRWR+W>N结果
322是读写必重叠
312否读可能打到旧副本
533是可容忍两个副本落后
523否存在不相交组合

代码:检测与合并

下面的包实现版本向量比较、LWW 合并、PN-Counter、add-wins OR-Set 和仲裁判定。所有合并函数都不依赖遍历顺序。

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

import "fmt"

type Order int

const (
	Equal Order = iota
	Before
	After
	Concurrent
)

type VersionVector map[string]uint64

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

// Merge returns the component-wise maximum of two vectors.
func (v VersionVector) Merge(other VersionVector) VersionVector {
	out := make(VersionVector, len(v)+len(other))
	for k, val := range v {
		out[k] = val
	}
	for k, val := range other {
		if val > out[k] {
			out[k] = val
		}
	}
	return out
}

// Compare tells concurrent updates apart from causally ordered ones.
func (v VersionVector) Compare(other VersionVector) Order {
	less, greater := false, false
	for k, val := range v {
		if oval := other[k]; val < oval {
			less = true
		} else if val > oval {
			greater = true
		}
	}
	for k, oval := range other {
		if _, ok := v[k]; !ok && oval > 0 {
			less = true
		}
	}
	switch {
	case less && greater:
		return Concurrent
	case less:
		return Before
	case greater:
		return After
	default:
		return Equal
	}
}

type LWWRegister struct {
	Value     string
	Timestamp int64
	Node      string
}

// MergeLWW keeps the greater timestamp; equal timestamps use the node ID.
func MergeLWW(a, b LWWRegister) LWWRegister {
	if a.Timestamp != b.Timestamp {
		if a.Timestamp > b.Timestamp {
			return a
		}
		return b
	}
	if a.Node >= b.Node {
		return a
	}
	return b
}

// PNCounter keeps per-node increments and decrements. Merge takes the maximum.
type PNCounter struct {
	inc map[string]int64
	dec map[string]int64
}

func NewPNCounter() *PNCounter {
	return &PNCounter{inc: map[string]int64{}, dec: map[string]int64{}}
}

func (c *PNCounter) Add(node string, delta int64) {
	if delta >= 0 {
		c.inc[node] += delta
		return
	}
	c.dec[node] -= delta
}

func (c *PNCounter) Merge(other *PNCounter) {
	for node, val := range other.inc {
		if val > c.inc[node] {
			c.inc[node] = val
		}
	}
	for node, val := range other.dec {
		if val > c.dec[node] {
			c.dec[node] = val
		}
	}
}

func (c *PNCounter) Value() int64 {
	var total int64
	for _, val := range c.inc {
		total += val
	}
	for _, val := range c.dec {
		total -= val
	}
	return total
}

// ORSet is add-wins: a remove only tombstones tags it has observed.
type ORSet struct {
	adds    map[string]map[string]struct{}
	removed map[string]map[string]struct{}
	seq     int
}

func NewORSet() *ORSet {
	return &ORSet{adds: map[string]map[string]struct{}{}, removed: map[string]map[string]struct{}{}}
}

func (s *ORSet) Add(node, value string) string {
	s.seq++
	tag := fmt.Sprintf("%s:%d", node, s.seq)
	if s.adds[value] == nil {
		s.adds[value] = map[string]struct{}{}
	}
	s.adds[value][tag] = struct{}{}
	return tag
}

func (s *ORSet) Remove(value string) {
	if s.removed[value] == nil {
		s.removed[value] = map[string]struct{}{}
	}
	for tag := range s.adds[value] {
		s.removed[value][tag] = struct{}{}
	}
}

func (s *ORSet) Contains(value string) bool {
	for tag := range s.adds[value] {
		if _, gone := s.removed[value][tag]; !gone {
			return true
		}
	}
	return false
}

func (s *ORSet) Merge(other *ORSet) {
	for value, tags := range other.adds {
		if s.adds[value] == nil {
			s.adds[value] = map[string]struct{}{}
		}
		for tag := range tags {
			s.adds[value][tag] = struct{}{}
		}
	}
	for value, tags := range other.removed {
		if s.removed[value] == nil {
			s.removed[value] = map[string]struct{}{}
		}
		for tag := range tags {
			s.removed[value][tag] = struct{}{}
		}
	}
	if other.seq > s.seq {
		s.seq = other.seq
	}
}

// QuorumsOverlap is true exactly when R+W>N.
func QuorumsOverlap(n, r, w int) bool {
	if n < 1 || r < 1 || w < 1 || r > n || w > n {
		return false
	}
	return r+w > n
}

测试覆盖四个判据:并发检测、LWW 的交换性、计数器的幂等合并、OR-Set 的 add-wins。

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

import "testing"

func TestConflictDetection(t *testing.T) {
	tests := []struct {
		name string
		a, b VersionVector
		want Order
	}{
		{"equal", VersionVector{"a": 1}, VersionVector{"a": 1}, Equal},
		{"before", VersionVector{"a": 1}, VersionVector{"a": 2}, Before},
		{"concurrent", VersionVector{"a": 2, "b": 1}, VersionVector{"a": 1, "b": 2}, Concurrent},
		{"missing key", VersionVector{"a": 1}, VersionVector{"a": 1, "b": 1}, Before},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := tt.a.Compare(tt.b); got != tt.want {
				t.Fatalf("Compare = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestLWWPicksTimestamp(t *testing.T) {
	newer := LWWRegister{Value: "上海市浦东新区世纪大道 100 号", Timestamp: 1000, Node: "b"}
	older := LWWRegister{Value: "上海市徐汇区漕溪北路 88 号", Timestamp: 900, Node: "a"}
	if got := MergeLWW(older, newer); got != newer {
		t.Fatalf("MergeLWW = %+v, want %+v", got, newer)
	}
	if MergeLWW(newer, older) != MergeLWW(older, newer) {
		t.Fatalf("MergeLWW is not commutative")
	}
}

func TestCRDTMerge(t *testing.T) {
	a, b := NewPNCounter(), NewPNCounter()
	a.Add("n1", 3)
	b.Add("n2", 4)
	b.Add("n2", -1)
	a.Merge(b)
	if got := a.Value(); got != 6 {
		t.Fatalf("PNCounter = %d, want 6", got)
	}
	before := a.Value()
	a.Merge(b)
	if a.Value() != before {
		t.Fatalf("PNCounter merge is not idempotent")
	}

	x, y := NewORSet(), NewORSet()
	x.Add("n1", "gift-wrap")
	y.Add("n2", "gift-wrap")
	x.Remove("gift-wrap")
	x.Merge(y)
	if !x.Contains("gift-wrap") {
		t.Fatalf("concurrent add should survive a remove")
	}
}

func TestQuorumOverlap(t *testing.T) {
	for _, tt := range []struct {
		n, r, w int
		want    bool
	}{
		{3, 2, 2, true},
		{3, 1, 2, false},
		{5, 3, 3, true},
		{5, 2, 3, false},
	} {
		if got := QuorumsOverlap(tt.n, tt.r, tt.w); got != tt.want {
			t.Fatalf("QuorumsOverlap(%d,%d,%d) = %v, want %v", tt.n, tt.r, tt.w, got, tt.want)
		}
	}
}

本机验证模块的真实输出:

$ go test ./article/multileader/ -count=1
--- PASS: TestConflictDetection (0.00s)
--- PASS: TestLWWPicksTimestamp (0.00s)
--- PASS: TestCRDTMerge (0.00s)
--- PASS: TestQuorumOverlap (0.00s)
PASS
ok  	archlab/article/multileader	0.261s

TestCRDTMerge 里 PN-Counter 的结果是 6(3 加 4 再减 1),第二次合并值不变;OR-Set 在本地删除后与远端合并,仍保留 gift-wrap,说明 add-wins 生效。这些是逻辑结果,不是吞吐指标。

决策表:先问能不能避免冲突

场景冲突概率建议退出条件
同城双主写订单地址中改为单主归属,或版本向量 + 人工/规则合并归属单元确定
离线设备补数据高CRDT 计数/集合,按操作合并合并幂等测试通过
支付金额高禁止多主,走单主 + 条件更新账务对平

常见错误与边界

第一,拿服务器时间当因果顺序。时钟偏移和回拨会让 LWW 选错,除非使用混合逻辑时钟并保留节点 ID 作为平局规则。

第二,版本向量只增不减,元数据无限膨胀。实际系统要按归属裁剪副本集合,并为旧条目设置清理策略。

第三,以为 CRDT 不需要业务约束。PN-Counter 的净额可能为负,OR-Set 的权益可能重复发放;不变量检查仍在应用层。

第四,把 R+W>N 当成强一致。它只保证读写集合相交,读仍要选版本、修复旧副本,写仍可能因超时返回不确定结果。

边界在于:多主适合“冲突可合并、业务能接受短暂多版本”的数据;订单金额、库存扣减这类需要唯一裁决的操作,仍应回到单主或共识路径,可参考Quorum 复制协议 。

小结

多主与无主复制把“谁先写”换成了“两个版本是否并发”。版本向量负责检测,CRDT 负责让合并可交换、可结合、幂等;LWW 只是其中一种会丢更新的合并规则。R+W>N 给出读写必然重叠的算术条件,但重叠之后的选版本与修复仍要自己实现。下一节回到单主复制的时间维度:复制延迟如何变成故障切换时的数据丢失窗口,以及这个窗口能否被测量和收窄。

阅读导航:上一节:单主复制:复制日志、位点与读己之写 · 下一节:复制延迟、故障切换与数据丢失窗口 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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