4. 主主复制与多主复制:冲突解决与数据一致性问题

多主复制听起来很美——多个节点都能写,任意节点挂了不影响写入。但光鲜背后藏着个大坑:写冲突。我最早接触这个模式是在做全球部署的协同编辑系统时,用户在北京和纽约同时编辑同一条记录,结果两边都觉得自己的数据才是对的。嗯,今天咱们就把这块硬骨头啃下来。

4.1 什么是多主复制?它跟主从有啥区别?

先理一下概念。主从复制(单主复制)只有一个主节点能写,其他从节点只能读。多主复制允许多个节点同时接受写入,每个节点既是主也是从——互相复制数据。

特性主从复制多主复制
写入点唯一主节点多个节点均可写
写冲突不会产生(串行写入)必然产生
故障切换需选主流程其他节点继续服务
延迟主从可能不同步跨地域同步延迟更明显
典型场景单体应用、读写分离多数据中心、离线编辑

说白了,多主复制是为了解决单点写入瓶颈和地域容灾。但代价就是——冲突处理。

4.2 冲突从哪里来?

你想想看,两个用户同时在网络不通的两个节点上修改了同一条数据。比如订单状态,A节点改成“已发货”,B节点改成“已取消”。等网络恢复,两边一同步——到底听谁的?

  • 写写冲突:两个节点同时修改同一个数据项的不同字段或相同字段。
  • 读写冲突:一个节点在写,另一个节点在读,读到不一致的中间状态。
  • 删除冲突:一边删了数据,另一边还在更新,墓碑( tombstone )处理不好就炸。

我在项目中遇到过最头疼的是删除冲突。一个用户删除了购物车中的商品,另一个用户在同一时间向购物车中加了这个商品。同步后商品消失了,用户投诉说“我刚加的东西怎么没了?” 嗯,这是典型的墓碑与增量更新的碰撞。

关键理解:冲突不是Bug,是多主复制的固有属性。你的系统必须在设计时就接纳冲突,而不是期望它不发生。

4.3 冲突解决的常用策略

策略分两类:避免冲突 和 解决冲突。能避免就避免,避免不了才去解决。

4.3.1 避免冲突——把冲突扼杀在摇篮里

  • 数据分片路由:每个数据项只有一个主节点负责。比如按用户ID哈希,用户A的所有写请求只发到节点1。这本质上是把多主退化为单主。
  • 时间戳加乐观锁:写入时检查版本号或时间戳,不匹配则拒绝。适用于冲突概率低的场景。
经验:我习惯对高竞争数据(比如库存扣减)强制使用分片路由,而对于用户个人信息这种低频修改数据,才放开多主写入。避免冲突的设计永远比解决冲突简单。

4.3.2 最后写入胜利(LWW) —— 简单但不一定对

每个写入带一个时间戳或逻辑时钟,同步时保留最新的那个。实现简单,但会丢失数据。举个例子:A节点把用户昵称改成“张三”,B节点改成“李四”。如果B的时间戳更大,最终昵称固定成“李四”,A的修改永久消失了。

// 伪代码:LWW合并逻辑
function mergeLWW(existing, incoming) {
    if (incoming.timestamp > existing.timestamp) {
        return incoming;
    }
    return existing;
}
注意:千万不要依赖机器物理时钟做LWW!时钟漂移会造成“过去”的写入覆盖“未来”的写入。我踩过这个坑——服务器NTP同步间歇性失效,后写入的数据被更早的时间戳覆盖,数据丢失了整整两小时。一定要用逻辑时钟(如Lamport时钟)或版本向量。

4.3.3 CRDT(无冲突可复制数据类型) —— 数学保证不冲突

CRDT是一种牛逼的数据结构。它的核心思想:所有操作都是可交换的(commutative),无论以什么顺序应用,最终状态都一样。就像往一个包里放苹果,先放1个再放2个,跟先放2个再放1个,结果都是3个。

常用CRDT类型:

  • G-Counter(增长计数器):只能增加,不能减少。每个节点维护自己的计数器,合并时取每个节点的最大值。
  • PN-Counter(正负计数器):支持增加和减少,拆成两个G-Counter(正和负),合并时分别取最大。
  • LWW-Register(最后写入胜利寄存器):本质还是基于时间戳,但比直接覆盖更规范。
  • OR-Set(观察删除集合):集合中的每个元素带唯一标签,删除时只移除标签,再添加同值不同标签的元素不会冲突。
// 伪代码:G-Counter合并
function mergeGCounter(local, remote) {
    let merged = {};
    for (let node in local) {
        merged[node] = Math.max(local[node] || 0, remote[node] || 0);
    }
    for (let node in remote) {
        if (!(node in local)) merged[node] = remote[node];
    }
    return merged;
}
// 整个过程无需冲突检测,直接合并即可
核心理念:CRDT把“解决冲突”变成了“避免冲突”。你不需要检测冲突,你只需要知道如何合并。我在做协同编辑工具时用了CRDT的文本库(如Automerge、Yjs),效果出奇的好。

4.3.4 应用层手动解决 —— 最麻烦但最灵活

把冲突交给上层应用处理。比如数据库记录多个冲突版本,暴露给用户人工合并。或者业务代码中写复杂的合并逻辑:如果是字段级别的冲突,取非空值;如果是列表冲突,以追加方式合并。

这种方式的代表系统是CouchDB和Riak。它们会把冲突记录保留为多个叶子版本,由客户端来裁决。

经验:我曾经在电商订单系统中使用应用层合并:当两个节点同时更新订单的“地址”和“商品备注”时,我们合并策略是“地址取最新的,备注取两次的拼接”。这需要业务人员参与定义合并规则,但胜在粒度可控。

4.4 数据一致性的现实权衡

多主复制中,一致性模型不像单主那么清晰。你必须接受“最终一致性”,而且最终是什么时候,取决于冲突解决的效率和网络延迟。我总结了三类典型模型:

模型特点适用场景
最终一致性所有副本最终一致,但中间可能读到旧版本强一致性要求不高的场景(如评论、点赞)
因果一致性有因果关系的操作按正确顺序呈现社交媒体时间线、邮件系统
读写一致性自己写入后立刻能读到最新值需要“我改我见”的场景(如修改密码后的立即生效)

你设计时要想清楚:“我的业务能容忍多久的不一致?” 如果是金融交易,多主复制几乎不可接受——必须走分布式事务或单主加仲裁。如果是用户修改头像,延迟几秒完全OK。

避坑:不要以为用了CRDT就万事大吉。CRDT只保证最终状态收敛,不保证读时看到最新数据。如果你的业务要求强读一致性,CRDT之后还得配合读修复或Quorum机制。我曾经天真地以为CRDT解决了一切,结果上线后用户抱怨“我明明点了取消订阅,过了十分钟页面还显示已订阅”——因为读请求落到了还没同步的副本上。

4.5 多主复制的架构示意图

下面这张图展示了两个数据中心的主主复制流程,以及冲突产生的时机。

多主复制冲突流程示意图 数据中心A 数据中心B 用户X: 订单=已发货 用户Y: 订单=已取消 异步复制(冲突到达对方) 异步复制(冲突到达对方) 冲突检测 同一订单ID: 状态字段同时被改为 “已发货” 和 “已取消” LWW CRDT 应用层合并 最终结果取决于选用的冲突解决策略,系统无法同时满足所有写入

4.6 实践中的冲突处理流程

我动手做过的系统中,一般按这个步骤处理多主冲突:

  1. 冲突检测:每个写入携带版本向量(Version Vector)或因果上下文。同步时比对,如果双方无因果关系(即并发修改),标记为冲突。
  2. 自动解决:根据业务提前设定的策略(LWW/CRDT/自定义规则)合并。自动解决失败则进入下一步。
  3. 手动解决:冲突版本保留为两个分支,暴露给运维或业务人员手动选择。这在CouchDB中称为“冲突树”。
  4. 收敛通知:解决后通知所有副本强制同步,避免老版本残留。
我提醒一句:冲突解决策略一旦选好,就不要在生产环境频繁更换。从LWW切到CRDT需要迁移所有现有数据,版本向量的格式也要改,代价极高。我见过一个团队在项目中期切换策略,导致三个月的数据无法正确合并,最后只好全量重建。选之前多想一步,选之后别回头。

多主复制不是银弹,但它确实是实现高可用多活的关键技术。只要理解了冲突的本质,选对数据结构,配合合理的业务容错,就能把多主复制用得稳稳当当。你想想看,全球化的产品哪个不用多主?关键还是回到那句老话:没有免费的午餐。你要多点写入,就得承受冲突的成本。