两地同时改地址,谁赢
用户在上海把订单收货地址改到浦东,几乎同时,客服在成都把同一订单改到徐汇。两个区域都是主库,各自本地提交成功,随后异步互相同步。最终用户看到浦东,客服看到徐汇,下一次同步后只剩一个版本,另一个人的修改无声消失。
多主复制的第一个误区是“只要时间戳大的赢就正确”。时间戳只说明哪台机器的时钟更靠前,不说明哪次修改在因果上更晚;跨区域的时钟偏移、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-Set | add 带唯一 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 时一定存在不相交的读写集合,读可能完全错过新值。
| N | R | W | R+W>N | 结果 |
|---|---|---|---|---|
| 3 | 2 | 2 | 是 | 读写必重叠 |
| 3 | 1 | 2 | 否 | 读可能打到旧副本 |
| 5 | 3 | 3 | 是 | 可容忍两个副本落后 |
| 5 | 2 | 3 | 否 | 存在不相交组合 |
代码:检测与合并
下面的包实现版本向量比较、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 给出读写必然重叠的算术条件,但重叠之后的选版本与修复仍要自己实现。下一节回到单主复制的时间维度:复制延迟如何变成故障切换时的数据丢失窗口,以及这个窗口能否被测量和收窄。
阅读导航:上一节:单主复制:复制日志、位点与读己之写 · 下一节:复制延迟、故障切换与数据丢失窗口 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。