实时协作 CRDT 原理

从一致性问题切入 CRDT:对比 OT 与 CRDT 的取舍、讲清 state-based 与 op-based 两大流派、逐一实现 G-Counter、OR-Set、LWW-Register 等基础类型、剖析 RGA 与 YATA 序列 CRDT、给出 Yjs 与 Automerge 的工程对比,并附 GC、性能与常见坑。

多个用户同时编辑同一份文档,各自的操作经过不同延迟到达不同副本,如何保证所有副本最终收敛到同一状态?这是分布式系统的经典难题,也是实时协作产品的技术核心。两条主流路线是 OT(Operational Transformation)与 CRDT(Conflict-free Replicated Data Type)。

本文聚焦 CRDT,从数学基础讲到工程实现。读完你应该能理解为什么 Yjs 能在本地立即响应、为什么 CRDT 的元数据会膨胀,以及什么时候该选 CRDT、什么时候该选 OT。

一、为什么需要 CRDT

1.1 一致性问题

设想两个用户同时编辑一份文档。用户 A 在位置 0 插入 “X”,用户 B 在位置 0 插入 “Y”。如果没有协调机制,两个副本会得到不同结果:A 看到 “XY”,B 看到 “YX”。这就是并发冲突。

强一致方案(如加锁、单点串行化)能避免冲突,但代价是延迟:每次编辑都要等服务器确认,网络往返让输入有可感知的延迟。用户期望的是「本地立即响应」。

1.2 OT 与 CRDT 的路线差异

维度OTCRDT
核心思想变换操作使其可交换设计天然可交换的数据结构
依赖服务器强依赖,需中心排序可去中心,P2P 也可
元数据小较大,随编辑增长
实现难度变换函数复杂数据结构复杂
典型实现ShareDB、ot.jsYjs、Automerge

OT 需要对每个操作定义变换函数,保证「A 对 B 变换后」与「B 对 A 变换后」得到相同结果。这个变换函数的正确性证明很困难,尤其在三方以上并发时。CRDT 则把复杂度转移到数据结构设计上,一旦设计正确,收敛性是数学保证的。

二、CRDT 的两大流派

流派同步内容优点缺点
State-based(CvRDT)完整状态简单,抗丢包状态大
Op-based(CmRDT)操作传输小需可靠有序传输
Delta-based状态差量兼顾两者实现复杂

State-based 每次同步发送完整状态,通过合并函数(merge)保证收敛。它天然抗丢包与乱序,因为合并是幂等、交换、结合的。Op-based 只发送操作,传输量小,但要求操作可靠送达且因果有序。Delta-based 折中,发送状态差量,是 Yjs 与 Automerge 实际采用的方式。

三个数学性质是 CRDT 收敛的基石:

  • 结合律:merge(merge(a, b), c) = merge(a, merge(b, c))
  • 交换律:merge(a, b) = merge(b, a)
  • 幂等律:merge(a, a) = a

只要合并函数满足这三条,无论消息以何种顺序、重复多少次到达,最终状态都一致。

三、基础数据类型

3.1 G-Counter 与 PN-Counter

计数器是最简单的 CRDT。G-Counter 只增不减,每个副本维护自己的计数,总值是各副本之和:

class GCounter {
  constructor(replicaId) {
    this.id = replicaId;
    this.counts = new Map([[replicaId, 0]]);
  }

  increment(n = 1) {
    this.counts.set(this.id, (this.counts.get(this.id) || 0) + n);
  }

  value() {
    let sum = 0;
    for (const v of this.counts.values()) sum += v;
    return sum;
  }

  merge(other) {
    for (const [id, v] of other.counts) {
      this.counts.set(id, Math.max(this.counts.get(id) || 0, v));
    }
  }
}

const a = new GCounter("A");
const b = new GCounter("B");
a.increment(3);
b.increment(2);
a.merge(b);
b.merge(a);
console.log(a.value(), b.value()); // 5 5,收敛一致

PN-Counter 用两个 G-Counter 分别记录增量与减量,value() 返回两者之差,从而支持减法。

3.2 G-Set 与 OR-Set

G-Set 只增不删,合并就是并集。OR-Set(Observed-Remove Set)支持删除,它为每个元素附加唯一标签,删除时移除当前观察到的标签:

类型支持删除合并方式
G-Set否并集
2P-Set是,但不可重加增删两个集合之差
OR-Set是,可重加标签集合的并集与差集

OR-Set 的巧妙之处在于区分「删除发生在添加之前还是之后」。若一个元素被删除后又被重新添加,OR-Set 能正确保留它,而 2P-Set 无法做到。

3.3 LWW-Register

LWW(Last-Writer-Wins)寄存器用一个时间戳决定冲突时谁赢:

class LWWRegister {
  constructor() {
    this.value = null;
    this.timestamp = -Infinity;
  }

  set(value, timestamp) {
    if (timestamp > this.timestamp) {
      this.value = value;
      this.timestamp = timestamp;
    }
  }

  merge(other) {
    if (other.timestamp > this.timestamp) {
      this.value = other.value;
      this.timestamp = other.timestamp;
    }
  }
}

LWW 的隐患在于依赖时钟。若副本间时钟偏差过大,会丢失「更新的写入」。工程上通常用混合逻辑时钟(HLC)代替物理时钟。

四、序列 CRDT

文本编辑需要的是序列 CRDT,它要支持任意位置的插入与删除,且保持字符顺序。两个代表性设计是 RGA 与 YATA。

RGA(Replicated Growable Array)给每个字符一个唯一标识,插入时记录「插在谁后面」,删除只做标记而不真正移除。合并时按标识的全序排列:

// RGA 节点的简化表示
class RGANode {
  constructor(id, char, originId) {
    this.id = id;            // 全局唯一,如 "A:5"
    this.char = char;
    this.originId = originId; // 插入位置的左邻居
    this.deleted = false;
  }
}

function mergeRGA(local, remote) {
  const seen = new Set(local.map((n) => n.id));
  for (const node of remote) {
    if (!seen.has(node.id)) {
      local.push(node);
      seen.add(node.id);
    }
  }
  // 按 originId 与 id 排序,保证所有副本顺序一致
  local.sort((a, b) => {
    if (a.originId === b.originId) return a.id < b.id ? -1 : 1;
    return a.originId < b.originId ? -1 : 1;
  });
  return local;
}

YATA(Yet Another Transformation Approach,Yjs 采用)在 RGA 基础上引入了更精细的冲突解决规则:当两个节点有相同的左邻居时,通过比较右邻居来确定顺序。这让 YATA 在并发插入密集的场景下表现更稳定。

五、文本 CRDT 实战:Yjs

Yjs 是目前性能最好的文本 CRDT 实现之一,核心优势是二进制编码高效、支持共享类型丰富、生态完善。

import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";

const doc = new Y.Doc();
const text = doc.getText("content");

// 本地编辑立即生效,无需等待网络
text.insert(0, "Hello ");

// 远端更新自动合并
text.observe((event) => {
  console.log("文档变化,变更长度:", event.delta);
});

// 接入 WebSocket 同步
const provider = new WebsocketProvider("wss://example.com", "room-1", doc);
provider.on("status", (event) => {
  console.log("连接状态:", event.status); // connected / disconnected
});

// 手动编码状态,用于持久化或通过其他通道传输
const update = Y.encodeStateAsUpdate(doc);
Y.applyUpdate(doc, update);

Yjs 的关键设计是「本地先行」:所有编辑立即应用到本地文档,observe 回调同步触发,用户输入零延迟。网络同步在后台进行,合并是幂等的,因此断线重连后只需交换缺失的更新即可。

5.1 Yjs 与 Automerge 对比

维度YjsAutomerge
语言JavaScriptJavaScript / Rust
编码自定义二进制,紧凑JSON-like,可读性好
性能极快较快
生态丰富(编辑器绑定多)良好
适用高并发文本协作复杂数据结构

Yjs 更适合高频文本编辑,Automerge 的数据模型更通用,适合结构复杂的应用状态。

六、GC、性能与存储

6.1 元数据膨胀

CRDT 的每个字符都携带标识与来源信息,文档体积会显著大于纯文本。Yjs 通过压缩与「墓碑合并」缓解,但长期编辑的文档仍会膨胀。

6.2 垃圾回收

删除的节点不会立即从数据结构中移除,而是标记为墓碑,以保证并发合并的正确性。Yjs 提供了 GC 选项,在确认所有副本都已同步后清理墓碑:

const doc = new Y.Doc({ gc: true }); // 默认开启

关闭 GC 会保留完整历史,适合需要版本回溯的场景,但内存占用更高。

6.3 快照与持久化

长期运行的文档需要定期生成快照,避免每次加载都重放全部历史:

// 生成快照
const snapshot = Y.snapshot(doc);
const stateVector = Y.encodeStateVector(doc);

// 从快照恢复
const restored = Y.createDocFromSnapshot(doc, snapshot);

七、网络同步与增量传输

7.1 状态向量与增量

CRDT 的同步不能每次发送完整状态,否则带宽随文档增长。正确做法是用状态向量(State Vector)描述「我有哪些更新」,对端据此只发送我缺失的部分。

// 状态向量:记录每个副本已见过的最新时钟
function encodeStateVector(doc) {
  return Y.encodeStateVector(doc);
}

// 对端根据我的状态向量,生成我缺失的差量更新
function diffForPeer(doc, remoteStateVector) {
  return Y.encodeStateAsUpdate(doc, remoteStateVector);
}

// 应用差量更新
function applyRemoteUpdate(doc, update) {
  Y.applyUpdate(doc, update, "remote");
}

// 监听本地更新并广播
doc.on("update", (update, origin) => {
  if (origin === "remote") return; // 避免回环
  broadcast(update);
});

这套机制让断线重连变得简单:重连时双方交换状态向量,各自补发对方缺失的更新,即可恢复到一致状态,无需重放全部历史。

7.2 同步协议的分层

层职责典型实现
传输层消息收发WebSocket、WebRTC 数据通道
同步层状态向量交换与差量y-protocols
感知层光标、选区等临时状态Awareness 协议

Awareness 协议用于同步「非持久状态」,如谁的光标在什么位置、谁正在输入。这些状态不需要 CRDT 的强一致性保证,只需要最终消失或过期即可,因此与文档数据分开管理。

7.3 通过 WebRTC 数据通道同步

在 P2P 场景下,CRDT 更新可以直接走 RTCDataChannel,绕过服务器:

const pc = new RTCPeerConnection();
const channel = pc.createDataChannel("crdt", { ordered: true });

channel.onopen = () => {
  // 连接建立后先交换状态向量
  channel.send(JSON.stringify({ type: "sv", sv: Array.from(Y.encodeStateVector(doc)) }));
};

channel.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === "sv") {
    const remoteSV = new Uint8Array(msg.sv);
    const diff = Y.encodeStateAsUpdate(doc, remoteSV);
    channel.send(JSON.stringify({ type: "update", data: Array.from(diff) }));
  } else if (msg.type === "update") {
    Y.applyUpdate(doc, new Uint8Array(msg.data), "remote");
  }
};

doc.on("update", (update) => {
  if (channel.readyState === "open") {
    channel.send(JSON.stringify({ type: "update", data: Array.from(update) }));
  }
});

注意数据通道应设为 ordered: true,因为 CRDT 的合并虽然可交换,但状态向量的交换需要有序才能正确计算差量。

八、常见坑清单

  • 把 CRDT 当成「自动解决一切冲突」,忽略语义冲突(如两人同时修改同一表格单元格)。
  • 用物理时钟做 LWW 时间戳,时钟偏差导致更新丢失。
  • 关闭 GC 后文档无限膨胀,内存耗尽。
  • 直接同步完整状态而非差量,带宽随文档规模线性增长。
  • 忽略墓碑清理时机,在副本未完全同步时清理导致数据不一致。
  • 把 CRDT 元数据直接暴露给用户,界面上出现大量内部标识。
  • 在弱网下频繁发送完整状态,与断线重连逻辑冲突。
  • 未做服务端持久化,所有副本离线后数据永久丢失。

小结

CRDT 用可交换、可结合、幂等的合并函数,把一致性保证从「运行时协调」前移到了「数据结构设计」。它让本地编辑零延迟,让离线协作成为可能,代价是元数据膨胀与实现复杂度。基础类型(Counter、Set、Register)适用于简单状态,序列类型(RGA、YATA)才是文本协作的核心。Yjs 与 Automerge 把复杂的数据结构封装成了易用的 API,让工程实践的门槛大幅降低。CRDT 与 OT 并非互斥,理解 协同编辑与 OT 算法 能帮助你在特定场景做出更合适的选择。把 CRDT 用于白板与光标这类高频状态,可参考 在线白板与光标同步实战 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 信令服务与房间水平扩展
  3. 低延迟直播:LL-HLS 与 WebRTC 直播