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 特性 • 实现简单 • 数据丢失风险高 • 依赖时间戳 特性 • 字段级合并 • 同字段冲突需处理 • 适合配置数据 特性 • 逻辑时钟 • 可检测并发冲突 • 实现较复杂 特性 • 自动合并 • 无数据丢失 • 适合离线场景 选择建议 • 简单场景用 LWW,但别用在重要数据上 • 字段独立更新用合并写入,注意同字段冲突 • 需要精确冲突检测用版本向量 • 追求最终一致性、离线优先用 CRDT

四种策略没有的好坏,关键看你的业务场景。我个人习惯是:能用简单策略就别上复杂的。LWW 能解决的问题,就别折腾 CRDT。但如果你做的是金融、医疗这类对数据一致性要求极高的系统,那还是老老实实用版本向量或者 CRDT 吧。