4. 主主复制与多主复制:冲突解决与数据一致性问题
多主复制听起来很美——多个节点都能写,任意节点挂了不影响写入。但光鲜背后藏着个大坑:写冲突。我最早接触这个模式是在做全球部署的协同编辑系统时,用户在北京和纽约同时编辑同一条记录,结果两边都觉得自己的数据才是对的。嗯,今天咱们就把这块硬骨头啃下来。
4.1 什么是多主复制?它跟主从有啥区别?
先理一下概念。主从复制(单主复制)只有一个主节点能写,其他从节点只能读。多主复制允许多个节点同时接受写入,每个节点既是主也是从——互相复制数据。
| 特性 | 主从复制 | 多主复制 |
|---|---|---|
| 写入点 | 唯一主节点 | 多个节点均可写 |
| 写冲突 | 不会产生(串行写入) | 必然产生 |
| 故障切换 | 需选主流程 | 其他节点继续服务 |
| 延迟 | 主从可能不同步 | 跨地域同步延迟更明显 |
| 典型场景 | 单体应用、读写分离 | 多数据中心、离线编辑 |
说白了,多主复制是为了解决单点写入瓶颈和地域容灾。但代价就是——冲突处理。
4.2 冲突从哪里来?
你想想看,两个用户同时在网络不通的两个节点上修改了同一条数据。比如订单状态,A节点改成“已发货”,B节点改成“已取消”。等网络恢复,两边一同步——到底听谁的?
- 写写冲突:两个节点同时修改同一个数据项的不同字段或相同字段。
- 读写冲突:一个节点在写,另一个节点在读,读到不一致的中间状态。
- 删除冲突:一边删了数据,另一边还在更新,墓碑( tombstone )处理不好就炸。
我在项目中遇到过最头疼的是删除冲突。一个用户删除了购物车中的商品,另一个用户在同一时间向购物车中加了这个商品。同步后商品消失了,用户投诉说“我刚加的东西怎么没了?” 嗯,这是典型的墓碑与增量更新的碰撞。
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;
}
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;
}
// 整个过程无需冲突检测,直接合并即可
4.3.4 应用层手动解决 —— 最麻烦但最灵活
把冲突交给上层应用处理。比如数据库记录多个冲突版本,暴露给用户人工合并。或者业务代码中写复杂的合并逻辑:如果是字段级别的冲突,取非空值;如果是列表冲突,以追加方式合并。
这种方式的代表系统是CouchDB和Riak。它们会把冲突记录保留为多个叶子版本,由客户端来裁决。
4.4 数据一致性的现实权衡
多主复制中,一致性模型不像单主那么清晰。你必须接受“最终一致性”,而且最终是什么时候,取决于冲突解决的效率和网络延迟。我总结了三类典型模型:
| 模型 | 特点 | 适用场景 |
|---|---|---|
| 最终一致性 | 所有副本最终一致,但中间可能读到旧版本 | 强一致性要求不高的场景(如评论、点赞) |
| 因果一致性 | 有因果关系的操作按正确顺序呈现 | 社交媒体时间线、邮件系统 |
| 读写一致性 | 自己写入后立刻能读到最新值 | 需要“我改我见”的场景(如修改密码后的立即生效) |
你设计时要想清楚:“我的业务能容忍多久的不一致?” 如果是金融交易,多主复制几乎不可接受——必须走分布式事务或单主加仲裁。如果是用户修改头像,延迟几秒完全OK。
4.5 多主复制的架构示意图
下面这张图展示了两个数据中心的主主复制流程,以及冲突产生的时机。
4.6 实践中的冲突处理流程
我动手做过的系统中,一般按这个步骤处理多主冲突:
- 冲突检测:每个写入携带版本向量(Version Vector)或因果上下文。同步时比对,如果双方无因果关系(即并发修改),标记为冲突。
- 自动解决:根据业务提前设定的策略(LWW/CRDT/自定义规则)合并。自动解决失败则进入下一步。
- 手动解决:冲突版本保留为两个分支,暴露给运维或业务人员手动选择。这在CouchDB中称为“冲突树”。
- 收敛通知:解决后通知所有副本强制同步,避免老版本残留。
多主复制不是银弹,但它确实是实现高可用多活的关键技术。只要理解了冲突的本质,选对数据结构,配合合理的业务容错,就能把多主复制用得稳稳当当。你想想看,全球化的产品哪个不用多主?关键还是回到那句老话:没有免费的午餐。你要多点写入,就得承受冲突的成本。