
远程协作实时编辑的技术选型CRDT、OT 与混合方案一、异步文档协作的一致性挑战远程团队使用 Google Docs 协同编辑需求文档时一切正常。切换到自研的 Wiki 系统后两个成员同时编辑同一段落保存后发生了数据覆盖——后保存的人覆盖了先保存的人的修改。团队尝试用编辑锁解决谁先点编辑按钮就锁定文档。但锁的粒度太粗导致等待时间长粒度太细又丢失了协作的初衷。实时协同编辑的核心技术问题是多个用户同时修改同一文档的不同位置时如何保证所有人最终看到一致的内容且不丢失任何人的修改。这个问题在分布式系统中被称为最终一致性但文档编辑场景对延迟的要求远高于数据库——200ms 以上的延迟用户就能感知到卡顿。二、CRDT 与 OT 两种算法的原理对比两种主流协同编辑算法的核心差异graph TB subgraph OT操作转换 OT1[用户 A在第5个字符后插入 X] OT2[用户 B删除第3个字符] OT1 -- OT3[OT 算法转换操作位置] OT2 -- OT3 OT3 -- OT4[用户 A 的操作转换为在第4个字符后插入] OT4 -- OT5[最终结果一致] end subgraph CRDT无冲突复制数据类型 CRDT1[每个字符有唯一标识br/position siteId counter] CRDT2[用户 A字符 X 被赋予标识 #5-A-1] CRDT3[用户 B字符 Y 被赋予标识 #3-B-2] CRDT2 -- CRDT4[按标识排序合并br/无需中心服务器协调] CRDT3 -- CRDT4 end subgraph 混合方案 H1[CRDT 用于文本内容br/本地优先离线可用] H2[OT 用于结构化操作br/如移动段落、修改样式] H1 -- H3[最终合并] H2 -- H3 endOT 的核心思想是转换操作——当两个操作冲突时通过算法将操作转换到对方的时间线后执行。OT 需要一个中心服务器对所有操作进行全局排序因此对网络延迟敏感。CRDT 的核心思想是数据结构天然可合并——每个字符都有全局唯一标识多个用户的修改可以任意顺序合并且结果一致。CRDT 不依赖中心服务器排序天然支持离线编辑。三、基于 YjsCRDT的协同编辑实现// 协同编辑器——基于 Yjs CRDT 的实时协作 // 设计意图使用 Yjs 的 Y.Text 类型管理文档内容 // 通过 awareness 协议同步光标位置 import * as Y from yjs; import { WebsocketProvider } from y-websocket; import { Awareness } from y-protocols/awareness; // 文档数据结构定义 interface CollaborativeDoc { // 正文——使用 Y.Text 支持富文本编辑 content: Y.Text; // 文档标题——独立字段高频变更 title: Y.Text; // 元数据——低频变更 metadata: Y.Mapunknown; } class CollaborativeEditor { private ydoc: Y.Doc; private wsProvider: WebsocketProvider; private awareness: Awareness; constructor(roomId: string, userId: string, userName: string) { // 创建 Yjs 文档——所有协同状态都存储在这个对象中 this.ydoc new Y.Doc(); // 连接到 WebSocket 信令服务器 this.wsProvider new WebsocketProvider( wss://collab.example.com, roomId, this.ydoc, { // 连接参数——用于服务端鉴权 params: { token: this.getAuthToken() }, // 重连配置 maxBackoffTime: 10000, // 最大重连间隔 10s } ); // 监听连接状态——用于 UI 展示 this.wsProvider.on(status, (event: { status: string }) { switch (event.status) { case connected: this.onConnectionChange(已连接); break; case connecting: this.onConnectionChange(连接中...); break; case disconnected: this.onConnectionChange(已断开尝试重连...); break; } }); // Awareness——同步用户光标和在线状态 this.awareness this.wsProvider.awareness; this.awareness.setLocalState({ user: { id: userId, name: userName, color: this.getUserColor(userId) }, cursor: null, // 光标位置由编辑器插件更新 }); // 监听远端用户的 Awareness 变化 this.awareness.on(change, () { const states this.awareness.getStates(); this.onRemoteCursorsChange(states); }); } // 获取文档正文的 Y.Text 引用 getContent(): Y.Text { return this.ydoc.get(content, Y.Text) as Y.Text; } // 监听正文变化——用于更新编辑器视图 onContentChange(callback: (text: string) void): void { this.getContent().observe(() { callback(this.getContent().toString()); }); } // 处理网络断线后的离线变更合并 private setupOfflineRecovery(): void { // 监听同步完成事件——断线重连后触发 this.ydoc.on(sync, (isSynced: boolean) { if (isSynced) { // CRDT 自动处理了冲突——所有离线期间的修改 // 以无冲突的方式合并到最新文档状态 console.log(离线变更已同步); } }); } // 销毁时清理资源 destroy(): void { this.wsProvider.disconnect(); this.ydoc.destroy(); } private getAuthToken(): string { return localStorage.getItem(collab_token) || ; } private getUserColor(userId: string): string { const colors [#FF6B6B, #4ECDC4, #45B7D1, #96CEB4]; const index userId.split().reduce((acc, c) acc c.charCodeAt(0), 0); return colors[index % colors.length]; } private onConnectionChange(status: string): void {} private onRemoteCursorsChange(states: Mapnumber, unknown): void {} }三个关键设计CRDT 天然支持离线编辑——断线期间的修改在重连后自动合并无需额外的冲突解决逻辑Awareness 协议管理光标位置和在线状态——与正文同步分离减少不必要的数据传输WebSocket 自动重连——maxBackoffTime控制重连节奏防止断线后疯狂重试。四、CRDT 的隐性限制与替代方案存储膨胀问题。CRDT 为每个字符保留位置标识长文档10 万字以上的元数据体积可能超过正文。Yjs 通过分段压缩Chunk Compression缓解了这个问题但对于超大文档定期生成快照Snapshot并清理历史元数据是必要的。不适合结构化操作的场景。移动一个段落将第 50-55 行移到第 20 行前在 CRDT 中等效为删除 50-55 行 在 20 行前插入。如果此时有人正在编辑第 53 行移动操作会将他的编辑也带上。OT 对这类结构化操作的处理更精确。服务端负载。虽然 CRDT 不需要服务端做操作排序但 WebSocket 信令服务器仍需转发所有变更。10 人同时编辑时每个字符的变更都会广播给其他 9 人。服务端需要做频率限制将多次快速打字合并为一次广播。五、总结实时协同编辑的技术选型CRDTYjs——适合富文本编辑场景天然支持离线协作不依赖中心服务器排序OT——适合结构化操作场景需要中心服务器协调对网络延迟敏感混合方案——文本编辑用 CRDT结构化操作用 OT。落地建议从 Yjs y-websocket 开始这是文档协同场景性价比最高的组合长文档10 万字需要定期生成快照防止元数据膨胀服务端需要实现频率限制将连续输入合并为批量广播做好离线状态的 UI 提示——断线不丢失编辑但应告知用户当前状态。