4、合并策略入门:最后写入胜出(LWW)、合并写入、版本向量、CRDT基础
数据冲突,说白了就是同一个数据在不同地方被改了,最后该听谁的?
我刚开始做分布式系统那会儿,觉得这事儿挺简单——谁最后写进去就听谁的呗。后来被坑了几次才发现,不同的业务场景,合并策略的选择天差地别。今天咱们就把四种最基础的合并策略掰开揉碎了讲清楚。
4.1 最后写入胜出(LWW)
LWW 是最简单粗暴的策略。它的逻辑就一句话:以时间戳最大的那个版本为准。
核心思想:每个写操作都带一个时间戳,合并时只保留时间戳最大的那个值,其他全部丢弃。
我在项目中遇到过这样一个场景:用户修改个人资料,A 节点和 B 节点同时收到了修改请求。A 节点改了手机号,B 节点改了邮箱。如果用 LWW,就看谁的时间戳更大。时间戳大的那个会把另一个的修改完全覆盖掉。
嗯,这里要注意——LWW 的代价是数据丢失。你想想看,如果两个修改都是合法的,只保留一个,另一个就白改了。
| 优点 | 缺点 |
|---|---|
| 实现简单,性能高 | 会丢失数据,不适用于重要业务 |
| 不需要复杂的合并逻辑 | 依赖可靠的时间戳同步 |
| 适合日志、监控等场景 | 无法处理并发写入的语义冲突 |
避坑指南:我曾经在金融系统里看到有人用 LWW 处理账户余额更新,结果两个并发交易互相覆盖,直接导致资金对不上。LWW 只适合那些「丢了也不心疼」的数据,比如用户最后登录时间、缓存刷新标记。
4.2 合并写入(Merge Write)
合并写入比 LWW 聪明一点。它不直接覆盖,而是把多个版本的值合并成一个新值。
举个例子:一个 JSON 对象 {name: "张三", age: 30},A 节点改了 name 为 "李四",B 节点改了 age 为 31。合并写入的结果是 {name: "李四", age: 31}——两个修改都保留了。
// 合并写入的伪代码示例
function mergeWrite(oldValue, changeA, changeB) {
// 按字段级别合并
let result = { ...oldValue };
for (let key in changeA) {
result[key] = changeA[key];
}
for (let key in changeB) {
result[key] = changeB[key];
}
return result;
}
但合并写入也有坑。如果两个修改改了同一个字段怎么办?比如 A 把 name 改成 "李四",B 把 name 改成 "王五"。这时候合并写入就傻眼了——它不知道该听谁的。
我的建议:合并写入最适合字段级独立更新的场景。比如用户资料、配置信息,每个字段的修改互不干扰。如果业务上允许字段级冲突,那就需要更高级的策略了。
4.3 版本向量(Version Vector)
版本向量是我个人比较喜欢的一种策略。它不靠时间戳,而是靠逻辑时钟来追踪数据的变更历史。
每个节点维护一个向量,记录自己和其他节点的版本号。比如三个节点 A、B、C,版本向量可能是 [A:3, B:2, C:1],表示 A 节点改了 3 次,B 改了 2 次,C 改了 1 次。
当两个版本需要合并时,比较它们的向量:
- 如果向量 A 的所有分量都大于等于向量 B,说明 A 是 B 的后继版本,直接覆盖
- 如果两个向量互有大小(比如 A 的某个分量大,B 的另一个分量大),说明发生了并发冲突,需要人工或自动解决
关键点:版本向量能检测出「真正的并发冲突」,而不是像 LWW 那样盲目覆盖。它告诉你「这两个修改是冲突的,你来决定怎么办」。
我记得在做一个协同编辑系统时,就用版本向量来追踪文档的修改历史。两个用户同时编辑同一段文字,版本向量能准确判断出谁先谁后,或者是不是并发冲突。
4.4 CRDT 基础
CRDT(Conflict-free Replicated Data Type)是合并策略的终极形态。它的目标是:不管数据怎么同步,最终都能自动合并成一致的状态,不需要人工干预。
CRDT 有两种实现方式:
- 基于操作(Op-based): 只同步操作日志,每个节点重放操作
- 基于状态(State-based): 同步整个状态,通过合并函数合并
举个最简单的 CRDT 例子——G-Counter(增长计数器):
// G-Counter 的合并逻辑
class GCounter {
constructor() {
this.counts = {}; // 每个节点一个计数
}
increment(nodeId) {
this.counts[nodeId] = (this.counts[nodeId] || 0) + 1;
}
merge(other) {
for (let nodeId in other.counts) {
this.counts[nodeId] = Math.max(
this.counts[nodeId] || 0,
other.counts[nodeId]
);
}
}
value() {
return Object.values(this.counts).reduce((a, b) => a + b, 0);
}
}
你想想看,G-Counter 为什么能自动合并?因为它把计数拆成了每个节点的独立分量,合并时取最大值,永远不会丢失数据。
实际应用:CRDT 被广泛用在离线优先的应用里,比如 Notion、Google Docs 的离线编辑。我参与过一个物联网项目,设备离线时采集数据,上线后自动同步,用的就是 CRDT 的变体。
4.5 四种策略对比
说了这么多,咱们用一张表总结一下:
| 策略 | 数据丢失风险 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| LWW | 高 | 低 | 日志、缓存、非关键数据 |
| 合并写入 | 中 | 中 | 字段级独立更新的配置、资料 |
| 版本向量 | 低(可检测冲突) | 高 | 需要精确冲突检测的场景 |
| CRDT | 极低 | 高 | 离线优先、协同编辑、IoT |
4.6 知识体系总览
下面这张图把四种策略的关系和适用边界画清楚了:
四种策略没有的好坏,关键看你的业务场景。我个人习惯是:能用简单策略就别上复杂的。LWW 能解决的问题,就别折腾 CRDT。但如果你做的是金融、医疗这类对数据一致性要求极高的系统,那还是老老实实用版本向量或者 CRDT 吧。