Google Docs、Figma、Notion——这些产品让「多人同时编辑」成为用户的基本预期。但如果你尝试自己实现,会发现一个残酷的事实:并发编辑的冲突解决是分布式系统中最难的问题之一。传统方案 OT(Operational Transform)依赖中心化服务器仲裁,算法复杂度随操作类型指数增长,Google Docs 团队花了 5 年才让 OT 在生产环境中稳定运行。而 CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)提供了一条截然不同的路径:通过数学证明保证任意顺序合并操作都能收敛到一致状态,无需中心化协调。
根据 npm 下载数据,Yjs(最主流的 CRDT 库)在 2026 年的周下载量已突破 450 万,被 Notion、Tiptap、BlockNote、Hocuspocus 等知名项目采用。如果你正在构建需要实时协作的应用,理解 CRDT 不再是可选项——它是必备技能。
🔬 一、CRDT 核心原理:为什么它能「无冲突」
1.1 OT 的痛点:为什么 Google 不用它做离线协作
OT(Operational Transform,操作变换)的核心思想是:当两个用户同时编辑时,服务器对操作进行「变换」,使它们在不同顺序执行时产生相同结果。听起来很美好,但实际工程中有三个致命问题:
-
变换函数的组合爆炸:每新增一种操作类型,就需要为所有已有操作类型编写变换函数。如果有 N 种操作,变换函数的数量是 O(N²)。Google Wave 项目正是因为无法维护这些变换函数而失败。
-
强依赖中心化服务器:OT 需要一个「权威源」来决定操作顺序。这意味着无法真正支持离线编辑——用户离线后编辑的内容,必须等上线后由服务器合并。
-
实现复杂度极高:即使是纯文本的 OT,正确实现也需要处理大量边界情况。富文本(带格式的文本)的 OT 实现复杂度再上一个数量级。
1.2 CRDT 的数学保证:交换律、结合律、幂等性
CRDT 的核心洞察是:如果合并操作满足三个数学性质,就能保证所有副本最终收敛到相同状态:
| 数学性质 | 含义 | 在 CRDT 中的体现 |
|---|---|---|
| 交换律(Commutative) | A ∘ B = B ∘ A | 操作顺序不影响最终结果 |
| 结合律(Associative) | (A ∘ B) ∘ C = A ∘ (B ∘ C) | 分组方式不影响最终结果 |
| 幂等性(Idempotent) | A ∘ A = A | 重复应用同一操作不会改变结果 |
📌 记住: CRDT 的「无冲突」不是说没有冲突发生,而是说冲突能在数学层面自动解决。你不需要写任何冲突处理逻辑——数据结构本身保证了收敛。
1.3 三种基础 CRDT 类型
CRDT 有三种经典实现,每种适用于不同场景:
G-Counter(只增计数器):每个节点维护自己的计数,读取时求和。点赞数、页面浏览量等场景的理想选择。
// G-Counter:分布式只增计数器的最简实现
class GCounter {
constructor(nodeId) {
this.nodeId = nodeId;
this.counts = {}; // { nodeId: count }
}
// 递增本节点的计数
increment() {
this.counts[this.nodeId] = (this.counts[this.nodeId] || 0) + 1;
}
// 合并另一个副本的状态——核心 CRDT 合并逻辑
merge(other) {
const allKeys = new Set([...Object.keys(this.counts), ...Object.keys(other.counts)]);
for (const key of allKeys) {
this.counts[key] = Math.max(this.counts[key] || 0, other.counts[key] || 0);
}
}
// 读取总计数
get value() {
return Object.values(this.counts).reduce((sum, v) => sum + v, 0);
}
}
// 模拟两个节点并发递增
const nodeA = new GCounter('A');
const nodeB = new GCounter('B');
nodeA.increment(); // A: 1, B: 0
nodeA.increment(); // A: 2, B: 0
nodeB.increment(); // A: 0, B: 1
// 合并:无论先合并谁,最终结果都是 3
nodeA.merge(nodeB);
console.log(nodeA.value); // 3 ✅
nodeB.merge(nodeA);
console.log(nodeB.value); // 3 ✅ — 收敛!
LWW-Register(Last-Writer-Wins 寄存器):每次写入带时间戳,冲突时取时间戳最大的值。适合配置项、用户资料等「最后一次写入覆盖」的场景。
OR-Set(Observed-Remove Set):支持添加和删除的集合,通过为每个元素附加唯一标签来解决「同时添加和删除」的冲突。Todo 列表、标签管理的理想选择。
💡 提示: 上面三种是「状态型 CRDT(CvRDT)」——直接合并完整状态。还有一种「操作型 CRDT(CmRDT)」——合并操作本身。Yjs 使用的是操作型方案,因为操作型在传输时更节省带宽。
🚀 二、实战:用 Yjs 构建实时协同编辑器
2.1 Yjs 的核心抽象:Y.Doc 与共享类型
Yjs 是目前最成熟的 CRDT 库,它提供了三种核心共享类型:
- Y.Text:纯文本 CRDT,支持高效插入/删除
- Y.XmlFragment / Y.XmlText:富文本 CRDT,支持格式标记嵌套
- Y.Map / Y.Array:JSON 结构 CRDT,适合表单数据、画布对象等
// Yjs 基础:创建共享文档并同步
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
// 创建共享文档
const doc = new Y.Doc();
// 定义共享文本类型
const yText = doc.getText('editor-content');
// 监听远程变更
yText.observe(event => {
event.changes.delta.forEach(change => {
if (change.insert) {
console.log('远程插入:', change.insert);
}
if (change.delete) {
console.log('远程删除:', change.delete, '个字符');
}
});
});
// 本地编辑——操作会自动 CRDT 合并
yText.insert(0, 'Hello '); // 插入文本
yText.insert(6, 'World!'); // 继续插入
// 连接 WebSocket 同步服务
const provider = new WebsocketProvider(
'wss://your-server.com', // Yjs 服务器地址
'document-id-123', // 文档唯一标识
doc // Y.Doc 实例
);
// 监听同步状态
provider.on('sync', synced => {
console.log('同步状态:', synced ? '已同步' : '同步中');
});
2.2 集成 Tiptap 编辑器:富文本协同
Tiptap 是基于 ProseMirror 的富文本编辑器框架,原生支持 Yjs 协同。以下是生产级集成代码:
// Tiptap + Yjs 富文本协同编辑器的完整配置
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const doc = new Y.Doc();
const provider = new WebsocketProvider('wss://your-server.com', 'doc-123', doc);
// 随机用户颜色——每个协作者有不同颜色
const colors = ['#e63946', '#2a9d8f', '#264653', '#e9c46a', '#f4a261'];
const userColor = colors[Math.floor(Math.random() * colors.length)];
const editor = new Editor({
element: document.querySelector('#editor'),
extensions: [
StarterKit.configure({
history: false, // 必须禁用——Yjs 自己管理撤销栈
}),
// 核心:Yjs 协同扩展
Collaboration.configure({
document: doc,
field: 'content', // Y.Doc 中的字段名
}),
// 光标协同:显示其他用户的光标和选区
CollaborationCursor.configure({
provider,
user: {
name: '用户-' + Math.random().toString(36).slice(2, 6),
color: userColor,
},
}),
],
});
⚠️ 警告: 使用 Yjs 协同时,必须禁用 Tiptap/ProseMirror 内置的
history扩展。两个撤销栈会互相冲突,导致编辑器行为不可预测。Yjs 的Y.UndoManager提供了协同感知的撤销/重做功能。
2.3 搭建同步服务器
Yjs 官方提供了 y-websocket 服务器,5 分钟即可部署:
# 一键启动 Yjs WebSocket 同步服务器
npx y-websocket-server --port 1234
# 生产环境:用 Docker 部署
# Dockerfile
FROM node:20-alpine
RUN npm install -g y-websocket-server
EXPOSE 1234
CMD ["y-websocket-server", "--port", "1234"]
对于需要持久化的生产环境,推荐使用 Hocuspocus——一个基于 Yjs 的可扩展协同服务器框架:
// Hocuspocus 服务器:带认证、持久化、Webhook 的生产级配置
import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
import { SQLite } from '@hocuspocus/extension-sqlite';
const server = Server.configure({
port: 1234,
// 认证钩子——每次连接时验证 Token
async onAuthenticate({ token, documentName }) {
const user = await verifyToken(token);
if (!user) throw new Error('认证失败');
if (!user.canEdit(documentName)) throw new Error('无编辑权限');
return { user: user.id, name: user.name };
},
// 文档加载——从数据库恢复状态
async onLoadDocument({ documentName }) {
const data = await db.get(`doc:${documentName}`);
return data ? Y.encodeStateAsUpdate(data) : null;
},
// 文档变更——持久化到数据库
async onStoreDocument({ documentName, state }) {
await db.set(`doc:${documentName}`, Y.encodeStateAsUpdate(state));
},
extensions: [
// SQLite 持久化——适合中小规模
new Database({
storage: new SQLite({ database: './data/docs.sqlite' }),
}),
],
});
server.listen();
💡 提示: Yjs 的状态可以通过
Y.encodeStateAsUpdate(doc)序列化为二进制,再用Y.applyUpdate(doc, binary)恢复。这个二进制格式比 JSON 小 10-50 倍,是生产环境持久化的首选方式。
📊 三、CRDT vs OT vs Yjs vs Automerge:技术选型对比
3.1 核心算法对比
| 对比维度 | OT(操作变换) | Yjs(CRDT) | Automerge(CRDT) |
|---|---|---|---|
| 核心算法 | 操作变换函数 | YATA 算法 | RGA + LWW |
| 是否需要中心服务器 | ✅ 必须 | ❌ 不需要 | ❌ 不需要 |
| 离线编辑支持 | ⚠️ 非常困难 | ✅ 原生支持 | ✅ 原生支持 |
| 文本编辑性能 | ⭐⭐⭐⭐ 好 | ⭐⭐⭐⭐⭐ 极好 | ⭐⭐⭐ 一般 |
| 内存占用 | 低 | 低-中 | 高(保留完整历史) |
| 包大小 | 取决于实现 | ~15KB gzip | ~180KB gzip |
| 学习曲线 | 高(变换函数难写) | 中 | 低(API 简单) |
| 生态成熟度 | 高(Google Docs 等) | 高(Tiptap/ProseMirror/BlockNote) | 中 |
| 多人光标支持 | 需自行实现 | 内置支持 | 需自行实现 |
| P2P 同步 | ❌ | ✅(y-webrtc) | ✅ |
3.2 什么场景选什么方案
⚡ 关键结论: 2026 年的新项目,除非你有非常特殊的理由(比如已有成熟的 OT 基础设施),否则应该直接选 Yjs。它的性能最好、生态最成熟、社区最活跃。Automerge 适合需要完整版本历史和 P2P 同步的场景(如 Local-First 应用),但包大小和内存消耗是明显短板。
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 富文本文档协作(类 Google Docs) | Yjs + Tiptap | 生态最好,性能最优 |
| 设计工具(类 Figma) | Yjs + 自定义 CRDT | Y.Map 适合画布对象 |
| 表单/数据协同 | Yjs(Y.Map) | JSON 结构天然支持 |
| Local-First 应用(离线优先) | Automerge | 完整历史 + P2P + 自动持久化 |
| 已有 OT 系统维护 | 继续 OT | 迁移成本不值得 |
| 简单计数/投票 | G-Counter / PN-Counter | 最简实现,无需依赖库 |
⚠️ 四、生产环境的 7 个坑与避坑指南
4.1 文档大小膨胀
CRDT 为每个操作分配唯一 ID(通常包含 clientID + clock),这些元数据会随编辑次数增长。一个编辑了 10 万次的文档,即使内容只有 1KB,CRDT 元数据可能膨胀到 50MB。
解决方案:定期对文档做「快照压缩」:
// 定期压缩 CRDT 文档——清除已同步的操作历史
function compactDocument(doc) {
// 获取当前完整状态
const fullState = Y.encodeStateAsUpdate(doc);
// 创建新 Doc,只保留最终状态
const compactedDoc = new Y.Doc();
Y.applyUpdate(compactedDoc, fullState);
// 序列化压缩后的状态
const compactedBinary = Y.encodeStateAsUpdate(compactedDoc);
const originalSize = fullState.byteLength;
const compactedSize = compactedBinary.byteLength;
console.log(`压缩效果: ${originalSize} → ${compactedSize} bytes`);
console.log(`压缩率: ${((1 - compactedSize / originalSize) * 100).toFixed(1)}%`);
return { doc: compactedDoc, binary: compactedBinary };
}
4.2 光标位置漂移
用户 A 在位置 5 有一个光标,用户 B 在位置 2 插入了 3 个字符。如果光标位置不做 CRDT 感知调整,用户 A 的光标会停留在位置 5,但实际上应该在位置 8。
⚠️ 警告: 永远不要用简单的数字偏移量表示光标位置。在协同编辑中,位置必须基于 CRDT 的内容 ID(如 Y.RelativePosition),否则光标漂移是必然的。
Yjs 提供了 Y.createRelativePositionFromTypeIndex() 来解决这个问题,它将光标绑定到 CRDT 内容上而非固定偏移量。Tiptap 的 CollaborationCursor 扩展已经内置了这个处理。
4.3 撤销/重做的协同陷阱
本地的撤销/重做会误撤销远程用户的操作。Yjs 的 Y.UndoManager 解决了这个问题——它只撤销当前用户的操作:
// 协同感知的撤销管理器
const undoManager = new Y.UndoManager(yText, {
trackedOrigins: new Set([doc.clientID]), // 只追踪本客户端的操作
captureTimeout: 500, // 500ms 内的连续操作合并为一个撤销步骤
});
// 绑定快捷键
document.addEventListener('keydown', (e) => {
if (e.metaKey && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
undoManager.redo();
} else {
undoManager.undo();
}
}
});
4.4 其他高频坑点
- ❌ 在 React 中直接监听 yText.observe 但不 cleanup — 组件卸载后会内存泄漏,必须在
useEffect的 cleanup 中调用unobserve - ❌ 频繁触发全量同步 — 应该用
Y.encodeStateVector做增量同步,只传输差异部分 - ❌ 不做认证就暴露 WebSocket — 任何知道文档 ID 的人都能连接和编辑,必须在连接握手时验证身份
- ✅ 使用 Awareness 协议管理在线状态 — Yjs 的 Awareness API 可以广播光标、在线状态、用户信息,不需要自己实现心跳机制
- ✅ 设置合理的文档过期策略 — 不活跃的文档应该从内存卸载,只在需要时从持久化存储恢复
🎯 总结与选型建议
CRDT 正在从学术论文走向工程实践。2026 年,Yjs 已经成为协同编辑的事实标准——它的性能(比 Automerge 快 10-100 倍)、包大小(15KB vs 180KB)、生态集成(Tiptap/ProseMirror/BlockNote 原生支持)让它在绝大多数场景中是唯一合理的选择。
给不同角色的建议:
- 🔧 前端开发者:从 Yjs + Tiptap 开始,30 分钟就能跑通一个协同编辑 demo。生产环境用 Hocuspocus 做服务器,SQLite 做持久化。
- 🔧 后端开发者:如果只需要协同表单数据(非富文本),直接用 Y.Map + WebSocket,不需要编辑器框架。
- 🔧 架构师:评估是否需要 P2P 同步——如果需要,考虑 Automerge 或 y-webrtc;如果不需要,Yjs + WebSocket 是最稳妥的方案。
相关工具推荐:
| 工具 | 用途 | 地址 |
|---|---|---|
| Yjs | CRDT 核心库 | yjs.dev |
| Tiptap | 富文本编辑器框架 | tiptap.dev |
| Hocuspocus | Yjs 协同服务器框架 | tiptap.dev/hocuspocus |
| y-websocket | Yjs WebSocket Provider | npm: y-websocket |
| y-webrtc | Yjs P2P Provider | npm: y-webrtc |
| BlockNote | 基于 Tiptap 的块编辑器 | blocknotejs.org |
| Automerge | 备选 CRDT 库(Local-First) | automerge.org |
| Liveblocks | 协同编辑 SaaS 服务(托管方案) | liveblocks.io |