多个用户同时编辑同一份文档,各自的操作经过不同延迟到达不同副本,如何保证所有副本最终收敛到同一状态?这是分布式系统的经典难题,也是实时协作产品的技术核心。两条主流路线是 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 的路线差异
| 维度 | OT | CRDT |
|---|---|---|
| 核心思想 | 变换操作使其可交换 | 设计天然可交换的数据结构 |
| 依赖服务器 | 强依赖,需中心排序 | 可去中心,P2P 也可 |
| 元数据 | 小 | 较大,随编辑增长 |
| 实现难度 | 变换函数复杂 | 数据结构复杂 |
| 典型实现 | ShareDB、ot.js | Yjs、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 对比
| 维度 | Yjs | Automerge |
|---|---|---|
| 语言 | JavaScript | JavaScript / 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 用于白板与光标这类高频状态,可参考 在线白板与光标同步实战 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。