实时协同编辑方案深度对比:OT 与 CRDT 的工程实践与架构选型
实时协同编辑方案深度对比:OT 与 CRDT 的工程实践与架构选型
一、协同编辑的灵魂拷问:为什么 CRDT 近年替代了 OT?
实时协同编辑(Google Docs 风格的多光标同步)在工程界经历了从 OT(Operational Transformation)到 CRDT(Conflict-free Replicated Data Type)的范式转移。这个转变并非因为 OT 已过时——Google Docs 至今仍在大规模使用 OT——而是因为新一代协同应用(Notion、Figma、Linear)的业务模型更适合 CRDT 的去中心化设计。
两者的核心分歧在于如何处理并发编辑:
- OT依赖一个中心服务器进行操作的转换和排序。用户 A 在位置 5 插入字符,用户 B 在位置 10 删除字符,服务器接收两者的操作后,通过变换函数将它们调整为相对于同一文档状态的等价操作。
- CRDT为每个字符分配全局唯一的 ID(通常是 Lamport 时间戳 + 客户端 ID),两个用户的插入操作无需服务器仲裁——它们根据 ID 的偏序关系自动确定位置,不存在"冲突",因此也不需要"解决冲突"。
这意味着 CRDT 天然支持去中心化(P2P 协同),而 OT 高度依赖中心服务器。但 CRDT 也有代价:文档的元数据体积远大于 OT,意味着内存占用和网络传输量更高。
二、OT 与 CRDT 的底层机制对比
2.1 OT:操作变换的数学保证
OT 的核心是两个操作变换函数:
- IT(Inclusion Transformation):
IT(opA, opB)返回opA'——opA在opB已应用上下文中的等价操作。例如:A 在位置 3 插入字符,B 在位置 1 插入了 5 个字符,A 的操作需要变换为在位置 8 插入。 - ET(Exclusion Transformation):
ET(opA, opB)返回opA'——opA在opB被撤销上下文中的等价操作。更复杂,部分 OT 实现(如 Google Wave OT)不支持真正的撤销。
OT 的正确性依赖两个数学属性:
- CP1(收敛性):对于任意两个操作 a 和 b,
IT(a, b)和IT(b, a)应用于相同初始状态后,最终文档状态一致。 - CP2(变换的关联性):对于任意三个操作 a, b, c,
IT(IT(a, b), IT(c, b))等于IT(IT(a, c), IT(b, c))。
CP2 的验证极其困难。Google Wave 的 OT 实现在早期版本中曾因变换函数不满足 CP2 而导致文档状态发散,修复此问题花费了数月时间。这是 CRDT 被认为"更简单"的主要原因——它不需要实现和验证复杂的变换函数。
2.2 CRDT:基于 ID 偏序的无冲突合并
CRDT 的基础数据结构是 RGA(Replicated Growable Array)或 YATA(Yjs 使用的结构)。每个字符被表示为一个三元组:
(id, originLeft, originRight, value)id:全局唯一的逻辑时间戳([lamportClock, clientId])originLeft:插入位置左侧字符的 ID(如果是最左端则为ROOT_LEFT)originRight:插入位置右侧字符的 ID(如果是最右端则为ROOT_RIGHT)
当两个用户同时在同一位置插入字符时(例如都在 "ab" 的 a 和 b 之间插入),CRDT 按以下优先级确定顺序:
- 比较
originLeft:originLeft更靠后的字符排在前面。 - 比较
originRight:originRight更靠前的字符排在前面。 - 比较
id(作为最终决胜):id更大的排在前面。
这套规则确保所有客户端在同一条数据上执行后,得到的字符序列完全一致。
2.3 两者在工程上的核心差异
| 维度 | OT | CRDT |
|---|---|---|
| 离线编辑支持 | 困难(需要重放和变换离线操作) | 天然支持(本地修改后同步) |
| P2P 协同 | 困难(需要中心变换服务器) | 天然支持 |
| 文档元数据体积 | 小(仅存操作历史) | 大(每个字符存 3 个邻近 ID) |
| 实现复杂度 | 变换函数极难正确实现 | 合并规则固定但需处理 GC |
| 服务端存储 | 操作日志(可重放) | 完整文档快照 + 增量更新 |
| 内存占用 | 低 | 比 OT 高 3~5 倍 |
| 成熟的开源实现 | ShareJS, ShareDB | Yjs, Automerge |
三、生产级 CRDT 协同编辑实现
/** * 基于 Yjs 的实时协同编辑前端集成 * 核心关注:连接管理、感知光标、冲突处理、离线恢复 */ import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; import { Awareness } from 'y-protocols/awareness'; import { IndexeddbPersistence } from 'y-indexeddb'; // ---- 文档初始化与持久化 ---- interface CollaborationSession { doc: Y.Doc; provider: WebsocketProvider; awareness: Awareness; indexedDB: IndexeddbPersistence; type: Y.Text; // 共享文本类型 } /** * 创建协同编辑会话 * 包含三层数据同步:内存(Y.Doc)→ IndexedDB(离线)→ 服务器(通过 WebSocket) */ function createSession(roomId: string): CollaborationSession { // 1. 创建 Yjs 文档实例 const doc = new Y.Doc(); // 2. 在文档中声明共享类型 // 文档内容使用 Y.Text(自动处理并发插入的 CRDT 结构) const type = doc.getText('content'); // 3. IndexedDB 持久化(离线存储 + 快速启动) const indexedDB = new IndexeddbPersistence(roomId, doc); indexedDB.on('synced', () => { console.log('[Yjs] 本地数据已从 IndexedDB 加载完成'); }); // 4. WebSocket 连接到协同服务器 const wsUrl = `wss://collab-server.example.com/${roomId}`; const provider = new WebsocketProvider(wsUrl, roomId, doc, { connect: true, // 断连后自动重连,默认使用指数退避 maxBackoffTime: 30_000, }); // 5. 协同感知(Awareness):跟踪在线用户和光标位置 const awareness = provider.awareness; // 设置本地用户状态 awareness.setLocalState({ name: `User-${Math.random().toString(36).slice(2, 6)}`, color: getRandomColor(), cursor: null, // { index: number, length: number } }); // 监听远程用户状态变更 awareness.on('change', () => { const states = awareness.getStates(); updateRemoteCursors(states); }); // 6. 断连诊断日志 provider.on('status', (event: { status: string }) => { switch (event.status) { case 'connected': console.log('[Yjs] WebSocket 已连接'); break; case 'disconnected': console.warn('[Yjs] WebSocket 已断开,尝试重连…'); // Yjs WebsocketProvider 自动处理重连 // 离线期间的编辑保存在本地 Y.Doc 中 // 重连后通过 Sync Step 1/2 自动同步差异 break; } }); // 7. 冲突日志(用于排查并发问题) type.observe((event: Y.YTextEvent) => { // Y.Text 的 observe 在每次插入/删除时触发 // 对于 debug 模式,可以记录操作来源 if (event.transaction.origin) { console.log( `[Yjs] 文本变更: origin=${event.transaction.origin}, delta=${JSON.stringify(event.delta)}` ); } }); return { doc, provider, awareness, indexedDB, type }; } /** * 感知光标更新 */ function updateRemoteCursors( states: Map<number, { name: string; color: string; cursor: { index: number; length: number } | null }> ): void { // 遍历所有在线用户,渲染光标位置 states.forEach((state, clientId) => { if (state.cursor) { // 在编辑器中渲染其他用户的光标 // cursor.index → 光标在文本中的位置 // state.color → 光标颜色(每个用户分配不同颜色) renderRemoteCursor(clientId, state.name, state.color, state.cursor); } }); } // ---- 离线编辑与恢复 ---- /** * 检查离线编辑数据是否完整 * IndexedDB 中的数据 + 服务器数据 = 完整文档 */ async function verifyOfflineData( session: CollaborationSession ): Promise<boolean> { try { // 检查 IndexedDB 中是否有未同步的数据 const hasLocalChanges = session.indexedDB.synced === false; if (hasLocalChanges) { console.log('[Yjs] 检测到离线编辑数据,将在重连后自动同步'); } // Yjs 的 Sync Protocol 会在 WebSocket 重连后自动执行: // 1. Sync Step 1: 客户端发送本地状态向量(State Vector) // 2. Sync Step 2: 服务端返回客户端缺失的操作 + 确认已接收的操作 // 3. Update: 双方交换各自的增量变更 return true; } catch (err) { console.error('[Yjs] 离线数据校验失败:', err); return false; } } // ---- 撤销/重做(Undo/Redo) ---- class UndoManager { private undoStack: Y.UndoManager; /** * Yjs 内置的 UndoManager 自动跟踪所有共享类型的变更 * scope 参数限定跟踪范围(仅跟踪 'content' 类型) */ constructor(private type: Y.Text) { this.undoStack = new Y.UndoManager([type], { // 同一用户的连续操作在 500ms 内合并为一个 undo 单元 captureTimeout: 500, }); } undo(): void { if (this.undoStack.undoStack.length > 0) { this.undoStack.undo(); } } redo(): void { if (this.undoStack.redoStack.length > 0) { this.undoStack.redo(); } } get canUndo(): boolean { return this.undoStack.undoStack.length > 0; } get canRedo(): boolean { return this.undoStack.redoStack.length > 0; } } // ---- 冲突处理策略 ---- /** * 对于 Y.Text 类型,CRDT 自动处理字符级冲突 * 但对于结构化数据(Y.Map),可能需要自定义冲突策略 * * 例如:两个用户同时修改同一字段,保留谁的值? */ interface ProfileData { title: string; tags: string[]; status: 'draft' | 'review' | 'published'; } function setupStructuredConflict(doc: Y.Doc): void { const profile = doc.getMap('profile'); // 场景:用户 A 和 B 同时修改 title // Y.Map 的默认行为:后到达的操作覆盖前者(Last Writer Wins) // 对于不需要合并的字段(如 title),LWW 是可接受的 // 对于需要合并的字段(如 tags),使用 Y.Array const tags = doc.getArray('tags'); // 对于需要自定义合并逻辑的字段,使用 observe 拦截: profile.observe((event: Y.YMapEvent<any>) => { for (const [key, change] of event.changes.keys) { if (change.action === 'update' && key === 'status') { const newValue = profile.get(key); const oldValue = change.oldValue; // 自定义状态合并规则: // published > review > draft const priority: Record<string, number> = { draft: 0, review: 1, published: 2, }; if (priority[oldValue] > priority[newValue]) { // 恢复旧值(不允许状态降级) profile.set(key, oldValue); } } } }); } // ---- 辅助函数 ---- function getRandomColor(): string { const colors = [ '#ff4d4f', '#ff7a45', '#ffa940', '#ffc53d', '#73d13d', '#36cfc9', '#40a9ff', '#597ef7', '#9254de', ]; return colors[Math.floor(Math.random() * colors.length)]; } function renderRemoteCursor( clientId: number, name: string, color: string, cursor: { index: number; length: number } ): void { // 在编辑器中渲染远程用户光标 console.log(`Cursor: ${name} at ${cursor.index} (${color})`); } // ---- 导出 ---- export { createSession, UndoManager, setupStructuredConflict, verifyOfflineData }; export type { CollaborationSession };四、CRDT 的工程陷阱与性能边界
4.1 文档元数据膨胀与 GC
CRDT 的"墓碑"问题:删除的字符不会从数据结构中移除,而是被标记为"已删除"(这是保证并发安全的基础——一个用户可能正在另一个设备上引用这段被删除的内容)。随着编辑量的增加,文档元数据(ID、originLeft、originRight)持续累积。
Yjs 的解决方案是定期执行 GC(垃圾回收):
- 当确定所有客户端都已接收到某次删除操作,且删除的字符不可能再被引用时,可以安全地从文档中移除墓碑。
- GC 的触发条件是所有客户端的时钟都超过了该操作的删除时间(通过 State Vector 判断)。
- 每次 GC 的执行成本为 O(n)(n = 删除字符数),建议在用户空闲时执行(闲时 > 5 秒)。
4.2 大文档的初始化加载时间
一个 10 万字符的文档(技术长文级别),其 Yjs 文档快照体积约为 300KB~800KB(包含元数据)。在首次加载时,IndexedDB 读取(本地)约为 50ms~200ms,WebSocket 同步(远程)取决于网络状况。
优化策略:
- 增量同步:Yjs 的 Sync Protocol 天然支持增量更新。首次连接只同步 State Vector 的差异,不传输完整文档。
- 文档分片:将大文档按章节拆分为多个 Y.Doc 实例(content-chapter-1, content-chapter-2…),用户只加载当前编辑的章节。
- 懒加载:对于只读用户(查看者),使用服务端渲染的静态 HTML 代替 Y.Doc 实例,仅在进入编辑模式时才加载协同引擎。
4.3 多类型协同的冲突策略矩阵
CRDT 对不同数据类型有不同的冲突处理行为。理解这些默认行为对于减少意外结果至关重要:
| 数据类型 | 并发操作 | CRDT 行为 | 是否可自定义 |
|---|---|---|---|
| Y.Text | A 插入 & B 插入 | 两者插入,按 ID 排序 | 否 |
| Y.Text | A 插入 & B 删除(覆盖区域) | 删除部分被覆盖的插入 | 否 |
| Y.Map | A set & B set(同 key) | LWW(后到达覆盖前者) | 是(observe 拦截) |
| Y.Array | A push & B push | 两者追加,顺序取决于到达时间 | 否 |
| Y.Array | A push & B delete(同 index) | 删除操作先于插入 | 部分(observe 拦截) |
五、总结
CRDT 和 OT 并非对手,而是针对不同场景的最优解。CRDT 的离线编辑和去中心化能力使其成为新一代协同应用的默认选择,而 OT 在中心化架构(如 Google Docs)下仍有其内存效率和成熟度优势。
选型建议:如果产品有离线编辑、P2P 协同、或多设备同步需求,选择 CRDT(Yjs 是当前最成熟的开源实现);如果产品始终在线、服务端有充足的运算能力、且需要支持 100+ 并发协作者,OT(ShareDB)可能更合适。对于大多数"3~20 人实时协作文档"的场景,CRDT + Yjs 的组合已经足够可靠。
在具体实现中,最容易被忽视的不是协同算法本身,而是用户感知的体验——远程光标的位置延迟(< 200ms 理想)、离线恢复的数据一致性校验、以及冲突发生时(极少情况下 CRDT 产生非预期结果)的自动回退策略。协同编辑的用户信任度建立在"即使出现极端情况,数据也不会丢失"的保障之上,这是比算法选择更重要的一层工程防御。
