土法炼钢兴趣小组的算法知识备份

【分布式系统百科】多主复制:冲突检测与解决策略的深水区

文章导航

分类入口
distributed
标签入口
#multi-leader#conflict-resolution#vector-clocks#crdt#replication

目录

本文是分布式系统百科系列 Part IV:复制 的第二篇。上一篇 主从复制 讨论了同步、异步与半同步的工程权衡。主从复制有一个根本限制:只有一个 Leader 能接受写入。本篇把 Leader 的数量从一个扩展到多个,看看”多个写入点”带来的好处和代价。

你的公司在北京、法兰克福、弗吉尼亚各有一个数据中心。用户在法兰克福提交了一笔订单,写请求跨越大西洋到弗吉尼亚的 Leader,RTT 约 80ms;再加上主从复制的确认延迟,用户感知到的写入延迟轻松超过 200ms。

你可以在法兰克福也放一个 Leader。用户写本地 Leader,延迟降到个位数毫秒。然后两个 Leader 之间异步交换数据——这就是多主复制(Multi-Leader Replication)。

听起来很美好。但当两个用户在不同的 Leader 上同时修改了同一行数据,你就掉进了本文标题说的”深水区”:冲突检测与解决

一、为什么需要多主复制

1.1 单主复制的三个瓶颈

上一篇文章详细拆解了主从复制的工程实现。它的模型简洁、语义清晰、工程成熟。但三个场景下,单主复制力不从心:

瓶颈 根因 影响
跨数据中心写入延迟 所有写请求必须路由到唯一 Leader 所在的数据中心 远离 Leader 的用户写入延迟高
写入吞吐上限 单个 Leader 的 CPU、磁盘 I/O、网络带宽是硬上限 无法通过加从节点提升写入性能
Leader 单点故障 Leader 挂了需要故障转移(Failover) 转移期间写入不可用;脑裂风险

多主复制(Multi-Leader Replication)的核心想法是:让多个节点同时扮演 Leader 角色,每个都能独立接受写入,然后通过异步复制互相交换数据

1.2 三大使用场景

场景一:多数据中心部署。 每个数据中心有一个 Leader。用户写本地 Leader,写入延迟降到本地网络级别。Leader 之间通过跨数据中心链路异步复制。如果某个数据中心整体离线,其他数据中心的 Leader 继续工作,恢复后追上差异即可。这是多主复制最经典的场景——MySQL Group Replication 的多主模式、TiDB 跨数据中心部署、CockroachDB 的多区域配置都有这类应用。

场景二:协同编辑。 Google Docs、Figma、Notion 这类多人实时协作产品。每个用户的本地编辑器就是一个”Leader”——你在本地立即看到自己的修改,然后和其他用户的修改异步合并。这本质上是一个多主复制问题,只不过”Leader”的粒度从数据中心缩小到了单个客户端。

场景三:离线客户端。 手机上的笔记应用、日历、任务管理。设备离线时在本地数据库读写(本地就是 Leader);上线后与服务端同步。如果你在飞机上修改了一条笔记,同事在办公室也修改了同一条,两台设备上线后就需要做冲突解决。CouchDB/PouchDB 就是为这个场景设计的——后面会专门分析。

三个场景的共同特征:多个写入点、异步复制、冲突不可避免

二、多主复制架构

2.1 与单主复制的核心区别

单主复制的写入路径是线性的:客户端 → Leader → 复制到 Follower → 确认。因为只有一个写入点,不会产生写冲突。

多主复制的写入路径是并行的:

客户端A → Leader-Beijing → 异步复制 → Leader-Frankfurt
客户端B → Leader-Frankfurt → 异步复制 → Leader-Beijing

每个 Leader 既是自己数据中心的写入点,又是其他 Leader 的 Follower。这意味着:

  1. 每个 Leader 维护自己的写入日志。
  2. 每个 Leader 异步接收并应用其他 Leader 的写入日志。
  3. 最终所有 Leader 的数据应该收敛到一致状态。

“最终一致”这四个字看似轻描淡写,但要做到它,需要解决一系列棘手的问题——首当其冲就是冲突。

2.2 数据流模型

多主复制中,每个 Leader 需要把自己的写入操作传播给其他所有 Leader。传播方式由复制拓扑(Replication Topology)决定,我们在第六节详细讨论。这里先建立一个基本模型:

Leader-A 的写入操作:
  1. 写入本地存储
  2. 写入本地变更日志(changelog)
  3. 将变更日志推送/拉取给其他 Leader
  4. 其他 Leader 接收后应用到本地存储

冲突发生在第4步:Leader-B 收到 Leader-A 的变更,发现自己已经对同一条数据做了不同的修改。

关键区别:在单主复制中,冲突在写入时就被串行化了(所有写都经过同一个 Leader)。在多主复制中,冲突在复制时才被发现——而且是异步发现的,写入早已成功返回给客户端。

这意味着你不能简单地拒绝冲突写入(用户已经收到了”写入成功”的响应),你必须在事后解决冲突。

三、冲突的类型与触发条件

不是所有的并发写入都会产生冲突。冲突发生在两个(或多个)Leader 在尚未互相同步的窗口期内,对同一份数据做了不兼容的修改。

3.1 写-写冲突(Write-Write Conflict)

最常见的冲突类型。两个 Leader 并发修改了同一行数据的同一个字段。

-- Leader-Beijing,时间 T1
UPDATE users SET name = '张三丰' WHERE id = 42;

-- Leader-Frankfurt,时间 T1(并发)
UPDATE users SET name = 'Zhang Sanfeng' WHERE id = 42;

两个操作都成功了。当两个 Leader 互相同步时,发现 id=42name 字段有两个不同的值。这就是写-写冲突。

3.2 插入冲突(Insert Conflict)

两个 Leader 并发插入了主键或唯一索引相同的记录。

-- Leader-Beijing
INSERT INTO users (id, name) VALUES (100, '李四');

-- Leader-Frankfurt(并发)
INSERT INTO users (id, name) VALUES (100, 'Li Si');

如果 id 是自增主键且两个 Leader 独立分配 ID,就很容易撞号。常见解法是用 UUID 替代自增 ID,或者给每个 Leader 分配不同的 ID 段(奇偶分段、范围分段)。但这只是避免了主键层面的冲突,业务层面的唯一约束(如邮箱、手机号)仍然可能冲突。

3.3 删除-更新冲突(Delete-Update Conflict)

一个 Leader 删除了一行,另一个 Leader 更新了同一行。

-- Leader-Beijing
DELETE FROM users WHERE id = 42;

-- Leader-Frankfurt(并发)
UPDATE users SET email = 'new@example.com' WHERE id = 42;

Beijing 说”这条数据不存在了”,Frankfurt 说”这条数据的 email 变了”。怎么合并?如果以删除为准,Frankfurt 的更新就丢了。如果以更新为准,Beijing 的删除意图被忽略了。

实际系统常用墓碑标记(Tombstone)来处理:删除不是真删除,而是打上”已删除”标记,同步时其他 Leader 看到墓碑就知道该行被删除了,然后根据策略决定最终结果。

3.4 一个完整的冲突场景

假设一个在线文档协作系统,用户 Alice 和 Bob 同时编辑同一份文档的标题:

时间线:
T0: 文档标题 = "设计文档 v1",所有 Leader 一致

T1: Alice 在 Leader-A 修改标题为 "设计文档 v2 - 最终版"
T1: Bob 在 Leader-B 修改标题为 "设计文档 v2 - 评审中"
    (两个写入并发发生,Leader 之间尚未同步)

T2: Leader-A 将 Alice 的修改发送给 Leader-B
T2: Leader-B 将 Bob 的修改发送给 Leader-A
    (两个 Leader 各自收到对方的修改,发现冲突)

T3: 冲突解决
    策略不同,结果不同——这就是第五节要讨论的内容。

四、用向量时钟检测冲突

在解决冲突之前,首先要检测冲突。核心问题是:两个写入操作是并发的(冲突),还是一个因果依赖于另一个(不冲突)?

4.1 因果序与并发

回顾 逻辑时钟 的内容。如果操作 A 发生在操作 B 之前,且 B 知道 A 的结果(即 B 因果依赖于 A),那么 A 和 B 不是并发的——B 应该覆盖 A,不存在冲突。

如果 A 和 B 互相不知道对方的存在(因果无关),那它们就是并发的——可能存在冲突。

向量时钟(Vector Clock) 是检测并发的标准工具。

4.2 向量时钟的工作原理

向量时钟为每个参与者维护一个逻辑时钟计数器。假设系统有 N 个 Leader,向量时钟就是一个长度为 N 的整数数组。

规则:

  1. 每个 Leader 维护一个向量时钟 VC[0..N-1],初始全为 0。
  2. Leader i 每次执行本地写入时,VC[i]++
  3. Leader i 发送消息时,附带自己的当前向量时钟。
  4. Leader j 收到来自 Leader i 的消息时,对每个分量取 max(VC_j[k], VC_i[k]),然后 VC_j[j]++

比较规则:

4.3 向量时钟实现

下面用 Go 实现一个向量时钟,支持递增、合并和并发检测:

package vclock

import "fmt"

// VClock 表示一个向量时钟。键是节点 ID,值是逻辑时间戳。
type VClock map[string]uint64

// New 创建一个空的向量时钟。
func New() VClock {
    return make(VClock)
}

// Increment 递增指定节点的逻辑时钟。
func (vc VClock) Increment(nodeID string) {
    vc[nodeID]++
}

// Merge 合并另一个向量时钟(逐分量取 max)。
func (vc VClock) Merge(other VClock) {
    for k, v := range other {
        if v > vc[k] {
            vc[k] = v
        }
    }
}

// Compare 比较两个向量时钟的因果关系。
// 返回值:
//
//  -1 : vc < other(vc 因果先于 other)
//   0 : vc || other(并发,可能存在冲突)
//   1 : vc > other(vc 因果晚于 other)
func (vc VClock) Compare(other VClock) int {
    hasLess := false
    hasGreater := false

    // 遍历所有出现过的 key
    allKeys := make(map[string]struct{})
    for k := range vc {
        allKeys[k] = struct{}{}
    }
    for k := range other {
        allKeys[k] = struct{}{}
    }

    for k := range allKeys {
        a, b := vc[k], other[k]
        if a < b {
            hasLess = true
        }
        if a > b {
            hasGreater = true
        }
    }

    if hasLess && hasGreater {
        return 0 // 并发
    }
    if hasLess {
        return -1 // vc < other
    }
    if hasGreater {
        return 1 // vc > other
    }
    return -1 // 相等,视为 vc ≤ other
}

// String 格式化输出向量时钟。
func (vc VClock) String() string {
    return fmt.Sprintf("%v", map[string]uint64(vc))
}

使用示例——模拟两个 Leader 的并发写入:

package vclock_test

import (
    "fmt"
    "vclock"
)

func Example_conflictDetection() {
    // Leader-A 和 Leader-B 各自独立写入
    vcA := vclock.New()
    vcB := vclock.New()

    // Leader-A 执行一次本地写入
    vcA.Increment("A") // VC_A = {A:1}

    // Leader-B 执行一次本地写入
    vcB.Increment("B") // VC_B = {B:1}

    // 检测因果关系
    result := vcA.Compare(vcB)
    fmt.Println("Compare result:", result) // 0 => 并发,需要冲突解决

    // 模拟因果依赖:Leader-B 收到 Leader-A 的消息
    vcB.Merge(vcA)     // VC_B = {A:1, B:1}
    vcB.Increment("B") // VC_B = {A:1, B:2}

    // 现在 VC_A < VC_B,不再并发
    result = vcA.Compare(vcB)
    fmt.Println("Compare result:", result) // -1 => A 因果先于 B
    // Output:
    // Compare result: 0
    // Compare result: -1
}

4.4 向量时钟的局限

向量时钟的长度等于参与者的数量。在多数据中心场景(3-5 个 Leader),这没问题。但在协同编辑场景(每个客户端一个”Leader”),向量时钟的长度随客户端数量线性增长。

对此有两种应对策略:

  1. 向量时钟裁剪(Pruning): 移除长时间不活跃的节点分量。Dynamo 论文采用这种做法,但可能导致误判因果关系。
  2. 改用版本向量(Version Vector)或点版本向量(Dotted Version Vector): 这是 Riak 采用的方案,能更精确地追踪因果关系,同时控制元数据大小。

五、冲突解决策略

检测到冲突之后,必须决定最终结果。不同的策略在数据正确性、复杂度和适用场景上差异巨大。

5.1 最后写入胜出(Last-Writer-Wins,LWW)

最简单也是最危险的策略:给每个写入操作附加一个时间戳,冲突时取时间戳最大的那个。

Leader-A 写入:{key: "title", value: "v2-最终版", ts: 1000}
Leader-B 写入:{key: "title", value: "v2-评审中", ts: 1002}

LWW 结果:value = "v2-评审中"(ts=1002 更大)

优点: 实现极其简单。不需要保留多个版本。收敛性有保证——所有节点最终选择同一个时间戳最大的值。

缺点: 数据丢失。Leader-A 的写入被静默丢弃,客户端以为写入成功了,实际上数据被覆盖了。更致命的是,时间戳本身不可靠——不同节点的物理时钟可能有偏差(参见 物理时钟)。NTP 偏差几十毫秒很正常,极端情况下几百毫秒也有可能。

谁在用: Cassandra 默认使用 LWW。DynamoDB 也可以配置为 LWW。适用于数据丢失可接受的场景(如缓存、会话存储、遥测数据、时序指标)。如果你的数据不能丢,不要用 LWW。

5.2 自定义合并函数(Custom Merge Function)

把冲突解决的逻辑交给应用层。系统检测到冲突后,调用应用注册的回调函数来决定最终值。

def merge_shopping_cart(conflict_a, conflict_b):
    """购物车合并策略:取两个版本中所有商品的最大数量"""
    merged = {}
    all_items = set(conflict_a.keys()) | set(conflict_b.keys())
    for item in all_items:
        qty_a = conflict_a.get(item, 0)
        qty_b = conflict_b.get(item, 0)
        merged[item] = max(qty_a, qty_b)
    return merged

# 冲突场景:
# Leader-A: {"苹果": 3, "香蕉": 2}
# Leader-B: {"苹果": 1, "香蕉": 5, "橘子": 1}
# 合并结果: {"苹果": 3, "香蕉": 5, "橘子": 1}

优点: 可以根据业务语义做出合理的合并决策。数据不一定丢失。

缺点: 需要应用开发者理解冲突解决的语义,编写和测试合并函数。不同数据类型可能需要不同的合并函数。如果合并函数有 bug,会导致数据不一致。更根本的问题是:不是所有数据都有自然的合并语义——“用户姓名”怎么合并?

5.3 基于 CRDT 的解决

无冲突复制数据类型(Conflict-free Replicated Data Type,CRDT)是一类特殊的数据结构,保证并发修改总能自动合并且收敛到一致状态,无需协调。详细原理参见 CRDT 入门(如果你对 CRDT 还不熟悉)。

CRDT 的核心思路:限制数据操作的类型,使得合并操作天然满足交换律、结合律和幂等性

常见的 CRDT 类型:

CRDT 类型 用途 合并策略
G-Counter 只增计数器 逐节点取 max,再求和
PN-Counter 可增可减计数器 分别维护增和减的 G-Counter
G-Set 只增集合 取并集
OR-Set(Observed-Remove Set) 可增可删集合 添加优先于未观察到的删除
LWW-Register 单值寄存器 LWW(本质上就是 5.1)
MV-Register(Multi-Value Register) 单值寄存器 保留所有并发版本,交给客户端解决

优点: 合并是自动的、确定性的。不需要协调或中心化的冲突解决。理论上保证最终一致性。

缺点: 只适用于特定的数据模型。你不能对任意的关系型数据使用 CRDT。元数据开销(向量时钟、墓碑集合)可能很大。语义有限——很多业务逻辑无法用 CRDT 表达。

谁在用: Riak 内置支持多种 CRDT 类型。Redis Enterprise 的 Active-Active 模式支持 CRDT。Automerge 和 Yjs 等协同编辑库基于 CRDT。

5.4 操作转换(Operational Transformation,OT)

操作转换(Operational Transformation,OT)是协同编辑领域的经典方案。核心思路:不是合并最终状态,而是转换操作本身,使得并发操作在任意顺序下应用都能得到一致结果。

初始文本:"ABC"

Alice 的操作:在位置 1 插入 "X" → "AXBC"
Bob 的操作:删除位置 2 的字符 → "AC"

如果直接按顺序应用:
  先 Alice 后 Bob:   "ABC" → "AXBC" → "AXC"
  先 Bob 后 Alice:   "ABC" → "AC"   → "AXAC" ← 结果不一致!

OT 的做法:根据先到的操作转换后到的操作。
  Bob 看到 Alice 在位置 1 插了字符,
  Bob 的"删除位置 2"应该变为"删除位置 3"
  结果:"AXBC" → "AXC" ✓

优点: 对文本编辑这类线性数据结构非常自然。Google Docs 就用的 OT。

缺点: 实现极其复杂。转换函数的正确性证明困难。需要中心服务器来确定操作的全局顺序(Google Docs 的 OT 不是去中心化的)。扩展到多种数据类型时,转换矩阵的组合爆炸。

近年来,CRDT(尤其是基于序列的 CRDT,如 LSEQ、RGA)正在逐步取代 OT 成为协同编辑的主流方案。Figma 用的是自定义的 CRDT;Yjs 和 Automerge 也基于 CRDT。

5.5 无冲突设计(Conflict-free by Design)

最优雅的冲突解决策略是:让冲突根本不会发生。通过 Schema 设计和写入策略,避免多个 Leader 写入同一份数据。

常见手段:

  1. 按写入者分区(Partitioning by Writer): 用户 A 的数据只在 Leader-A 写入,用户 B 的数据只在 Leader-B 写入。读可以去任何节点。这实质上把多主复制退化为多个独立的单主复制。
  2. 只追加数据(Append-only): 不做 UPDATE 和 DELETE,只做 INSERT。每次修改都是一条新记录。合并时只需要合并事件流(类似事件溯源 Event Sourcing)。
  3. 分字段所有权(Per-field Ownership): 不同 Leader 负责不同字段。Leader-A 负责写 status 字段,Leader-B 负责写 comment 字段。字段级别不会冲突。
  4. 业务层路由: 写请求路由到”主 Leader”,只在故障转移时切换到备 Leader。这在大多数时间等价于单主复制,只在灾难场景才进入多主模式。

优点: 根本不需要冲突解决逻辑。系统复杂度大幅降低。

缺点: 限制了应用的灵活性。不是所有场景都能这么设计。

5.6 策略对比总结

策略 数据丢失风险 实现复杂度 适用场景 代表系统
LWW 缓存、遥测、时序数据 Cassandra、DynamoDB
自定义合并函数 取决于实现 业务语义明确的数据 Riak(allow_mult)
CRDT (受限数据类型) 中-高 计数器、集合、协同编辑 Riak、Redis Enterprise
OT 极高 文本协同编辑 Google Docs
无冲突设计 低-中 可分区、可追加的数据 自定义架构
保留所有版本 需要人工介入的场景 CouchDB

(上表的策略分支可参考 conflict-resolution.svg 中的可视化。)

以下 Mermaid 流程图将冲突解决策略的选择逻辑可视化,帮助架构师根据数据特征快速定位合适的策略:

flowchart TD
    Start["检测到写冲突"] --> CanAvoid{"能从 Schema 层面<br/>避免冲突?"}

    CanAvoid -->|能| Avoid["无冲突设计<br/>按写入者分区 / 只追加 / 分字段所有权"]
    CanAvoid -->|不能| DataType{"数据类型?"}

    DataType -->|计数器/集合/标志位| CRDT["CRDT<br/>G-Counter / OR-Set 等<br/>自动合并, 无数据丢失"]
    DataType -->|文本/富文本| Editor{"需要去中心化?"}
    DataType -->|通用业务数据| LossOK{"丢失旧值可接受?"}

    Editor -->|是| CRDTText["基于序列的 CRDT<br/>Yjs / Automerge"]
    Editor -->|否| OT["操作转换 OT<br/>需中心服务器排序"]

    LossOK -->|是| LWW["LWW 最后写入胜出<br/>简单高效<br/>Cassandra / DynamoDB"]
    LossOK -->|不是| Semantic{"有明确的业务合并语义?"}

    Semantic -->|有| Custom["自定义合并函数<br/>如购物车取 max"]
    Semantic -->|没有| KeepAll["保留所有版本<br/>交给用户/应用解决<br/>CouchDB 模式"]

    style Avoid fill:#e8f5e9
    style CRDT fill:#e3f2fd
    style LWW fill:#fff3e0
    style Custom fill:#fff3e0
    style KeepAll fill:#fce4ec
    style CRDTText fill:#e3f2fd
    style OT fill:#fce4ec

该决策树的第一步最为关键:如果能通过 Schema 设计避免冲突(绿色路径),就应该优先选择,这比任何冲突解决策略都更简单、更可靠。对于无法避免冲突的场景,决策树根据数据类型和业务容忍度逐步缩小策略范围——计数器和集合类数据天然适合 CRDT;通用业务数据则需要在 LWW 的简单性和自定义合并的精确性之间做权衡。

六、复制拓扑

多个 Leader 之间如何互相传播变更?这由复制拓扑(Replication Topology)决定。

6.1 星型拓扑(Star / Hub-and-Spoke)

一个中心 Leader 充当 Hub,其他 Leader 只和 Hub 通信。

       ┌──── Leader-B
       │
Hub ───┤
       │
       └──── Leader-C

特点: - 变更传播路径短(最多两跳:源 → Hub → 目标)。 - Hub 是单点故障——Hub 挂了,其他 Leader 之间无法同步。 - 适合有”主数据中心”概念的架构。

6.2 环形拓扑(Circular / Ring)

每个 Leader 只向下一个 Leader 转发变更,形成一个环。

Leader-A → Leader-B → Leader-C → Leader-A

特点: - 每个节点只需维护一个出方向连接,网络开销小。 - 变更传播延迟高——从 A 到 C 需要经过 B。 - 任何一个节点挂了,环就断了。MySQL 传统的循环复制(Circular Replication)就是这种拓扑,生产环境中问题很多。 - 需要在变更消息中记录”已经过的节点列表”以防止无限循环。

6.3 全互联拓扑(All-to-All / Mesh)

每个 Leader 直接向所有其他 Leader 发送变更。

Leader-A ←→ Leader-B
Leader-A ←→ Leader-C
Leader-B ←→ Leader-C

特点: - 容错性最好——任何一个节点挂了,其他节点之间的通信不受影响。 - 变更传播延迟最低——一跳直达。 - 网络连接数为 N×(N-1)/2,Leader 数量多时开销大。 - 因果序问题: 如果 Leader-A 的变更经过 Leader-B 转发给 Leader-C,而 Leader-A 直接发给 Leader-C 的消息先到了,Leader-C 可能看到乱序。需要向量时钟或 Lamport 时间戳来保证因果序。

三种拓扑的对比见 conflict-resolution.svg 底部的拓扑图示。

拓扑 容错性 传播延迟 连接数 因果序保证
星型 Hub 是单点 低(≤2跳) N-1 Hub 天然串行化
环形 任一节点断裂 高(最多 N-1 跳) N 需要节点列表防循环
全互联 最佳 最低(1跳) N×(N-1)/2 需要向量时钟

实践中,全互联拓扑最常用。PostgreSQL 的 BDR(Bi-Directional Replication)、MySQL Group Replication 多主模式都采用全互联。

下面用 Mermaid 流程图直观展示三种拓扑的结构、优缺点和适用场景:

flowchart LR
    subgraph star["星型拓扑 Star"]
        direction TB
        Hub((Hub)) <--> S1[Leader-B]
        Hub <--> S2[Leader-C]
        Hub <--> S3[Leader-D]
        S_note["容错: Hub单点故障<br/>延迟: 低 <=2跳<br/>连接: N-1<br/>适合: 有主数据中心"]
    end

    subgraph ring["环形拓扑 Ring"]
        direction TB
        R1[Leader-A] --> R2[Leader-B]
        R2 --> R3[Leader-C]
        R3 --> R1
        R_note["容错: 任一节点断裂<br/>延迟: 高 最多N-1跳<br/>连接: N<br/>适合: 不推荐生产使用"]
    end

    subgraph mesh["全互联拓扑 All-to-All"]
        direction TB
        M1[Leader-A] <--> M2[Leader-B]
        M1 <--> M3[Leader-C]
        M2 <--> M3
        M_note["容错: 最佳<br/>延迟: 最低 1跳<br/>连接: N*(N-1)/2<br/>适合: 大多数生产场景"]
    end

三种拓扑中,星型拓扑的 Hub 是单点故障,环形拓扑的任何节点断裂都会中断复制链路,只有全互联拓扑提供了最佳的容错性和最低的传播延迟。生产环境中绝大多数多主部署采用全互联拓扑,尽管连接数随节点数平方增长——但多主复制的节点数通常不超过 5 个,连接数开销完全可以接受。

七、CouchDB/PouchDB 实战案例

CouchDB 是多主复制领域的经典实现。它的设计哲学是:不自动解决冲突,而是保留所有冲突版本,让应用层(或用户)来决定

7.1 CouchDB 的复制模型

CouchDB 的每个数据库实例都是平等的——没有”主”和”从”的区分。任何两个 CouchDB 实例之间都可以建立双向复制。这是真正的多主复制。

每个文档有一个修订树(Revision Tree)。修订 ID 的格式是 {revision_depth}-{hash},例如 3-abc123。当两个实例并发修改同一个文档时,修订树分叉,产生冲突。

7.2 冲突产生与检测

模拟一个完整的冲突场景:

# 1. 创建初始文档
curl -X PUT http://localhost:5984/mydb/doc1 \
  -H "Content-Type: application/json" \
  -d '{"_id": "doc1", "title": "初始标题", "content": "hello"}'

# 返回:{"ok":true,"id":"doc1","rev":"1-xxxxx"}
# 2. 在实例 A 上修改
curl -X PUT http://localhost:5984/mydb/doc1 \
  -H "Content-Type: application/json" \
  -d '{"_id": "doc1", "_rev": "1-xxxxx", "title": "Alice 的修改", "content": "hello world"}'

# 返回:{"ok":true,"id":"doc1","rev":"2-aaaaa"}
# 3. 同时在实例 B 上修改(基于相同的 rev 1-xxxxx)
curl -X PUT http://localhost:5985/mydb/doc1 \
  -H "Content-Type: application/json" \
  -d '{"_id": "doc1", "_rev": "1-xxxxx", "title": "Bob 的修改", "content": "hello CouchDB"}'

# 返回:{"ok":true,"id":"doc1","rev":"2-bbbbb"}
# 4. 触发双向复制
curl -X POST http://localhost:5984/_replicate \
  -H "Content-Type: application/json" \
  -d '{"source": "http://localhost:5984/mydb", "target": "http://localhost:5985/mydb"}'

curl -X POST http://localhost:5984/_replicate \
  -H "Content-Type: application/json" \
  -d '{"source": "http://localhost:5985/mydb", "target": "http://localhost:5984/mydb"}'

复制完成后,两个实例上的 doc1 都有两个冲突版本。

7.3 查看与解决冲突

# 查看文档(CouchDB 自动选择一个"胜出"版本,但未删除另一个)
curl http://localhost:5984/mydb/doc1

# 查看所有冲突版本
curl "http://localhost:5984/mydb/doc1?conflicts=true"
# 返回:{..., "_conflicts": ["2-bbbbb"]}

CouchDB 会确定性地选择一个”胜出”版本(通常基于修订哈希的字典序,不是时间戳——这避免了时钟偏差问题),但不会丢弃另一个版本。应用可以获取所有冲突版本,然后自行决定:

# 获取非胜出版本的内容
curl "http://localhost:5984/mydb/doc1?rev=2-bbbbb"

# 应用层合并后,写入最终版本
curl -X PUT http://localhost:5984/mydb/doc1 \
  -H "Content-Type: application/json" \
  -d '{
    "_id": "doc1",
    "_rev": "2-aaaaa",
    "title": "合并后的标题",
    "content": "hello world + CouchDB",
    "resolved_from": ["2-aaaaa", "2-bbbbb"]
  }'

# 删除冲突版本
curl -X DELETE "http://localhost:5984/mydb/doc1?rev=2-bbbbb"

7.4 PouchDB:离线优先的客户端

PouchDB 是 CouchDB 的 JavaScript 实现,运行在浏览器或 Node.js 中。它的核心价值:离线时写入本地 PouchDB,上线后自动与远端 CouchDB 同步

const PouchDB = require('pouchdb');

const localDB = new PouchDB('local_tasks');
const remoteDB = new PouchDB('http://server:5984/tasks');

// 开启持续双向同步
const sync = localDB.sync(remoteDB, {
  live: true,
  retry: true
});

// 监听冲突事件
sync.on('change', function (change) {
  // change.direction: 'push' | 'pull'
  console.log('同步变更:', change.direction, change.change.docs.length, '个文档');
});

// 监听冲突
localDB.changes({
  since: 'now',
  live: true,
  conflicts: true
}).on('change', function (change) {
  if (change.doc._conflicts) {
    console.log('检测到冲突:', change.doc._id, change.doc._conflicts);
    resolveConflict(change.doc);
  }
});

async function resolveConflict(doc) {
  // 获取所有冲突版本
  const dominated = doc._conflicts;
  const winner = doc;

  // 应用层合并逻辑(这里用简单的时间戳比较)
  for (const rev of dominated) {
    const conflicting = await localDB.get(doc._id, { rev: rev });
    // 合并逻辑:保留更新时间更晚的字段
    if (conflicting.updated_at > winner.updated_at) {
      Object.assign(winner, conflicting);
    }
    // 删除冲突版本
    await localDB.remove(doc._id, rev);
  }

  // 保存合并结果
  await localDB.put(winner);
}

CouchDB/PouchDB 的设计哲学总结: 1. 冲突是常态,不是异常。 2. 系统负责检测冲突、保留所有版本。 3. 应用负责合并逻辑。 4. 确定性的胜出规则保证即使应用不处理冲突,数据也能收敛。

八、工程挑战

多主复制在生产环境中面临的问题远不止冲突解决。

8.1 双数据中心多主部署案例

以下是一个电商平台在北京和上海两个数据中心部署 MySQL Group Replication 多主模式的真实架构还原:

架构设计:两个数据中心各有一个 Primary 节点,采用全互联拓扑双向复制。北京用户的写入路由到北京 Primary,上海用户写入路由到上海 Primary。跨数据中心 RTT 约 25ms。

观察到的冲突模式

冲突类型 频率 根因 解决方式
用户资料并发修改 极低(<0.01%) 同一用户在两地登录修改个人信息 LWW(时间戳)
库存扣减冲突 中等(0.1~0.5%) 热门商品在两地同时下单 改为单 Leader 写入(按商品分区)
订单号重复 初期频发 自增 ID 冲突 切换为 Snowflake ID
优惠券核销冲突 低(<0.05%) 同一优惠券在两地同时使用 改为分布式锁 + 单 Leader

关键教训:上线后最大的问题不是技术冲突率,而是运维可见性不足。团队最初没有监控冲突率指标,直到库存超卖事故才发现库存扣减在高峰期的冲突率高达 0.5%。修复方案是将库存相关写入收归单 Leader(按商品 ID 哈希路由到固定 Leader),仅对用户资料、浏览记录等低冲突数据保持多主写入。

8.2 可观测性体系建设

多主复制系统的可观测性是生死线。以下是建议监控的核心指标体系:

第一层:复制健康指标

指标 采集方式 告警阈值 含义
复制延迟(Replication Lag) SHOW SLAVE STATUS / 自定义探针 >5s 预警,>30s 严重 Leader 间数据同步延迟
复制队列深度 Performance Schema >10000 预警 待发送的变更事件数
复制链路状态 Heartbeat 探针 断开即告警 Leader 间连接是否正常

第二层:冲突指标

指标 采集方式 告警阈值 含义
冲突率(Conflict Rate) 应用层埋点 / Certification 日志 >0.1% 预警,>1% 严重 写入中产生冲突的比例
冲突解决延迟 应用层计时 >100ms 预警 合并函数执行耗时
未解决冲突数 定期扫描 >0 告警 需要人工介入的冲突
冲突热点 Key Top-N 统计 集中度>50% 冲突是否集中在少数 Key

第三层:数据一致性校验

-- 定期一致性校验 Job(伪代码)
-- 对比两个 Leader 的关键表数据
SELECT
    'leader_beijing' AS source,
    table_name,
    COUNT(*) AS row_count,
    MD5(GROUP_CONCAT(CONCAT_WS(',', id, updated_at) ORDER BY id)) AS checksum
FROM information_schema.tables
CROSS JOIN LATERAL (SELECT id, updated_at FROM {table_name} ORDER BY id) t
GROUP BY table_name;

-- 两个 Leader 的 checksum 不一致时触发告警和自动修复

8.3 Schema 层面的冲突规避策略

与其在冲突发生后解决,不如从数据模型设计层面消除冲突的可能性。以下是三种经过生产验证的策略:

策略一:按实体类型划分写入所有权

不同类型的数据指定固定的写入 Leader。例如:

Leader-Beijing 专属写入:
  - users 表(用户注册、资料修改)
  - orders 表(下单)

Leader-Shanghai 专属写入:
  - inventory 表(库存管理)
  - logistics 表(物流信息)

所有 Leader 都可读:
  - 所有表(异步复制后可读)

这实质上将多主复制退化为”多个单主复制的组合”,每类数据只有一个写入点,完全消除冲突。代价是某些跨类型操作需要跨 Leader 协调(如下单同时扣库存),但可以通过异步消息队列解耦。

策略二:只追加 + 事件溯源(Append-Only + Event Sourcing)

不做 UPDATE 和 DELETE,所有写入都是 INSERT。冲突变成了”谁先谁后”的排序问题,而不是”谁覆盖谁”的合并问题。

-- 传统方式(会冲突):
UPDATE accounts SET balance = balance - 100 WHERE id = 42;

-- 事件溯源方式(不会冲突):
INSERT INTO account_events (account_id, event_type, amount, source_leader, ts)
VALUES (42, 'DEBIT', 100, 'beijing', NOW());

-- 余额通过聚合事件流计算
SELECT SUM(CASE WHEN event_type='CREDIT' THEN amount ELSE -amount END) AS balance
FROM account_events WHERE account_id = 42;

策略三:分字段所有权 + 合并策略

对同一行数据的不同字段分配给不同 Leader 写入。例如用户表:

users 表:
  - name, email, phone: 用户所在地的 Leader 可写
  - credit_score: 仅 Leader-Beijing 可写(风控团队在北京)
  - shipping_preference: 仅 Leader-Shanghai 可写(物流团队在上海)

这种策略需要在应用层严格执行写入路由规则,并通过 CDC(Change Data Capture)或触发器检测违规写入。

8.4 监控冲突率

冲突率是多主复制最关键的运维指标。冲突率过高通常说明数据分区策略有问题,或者应用的写入模式不适合多主架构。

-- 伪 SQL:统计过去一小时的冲突率
SELECT
    COUNT(*) AS total_replicated_ops,
    SUM(CASE WHEN is_conflict = true THEN 1 ELSE 0 END) AS conflict_count,
    ROUND(100.0 * SUM(CASE WHEN is_conflict = true THEN 1 ELSE 0 END)
        / COUNT(*), 2) AS conflict_rate_pct
FROM replication_log
WHERE timestamp > NOW() - INTERVAL '1 hour';

关键指标和告警阈值参考:

指标 健康值 预警值 严重值
冲突率 < 0.1% 0.1% - 1% > 1%
复制延迟(Replication Lag) < 1s 1s - 10s > 10s
未解决冲突数(仅限 CouchDB 等保留冲突的系统) 0 < 100 > 100
复制队列深度 < 1000 1000 - 10000 > 10000

8.5 Schema 演进

在单主复制中,Schema 变更(如加字段、改类型)可以在 Leader 上执行,然后自动复制到 Follower。在多主复制中,如果两个 Leader 同时执行不同的 Schema 变更,可能导致不兼容。

安全做法:

  1. Schema 变更只在一个 Leader 执行,其他 Leader 通过复制获取。实质上对于 DDL 回退到单主模式。
  2. 使用 Schema-less 存储(如 CouchDB 的 JSON 文档),避免 Schema 变更的复制问题。
  3. 版本化 Schema,在文档中携带 Schema 版本号,应用层根据版本号做兼容处理。

8.6 外键与约束

多主复制中,跨行和跨表的约束(外键、唯一索引、CHECK 约束)很难保证全局一致。

-- Leader-A:插入订单,引用 user_id = 42
INSERT INTO orders (id, user_id, amount) VALUES (1001, 42, 99.00);

-- Leader-B(并发):删除用户 42
DELETE FROM users WHERE id = 42;

同步之后,orders 表引用了一个不存在的 user_id。外键约束在单个 Leader 内有效,但跨 Leader 无法实时验证。

解决方案: 1. 放弃数据库层面的外键约束,改为应用层检查。 2. 使用软删除(Soft Delete),不做物理删除。 3. 后台异步校验数据完整性,发现违规后告警或自动修复。

8.7 自增 ID 冲突

如果两个 Leader 都用自增 ID(Auto-Increment),必然撞号。

常见方案:

方案 做法 缺点
奇偶分段 Leader-A 用奇数,Leader-B 用偶数 扩展到第三个 Leader 时需要改规则
范围分段 Leader-A 用 1-1000000,Leader-B 用 1000001-2000000 范围用完需要扩展
UUID 每个节点独立生成 UUID 128 位,索引性能差;无序
Snowflake 时间戳 + 机器 ID + 序列号 依赖时钟同步(毫秒级即可)
ULID 时间戳 + 随机数,字典序可排 同一毫秒内的排序依赖随机数

推荐 Snowflake 或 ULID。它们既能保证全局唯一,又有时间排序性,索引友好。

8.8 调试复制问题

多主复制的问题排查比单主复制难一个数量级。关键调试方法:

# CouchDB:检查复制状态
curl http://localhost:5984/_active_tasks | python3 -m json.tool

# CouchDB:查看未解决冲突的文档列表
curl "http://localhost:5984/mydb/_changes?conflicts=true&filter=_conflicts" \
  | python3 -m json.tool

# MySQL Group Replication:检查组状态
mysql -e "SELECT * FROM performance_schema.replication_group_members;"

# MySQL Group Replication:查看应用队列
mysql -e "SELECT * FROM performance_schema.replication_group_member_stats\G"

建议在每条变更日志中包含:源 Leader ID、逻辑时间戳(或向量时钟)、操作类型、受影响的键。出问题时,可以按键回溯一条数据在所有 Leader 上的变更历史。

九、决策框架:何时采用多主复制

多主复制增加了大量的工程复杂度。以下决策框架帮助你判断是否值得:

使用多主复制的条件(满足至少两条)

  1. 业务确实需要多数据中心写入,且跨数据中心的写入延迟影响用户体验。
  2. 需要离线写入能力(移动端、边缘节点)。
  3. 写入吞吐确实超过了单节点的能力(先考虑垂直扩展和分库分表)。
  4. 团队有足够的工程能力处理冲突解决、数据一致性校验、多主调试。
  5. 数据模型允许无冲突设计或冲突可以用 CRDT/LWW 解决。

不适合多主复制的场景

  1. 强一致性要求:银行转账、库存扣减。用共识协议(Raft/Paxos)或分布式事务。
  2. Schema 复杂、外键密集:关系型数据库的约束模型和多主复制天然矛盾。
  3. 团队规模小、运维经验不足:多主复制的调试和运维成本极高。
  4. 单数据中心部署:没有跨地域需求,不需要多主。

降低复杂度的务实建议

  1. 优先考虑无冲突设计。通过数据分区、只追加写入、业务层路由,最大限度减少冲突。
  2. 从两个 Leader 开始。不要一上来就部署五个。先验证两个数据中心之间的复制是否稳定。
  3. 建立完善的监控。冲突率、复制延迟、队列深度、数据一致性校验结果。
  4. 定期做一致性校验。后台 Job 定期比对两个 Leader 的数据,发现不一致及时告警。
  5. 选择成熟的方案。CockroachDB、TiDB 的多区域部署比自己在 MySQL 上搭多主要可靠得多。

参考资料

  1. Kleppmann, M. Designing Data-Intensive Applications. O’Reilly, 2017. Chapter 5: Replication.
  2. Shapiro, M., Preguiça, N., Baquero, C., Zawirski, M. “Conflict-free Replicated Data Types.” 2011.
  3. DeCandia, G. et al. “Dynamo: Amazon’s Highly Available Key-value Store.” SOSP, 2007.
  4. Anderson, J. C., Lehnardt, J., Slater, N. CouchDB: The Definitive Guide. O’Reilly, 2010.
  5. Lamport, L. “Time, Clocks, and the Ordering of Events in a Distributed System.” 1978.
  6. Fidge, C. “Timestamps in Message-Passing Systems That Preserve the Partial Ordering.” 1988.
  7. Preguiça, N. et al. “Dotted Version Vectors: Logical Clocks for Optimistic Replication.” 2012.
  8. Sun, C. and Ellis, C. “Operational Transformation in Real-Time Group Editors: Issues, Algorithms, and Achievements.” CSCW, 1998.
  9. CouchDB 官方文档:Replication and Conflicts. https://docs.couchdb.org/en/stable/replication/conflicts.html
  10. MySQL Group Replication 文档:https://dev.mysql.com/doc/refman/8.0/en/group-replication.html

Prev: 主从复制 | Next: 无主复制

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · distributed

【分布式系统百科】主从复制:同步、异步与半同步的工程权衡

一份数据写到一个节点,怎么安全地复制到其它节点?同步复制保证强一致但拖慢写入;异步复制延迟低但 Leader 崩溃可能丢数据;半同步在两者之间找平衡。本文拆解 PostgreSQL Streaming Replication、MySQL Semi-Sync / Group Replication、Galera Cluster 的工程实现,深入分析复制延迟的三类一致性陷阱和故障转移中的脑裂与数据丢失问题。


By .