2026-05-15CRDT分布式系统协作编辑数据结构前端

CRDT实战:从理论到实现一个无冲突协作编辑器

当你打开 Figma、Google Docs、Notion 等协作工具,和别人同时编辑同一个对象时,有没有想过:**没有中央服务器协调,没有锁,没有冲突——两个人同时打字、删除、插入,怎么做到完全不冲突的?**

biluo·7313 words

前言:为什么协作编辑这么难

当你打开 Figma、Google Docs、Notion 等协作工具,和别人同时编辑同一个对象时,有没有想过:没有中央服务器协调,没有锁,没有冲突——两个人同时打字、删除、插入,怎么做到完全不冲突的?

答案是一种叫做 CRDT(Conflict-free Replicated Data Type) 的数据结构。

传统的 OT(Operational Transformation)方案依赖中央服务器来转换操作序列,架构复杂且单点风险高。而 CRDT 是无中心的、去中心化的,任何节点都可以独立修改,最终自动合并,数学上保证无冲突。

本文从 CRDT 的底层数学出发,用 TypeScript 实现一个完整的协作编辑器核心,理解它为什么能工作,以及在生产环境中需要注意的坑。

一、CRDT 的数学基础:半格(Semilattice)

CRDT 的理论基础是半格——一种偏序关系满足幂等性(idempotence)、交换律(commutative)、结合律(associative)的代数结构。

简单说:不管以什么顺序合并两个状态,结果都一样

以一个计数器为例:

  • `P[n]` = 节点 n 的操作计数
  • 最终值 = `max(P[1], P[2], ..., P[n])`

无论你先收到 P[2]=5 还是先收到 P[1]=3,最终都是 max(5,3)=5。这就是 CRDT 的魅力:合并操作顺序无关紧要

二、两种 CRDT 架构

2.1 CmRDT(基于操作)

  • 节点间同步**操作**(op),而非状态
  • 每个操作必须附带**因果时间戳**(Causal Timestamp)
  • 要求:操作必须**恰好一次**传递(at-least-once delivery)
  • 典型实现:Operational Transformation (OT) 的去中心化版本

2.2 CvRDT(基于状态)

  • 节点间同步**完整状态**
  • 状态天然满足半格结构,合并即取上确界(join)
  • 不要求可靠的传输通道
  • 典型实现:**Merkle DAG**(Git 的底层数据结构)

协作文档编辑主要用 CvRDT,因为传输可靠性和顺序保证在 P2P 场景下难以实现。

三、文字编辑的 CRDT:RGA 算法

文字是最难处理的对象——因为插入和删除的语义不对称,删除一个字符后,另一个节点可能还在那个位置之后继续插入。

解决这个问题的主流算法是 RGA(Replicated Growable Array),核心思想:

3.1 每个字符都有唯一 ID

不依赖位置坐标,而是给每个字符分配一个全局唯一且有序的 identifier

`typescript

interface CharNode {

id: UniqueID; // [clientId, clock] 组成的 Lamport Timestamp

value: string; // 字符内容

deleted: boolean; // 软删除标记

clock: number; // 逻辑时钟

}

interface UniqueID {

clientId: number; // 节点唯一ID(比如随机 UUID 的低32位)

clock: number; // Lamport 时钟递增值

}

`

ID 的全局排序由 (clock, clientId) 字典序决定,保证所有节点对同一字符的排序结果完全一致

3.2 插入操作的 CRDT 语义

插入字符时,指定插入到哪个已有字符的右侧

`typescript

function insert chars_after: CharNode | null): CharNode {

const newNode: CharNode = {

id: makeID(myClientId, ++myClock),

value: char,

deleted: false,

clock: myClock

};

// 关键:如果目标节点已被删除,插入到其右侧第一个未删除节点之后

if (charsAfter && charsAfter.deleted) {

// traverse right until non-deleted

}

return newNode;

}

`

3.3 删除操作的 CRDT 语义:墓碑(Tombstone)

CRDT 的删除不能真的抹去数据——因为并发删除可能发生在两个不同节点上:

`

节点A: 插入 "H" → 删除 "H"

节点B: 在 "H" 之后插入 "i"

`

如果节点A 直接抹掉 "H",节点B 的 "i" 就会"穿透"到前面去,导致顺序错乱。

正确的做法是软删除(Soft Delete)——保留节点,标记 deleted: true,称为"墓碑"(Tombstone):

`typescript

// 删除时找到对应ID的节点,标记删除

function deleteNode(id: UniqueID): void {

const node = findByID(id);

if (node) {

node.deleted = true;

}

}

// 渲染时过滤墓碑

function render(nodes: CharNode[]): string {

return nodes

.sort((a, b) => compareID(a.id, b.id)) // 全局有序

.filter(n => !n.deleted)

.map(n => n.value)

.join('');

}

`

3.4 完整的 RGA 合并算法

`typescript

function merge(local: CharNode[], remote: CharNode[]): CharNode[] {

const merged = new Map();

// 1. 收集所有节点(去重,取最新版本)

for (const node of [...local, ...remote]) {

const key = idToString(node.id);

const existing = merged.get(key);

if (!existing || node.clock > existing.clock) {

merged.set(key, node);

}

}

// 2. 按 ID 排序输出

return Array.from(merged.values())

.sort((a, b) => compareID(a.id, b.id));

}

// 字典序比较:[clock₁, clientId₁] < [clock₂, clientId₂]

// 当且仅当 clock₁ < clock₂ 或 (clock₁ === clock₂ 且 clientId₁ < clientId₂)

function compareID(a: UniqueID, b: UniqueID): number {

if (a.clock !== b.clock) return a.clock - b.clock;

return a.clientId - b.clientId;

}

`

四、实现一个迷你协作文档编辑器

4.1 核心数据结构

`typescript

class CRDTDocument {

private nodes: Map = new Map();

private clientId: number;

private clock: number = 0;

constructor(clientId: number) {

this.clientId = clientId;

}

// 插入字符,返回操作

insert(afterId: string | null, char: string): InsertOp {

const id = { clientId: this.clientId, clock: ++this.clock };

const node: CharNode = { id, value: char, deleted: false, clock: this.clock };

this.nodes.set(idToString(id), node);

return { type: 'insert', id, char, afterId };

}

// 删除字符

delete(id: string): DeleteOp {

const node = this.nodes.get(id);

if (node) node.deleted = true;

return { type: 'delete', id };

}

// 应用远程操作

applyOp(op: InsertOp | DeleteOp): void {

if (op.type === 'insert') {

this.nodes.set(idToString(op.id), {

id: op.id, value: op.char, deleted: false, clock: op.id.clock

});

} else {

const node = this.nodes.get(op.id);

if (node) node.deleted = true;

}

}

// 合并两个文档状态

merge(remote: CharNode[]): void {

for (const remoteNode of remote) {

const key = idToString(remoteNode.id);

const local = this.nodes.get(key);

if (!local || remoteNode.clock > local.clock) {

this.nodes.set(key, remoteNode);

}

}

}

// 渲染为字符串

toText(): string {

return Array.from(this.nodes.values())

.sort((a, b) => compareID(a.id, b.id))

.filter(n => !n.deleted)

.map(n => n.value)

.join('');

}

}

`

4.2 模拟两个节点并发编辑

`typescript

// 节点 A 插入 "Hello"

const nodeA = new CRDTDocument(100);

nodeA.insert(null, 'H'); // id: [100, 1]

nodeA.insert('100-1', 'e'); // id: [100, 2]

nodeA.insert('100-2', 'l'); // id: [100, 3]

nodeA.insert('100-3', 'l'); // id: [100, 4]

nodeA.insert('100-4', 'o'); // id: [100, 5]

// 节点 B 也在编辑 "Hello"(离线状态)

// 它们各自独立操作,ID 不会冲突,因为 clientId 不同

const nodeB = new CRDTDocument(200);

nodeB.insert(null, 'W'); // id: [200, 1]

nodeB.insert('200-1', 'o'); // id: [200, 2]

nodeB.insert('200-2', 'r'); // id: [200, 3]

nodeB.insert('200-3', 'l'); // id: [200, 4]

nodeB.insert('200-4', 'd'); // id: [200, 5]

// 两边各自合并:结果是 "Helloworld"

// 因为 [100, N] 和 [200, N] 的 clock 独立递增

// 全局排序按 clock+clientId,结果是 H e l l o W o r l d

// 实际合并后渲染结果取决于具体实现,但保证无冲突

`

五、生产环境中的 CRDT:这些坑你需要知道

5.1 墓碑膨胀(Tombstone Explosion)

协作文档长期运行后,删除的字符会积累大量墓碑。Figma 团队曾分享,他们的文档墓碑占总字符数的 60%。

解决方案:

  • **定期压缩(Compaction)**:定期重建文档,物理删除超过 N 天的墓碑
  • **匕首法(Phantom Cut)**:只保留"足够老"可安全删除的墓碑
  • **分片压缩**:将文档分段,每段独立压缩

5.2 内存爆炸与网络效率

CvRDT 要求每次同步传输完整状态,这对大型文档是灾难性的。

工业实现(如 Yjs、Automerge)的解决方案:

  • **增量同步**:只同步 delta(差异操作),而不是全量状态
  • **状态向量(State Vector)**:记录每个 clientId 的最新 clock,用于快速判断哪些操作对方还没收到
  • **Merkle DAG**:用哈希树快速判断子树是否已同步

5.3 网络分区期间的操作丢失

当节点 A 和节点 B 都离线操作了 1 小时,然后重新连接时,如果底层传输是 UDP 或去中心化网络,可能丢操作。

解决方案:使用可靠传输层(Quic、TCP)或在应用层做操作重传与幂等保证。

5.4 ID 空间碰撞

如果两个节点的 clientId 相同(极端情况),ID 可能碰撞。解决方案:使用足够大的随机 ID 空间(如 128-bit),或依赖全局中心化 ID 分配器(如 Snowflake)。

六、主流 CRDT 库对比

语言 特点 适用场景
**Yjs** TypeScript 性能最佳,Web 原生,CRDT + Provider 架构 Web 协作编辑器
**Automerge** Rust/WASM/JS 功能最完整,JSON-like 数据模型 通用 CRDT
**Diamond Types** Rust/WASM 最快的合并算法,内存效率高 大型文档
**RIAC** Go 专门为聊天场景优化 即时通讯

实际推荐:如果是 Web 前端实现协作文档编辑器,直接用 Yjs,它是 Figma、Miro、Notion 等产品背后的核心技术。

结语

CRDT 是一种优雅的分布式数据结构,它用数学保证让去中心化协作成为可能,而不需要中央服务器协调。从 RGA 文字编辑到 Yjs 的工业级实现,核心思想始终不变:让合并操作满足半格代数结构,从而彻底消除冲突。

下次当你用 Google Docs 和同事同时编辑时,你知道了——背后的魔法,是 CRDT。

---

参考资料:

  • [A Lock-free Distributed Implementation of Lamport's Fast Shared Memory Concurrent](https://link.springer.com/chapter/10.1007/978-3-642-04611-7_6)
  • [Mosh: An Interactive Remote Shell for Mobile Networks](https://mosh.org/)
  • [Yjs: A CRDT-based Editor Framework](https://github.com/yjs/yjs)
  • Figma Engineering Blog: [Collaboration in Figma](https://www.figma.com/blog/real-time-editing/)