Y.js CRDT 原理与协作编辑架构
🤝 **一句话理解:**Y.js 把每个客户端的编辑变成可合并的 CRDT 数据。即使多人同时输入、消息重复或乱序、设备离线后再上线,只要各副本最终收到同一批更新,文档就会收敛到同一结果。
先给小白一个画面
想象三个人各拿着同一份草稿的复印件:
- A 在第一段前面加了标题;
- B 同时删除了第二段的一句话;
- C 在断网状态下修改了结尾;
- 网络恢复后,三个人的修改以不同顺序到达服务器。
普通“保存整个 JSON”方案很容易变成后保存的人覆盖先保存的人。Y.js 不把“整份文档的最新值”当作唯一事实,而是给插入内容分配稳定身份,并传播二进制更新。每个副本都能用同一套规则合并这些更新,因此不需要服务器逐次裁决谁覆盖谁。
🧠 **CRDT 保证的是最终收敛,不是所有人每一毫秒都看到相同画面。**网络仍有延迟;只有在各方收到相同更新集合后,状态才会一致。
协作编辑到底难在哪里
单机编辑只有一条时间线;多人协作则会同时遇到:
- **并发:**两个人在同一个位置输入。
- **乱序:**更新 2 可能比更新 1 更早到达。
- **重复:**重试或消息中间件可能重复投递。
- **离线:**客户端离线数小时后带着本地修改回来。
- **光标漂移:**远端在光标前插入内容后,数字索引已经失效。
- **撤销边界:**用户通常只想撤销自己的操作,而不是撤销其他人的输入。
- **富文本结构:**标题、列表、表格不是一段简单字符串。
- **工程边界:**鉴权、持久化、审计、扩缩容并不会因为用了 CRDT 自动消失。
一个“覆盖保存”事故
初始文档是 Hello:
- A 读取
Hello,改成Hello Alice。 - B 也读取
Hello,改成Hello Bob。 - A 保存完整 JSON。
- B 随后保存完整 JSON,数据库最终只剩
Hello Bob。
A 的修改不是发生了“冲突提示”,而是直接丢失。Y.js 传播的是可合并更新,不是用一个快照盲目覆盖另一个快照。
CRDT、OT 与最后写入者胜出
| 方案 | 基本思想 | 优点 | 主要代价 |
|---|---|---|---|
| 最后写入者胜出 | 较新的整体值覆盖旧值 | 实现简单 | 并发修改容易丢失 |
| OT | 根据并发上下文变换操作 | 在中心化在线编辑中成熟 | 协议与服务端时序设计较复杂 |
| CRDT / Y.js | 数据和操作自带可确定合并的信息 | 天然适配离线、乱序、点对点同步 | 需要管理元数据、文档增长和业务语义 |
Y.js 不是“自动解决所有冲突”的魔法。它擅长解决数据结构层面的收敛;“两个用户把任务状态分别改成完成和取消,业务上应该选哪个”仍然要由产品规则决定。
Y.js 的分层心智模型
理解这几层非常重要:
- 编辑器负责用户交互和文档 Schema。
- Binding把编辑器事务映射到 Y.js,共享选区和内容。
- Y.Doc保存 CRDT 状态并生成 update。
- Provider只负责搬运或落盘 update,不决定文档语义。
- 协作服务负责连接、身份、权限、房间和广播。
- 数据库负责耐久性,不应该把 Y.Doc 当作普通 JSON 随意覆盖。
从零上手:Y.Doc 与共享类型
import * as Y from 'yjs'
const doc = new Y.Doc()
const text = doc.getText('content')
const meta = doc.getMap('meta')
const comments = doc.getArray('comments')
text.insert(0, 'Hello')
meta.set('title', '协作文档')
comments.push([{ id: 'c1', body: '需要补充示例' }])
常见共享类型:
Y.Text:纯文本和带格式文本。Y.Array:有序列表。Y.Map:键值数据;同一键并发赋值时按 CRDT 规则得到确定结果。Y.XmlElement/Y.XmlFragment:树形富文本;Tiptap / ProseMirror 协作常用。Y.Doc子文档:把大文档拆成可独立加载的单元。
共享类型不是普通 JS 对象
应该通过共享类型 API 修改数据,而不是取出 JSON 后原地改:
// 正确:产生 Y.js 可观察、可同步的变更
meta.set('title', '新标题')
// 错误思路:toJSON() 得到的是普通快照,修改它不会回写 Y.Doc
const snapshot = meta.toJSON()
snapshot.title = '不会自动同步'
⚠️ 把大型、频繁变化的嵌套对象整体塞进一个
Y.Map键,可能让每次修改都退化成“整个值竞争”。需要多人分别编辑的字段,应尽量拆成可独立合并的共享类型。
原理深入:Y.js 为什么能确定性合并
1. 每段插入都有稳定 ID
Y.js 内部以列表 CRDT 为核心。插入内容会获得类似下面的身份:
ID = (clientID, clock)
clientID标识本次客户端会话。clock是该客户端插入内容的递增时钟。- 两个客户端即使在同一位置同时输入,也会生成不同 ID。
客户端不只是说“在索引 5 插入 X”,还会记录插入项与相邻项的关系。并发更新到达时,各副本使用同样的排序和整合规则,所以最终顺序一致。
2. 文本不是简单字符数组
概念上可以把文本看成带身份的字符序列;实现上 Y.js 会把同一次连续输入压缩为较少的 Item,避免每个字符都创建一个 JS 对象。发生中间删除或并发插入时,Item 才可能被拆分。
3. 删除是标记,不是“忘掉它存在过”
删除需要让迟到的客户端也知道哪些 ID 已被移除,因此更新中会携带 Delete Set。启用垃圾回收后,被删除内容的实际负载可被丢弃,但仍会保留维持 CRDT 结构所需的轻量信息。
这解释了两个现象:
- 文档经过大量编辑后,二进制状态可能大于当前可见内容。
mergeUpdates能去重和合并更新,但不会完成需要完整Y.Doc的垃圾回收。
4. State Vector 是“我知道你写到哪里了”
State Vector 可以理解为:
client A -> 已知 clock 120
client B -> 已知 clock 42
client C -> 已知 clock 7
它不是文档内容,也不是传统数据库版本号,而是各客户端插入进度的摘要。远端收到它后,只需发送缺少的结构,而不是每次发送整个文档。
Update:同步与持久化的核心单位
Y.js 把变更编码为 Uint8Array。Update 具有:
- **交换律:**先应用 A 再应用 B,与先 B 后 A 最终相同。
- **结合律:**分组方式不影响最终结果。
- **幂等性:**同一个 update 重复应用不会重复插入内容。
const fullState = Y.encodeStateAsUpdate(doc)
Y.applyUpdate(otherDoc, fullState)
增量同步:
const remoteVector = Y.encodeStateVector(remoteDoc)
const missing = Y.encodeStateAsUpdate(doc, remoteVector)
Y.applyUpdate(remoteDoc, missing)
监听本地增量:
doc.on('update', (update, origin) => {
sendBinary(update)
persistBinary(update)
})
Update 不是 JSON
Update 是二进制数据:
- 浏览器和 WebSocket 可直接传
Uint8Array。 - Node.js 中可存为
Buffer/ BLOB / BYTEA。 - 若系统只能传字符串,再用 Base64;它会增加体积,不应无理由转换。
- 不要对
Uint8Array直接JSON.stringify并期待可逆。
一次重连同步发生了什么
这里没有“谁的整份文档更新就覆盖谁”。双方交换自己缺少的部分。
Transaction 与 origin:资深工程师容易忽略的细节
所有 Y.js 修改都发生在 transaction 中。批量修改应显式合并,减少 observer 次数和网络 update:
const LOCAL_ORIGIN = Symbol('local-form')
doc.transact(() => {
meta.set('title', '新标题')
meta.set('updatedAt', Date.now())
}, LOCAL_ORIGIN)
origin 常用于:
- 区分本地输入、远端 provider、数据迁移和机器人操作。
- 避免 provider 把自己刚应用的远端 update 再发回去形成回环。
- 配置
Y.UndoManager只跟踪指定来源。 - 诊断“是谁产生了这次事务”。
💡 Y.js 的 transaction 用于批量提交与事件边界,不像数据库事务那样可以回滚。需要先校验后修改,不要把它当作
BEGIN / ROLLBACK。
富文本:Y.js 与 Tiptap / ProseMirror 如何配合
典型组合是:
ProseMirror EditorState
↕ binding
Y.XmlFragment
↕ provider
远端 Y.Doc
关键原则:
- 让官方或成熟 binding 双向同步,不要同时手写两套“编辑器 JSON ↔ Y.Doc”回写逻辑。
- 协作模式下通常由协作历史接管撤销,不要让 ProseMirror 本地 history 与
Y.UndoManager同时处理同一批变更。 - 所有客户端的 ProseMirror Schema 必须兼容;旧客户端遇到未知节点时可能无法正确渲染或保留内容。
- 文档内容、评论锚点、用户 Presence 是三个不同问题,不要全部塞进一个 JSON。
为什么不能持久化编辑器的“最新 JSON”作为唯一真相
编辑器 JSON 是某一时刻的投影,不包含 Y.js 合并并发更新所需的完整结构信息。可把 JSON/HTML 用于:
- 搜索索引;
- 预览和服务端渲染;
- 导出;
- 内容审核管道。
但协作真相通常应是 Y.js 二进制状态和更新历史。派生 JSON 应可重建,而不是反过来覆盖 CRDT 状态。
光标、评论锚点与 Relative Position
普通数字索引很脆弱。假设光标在 a|c 中间,远端在前面插入 x 后,旧索引可能仍指向错误位置。
Relative Position 会绑定到共享结构中的稳定位置:
const relative = Y.createRelativePositionFromTypeIndex(ytext, 1)
// 传输或持久化 relative,稍后再映射回当前文档的绝对索引
const absolute = Y.createAbsolutePositionFromRelativePosition(relative, doc)
if (absolute) {
console.log(absolute.index)
}
适合使用 Relative Position 的场景:
- 远程光标和选区;
- 行内评论的起止锚点;
- 批注、建议和引用范围;
- 跨并发编辑仍需跟随内容的位置标记。
若被锚定的类型已经删除,转换结果可能为 null,业务层必须定义“评论失去锚点”后的展示和恢复策略。
Awareness:在线状态不是文档状态
Awareness 用于短暂的 Presence 信息:
- 用户名、颜色、头像;
- 当前光标和选区;
- 正在查看的块;
- 临时状态,例如“正在输入”。
provider.awareness.setLocalStateField('user', {
id: currentUser.id,
name: currentUser.name,
color: '#7c3aed',
})
Awareness 不应承担:
- 文档权限;
- 审计记录;
- 任务状态;
- 必须永久保存的评论;
- 计费或合规数据。
它通常不会写入 Y.Doc,用户离线后状态会过期或删除。服务端仍要校验 awareness 载荷大小,不能信任客户端提交的姓名、角色或 HTML。
Provider:Y.js 不绑定网络拓扑
Provider 的职责是把 update 从一个副本送到另一个副本:
y-websocket:中心化 WebSocket,适合统一鉴权、持久化和可观测性。- WebRTC provider:客户端之间点对点传播,适合特定场景,但连接拓扑、NAT 和权限治理更复杂。
y-indexeddb:本地浏览器持久化,不是远端协作服务器。- Hocuspocus:面向 Y.js 的协作后端框架,提供连接钩子、鉴权和扩展能力。
一个 Y.Doc 可以同时连接本地持久化 provider 与网络 provider。多个 provider 都应正确设置 transaction origin,避免更新回环。
离线优先与同步状态
推荐启动顺序:
- 创建
Y.Doc。 - 连接 IndexedDB,先恢复本地内容。
- 初始化编辑器 binding。
- 建立网络连接并鉴权。
- 交换 state vector 与缺失 update。
- 收敛后标记为已同步。
UI 至少应区分:
- **仅本地已保存:**刷新不丢,但远端未确认。
- **正在连接 / 同步:**可能仍在拉取远端差异。
- **已同步:**当前已完成一次与服务端的状态交换。
- **离线编辑中:**本地允许继续写。
- **同步失败:**网络、鉴权、协议或持久化发生错误。
- **只读 / 权限已撤销:**不能把它伪装成普通断网。
⚠️ “WebSocket 已连接”不等于“文档已同步”,“update 已发出”也不等于“服务端已耐久化”。产品状态文案必须对应真实语义。
服务端持久化:推荐快照 + 增量日志
方案 A:只存增量 update
优点:
- 写入快;
- 天然追加;
- 易于重放和调试。
缺点:
- 日志持续增长;
- 冷启动需要重放大量更新;
- 需要压缩和损坏恢复策略。
方案 B:每次覆盖完整状态
优点是读取简单,但每次编码和写入完整文档成本高,并且若实现不正确,容易在多节点并发保存时覆盖新数据。
方案 C:周期快照 + 快照后的增量
生产环境常用流程:
- 读取最近快照。
- 重放快照之后的 update。
- 将新 update 追加到日志。
- 达到条数、字节数或时间阈值后生成新快照。
- 新快照耐久化成功后,再安全清理旧增量。
建议每条日志至少包含:
document_id- 单调递增的服务端序号或可比较游标
- update 二进制
- 接收时间
- actor / connection / request 追踪信息
- 协议或 Schema 版本
压缩时的竞态
压缩任务不能简单执行“读取旧日志 → 写快照 → 删除全部日志”。在压缩期间仍可能有新 update 写入。安全做法是:
- 在事务中记录压缩截止游标;
- 快照只覆盖该游标之前的数据;
- 仅清理已被快照覆盖的日志;
- 或按文档串行化压缩与写入。
Y.mergeUpdates 可合并并去重二进制更新,但不会执行完整垃圾回收。要真正缩减已删除内容相关负担,需要把状态加载进 Y.Doc 后重新编码,并评估历史恢复需求。
鉴权与安全边界
CRDT 解决合并,不解决授权。服务端至少要做:
- **连接鉴权:**验证 session / token,拒绝匿名伪造。
- **文档授权:**根据服务端可信数据判断 read / write 权限,不能只信 room 名称。
- **写入校验:**只读连接收到 update 时立即拒绝并记录。
- **权限撤销:**权限变化后主动断开连接或使后续写入失效。
- **租户隔离:**缓存、Pub/Sub channel、对象存储 key 都要包含可信 tenant 边界。
- **资源限制:**限制单条 update、awareness 载荷、消息频率、连接数与文档大小。
- **审计分层:**Y.js 内部删除信息不等于“谁在何时删除了业务字段”,合规审计要独立记录。
- **内容安全:**富文本渲染仍需处理 XSS、危险 URL、附件权限和导出注入。
🔐 不要把
role: 'admin'放进 Awareness 或Y.Map后就据此授权。客户端可构造任意共享数据;权限判断必须基于服务端可信身份与策略。
横向扩展:从单机到多节点
单机模型很简单:一个 room 的连接和 Y.Doc 都在同一进程。多节点后需要保证同一文档的更新能到达其他节点上的连接。
常见方案:
Pub/Sub 广播
- 每个 room 对应一个 channel。
- 节点收到 update 后持久化并发布。
- 其他节点订阅后向本机连接广播。
- 必须接受重复投递,并使用 Y.js 的幂等性;同时避免自己发布、自己无限回环。
优点是容错和负载均衡简单;代价是同一文档可能在多节点重复驻留。
一致性哈希 / 文档归属
- 同一文档尽量路由到唯一协作节点。
- 减少跨节点广播和重复内存。
- 需要健康检查、故障转移、重新分片和连接迁移。
无论哪种方案,都要明确:
- 先广播还是先持久化;
- 持久化失败后客户端看到什么;
- 节点崩溃时最多丢多少已确认更新;
- 热门大房间如何限流和隔离;
- 多节点同时做快照时如何避免竞态。
Undo / Redo:撤销“我做的事”
Y.UndoManager 可以限定作用域和来源:
const undoManager = new Y.UndoManager(
[ytext, meta],
{
trackedOrigins: new Set([LOCAL_ORIGIN]),
captureTimeout: 500,
},
)
设计时要回答:
- 只撤销当前用户的本地操作,还是也撤销机器人操作?
- 连续输入多久合并成一个撤销单元?
- 用户切换段落或选择后是否停止合并?
- 页面刷新后是否保留撤销栈?通常不能默认期待跨会话保留。
- 编辑器自身 history 是否已关闭,避免双重撤销?
Schema 迁移与多版本客户端
协作文档可能长期存活,而且新旧客户端会同时在线。安全演进原则:
- **先读后写:**新客户端先能读取旧结构,再开始写新结构。
- **向后兼容:**未知节点尽可能保留,而不是解析失败后丢弃。
- **幂等迁移:**迁移任务重复执行不会重复插入或破坏数据。
- **事务标记来源:**使用 migration origin,避免进入普通撤销栈。
- **灰度发布:**监控未知节点、解析失败和文档体积变化。
- **服务端验证:**不要默认所有客户端都遵守新 Schema。
- **版本可见:**在可信元数据中记录内容 Schema 版本,但不要把它误当成权限机制。
对于破坏性变更,通常采用“新增新字段 → 双读或兼容读 → 回填 → 停止旧写入 → 最后移除旧字段”,而不是一步删除旧节点。
性能与容量规划
重点不是只看“当前正文多少 KB”,而是看编辑历史和并发模式。
客户端
- 批量修改放进单个 transaction。
- 避免高频 observer 中执行整文档
toJSON()。 - 大文档考虑拆分为子文档或按块加载。
- 输入法组合事件、超长粘贴、表格和代码块要单独压测。
- 页面卸载时销毁 provider、binding 和 listener,防止重复连接。
服务端
- 监控单 room 连接数和广播扇出。
- 对 update 大小、频率和积压做限流。
- 快照在后台生成,但要有游标和竞态保护。
- 热门文档与普通文档分开设置资源上限。
- 不要在每个按键都同步生成 HTML、全文索引和业务事件;应去抖或异步处理。
建议观测指标
- 当前连接数、活跃 room 数、每 room 峰值连接数
- update 大小 P50 / P95 / P99 与每秒数量
- 服务端接收 → 持久化 → 广播延迟
- 首次同步耗时与同步流量
- 快照大小、快照耗时、增量重放条数与耗时
- 文档加载后的内存占用
- 断线率、重连次数、鉴权失败率
- 只读用户写入尝试、超限 update、坏消息数量
- IndexedDB 恢复失败和本地存储配额错误
故障排查手册
症状:两个客户端内容不一致
依次检查:
- 是否错误地用编辑器 JSON 覆盖了 Y.Doc。
- Provider 是否过滤错了 origin,导致某些更新没发出。
- 持久化层是否截断或错误编码了二进制。
- 多节点 Pub/Sub 是否订阅了错误 tenant / room。
- 客户端是否意外创建了不同的顶层共享类型名称。
- 是否有不兼容 Schema 导致编辑器投影异常,而 Y.Doc 实际已一致。
可在测试环境比较各端 encodeStateAsUpdate 或 state vector,并记录 update 的字节长度和哈希;不要在生产日志直接输出敏感正文。
症状:同一更新不断来回广播
- 检查
Y.applyUpdate(doc, update, provider)是否设置 origin。 - 发送监听器是否忽略由同一 provider 应用的 update。
- 多个 provider 之间是否形成环。
- Pub/Sub 消息是否带节点来源并正确去回环。
症状:首次打开越来越慢
- 增量日志是否从未压缩。
- 是否每次从零重放全部 update。
- 是否把所有子文档一次性加载。
- 快照是否实际覆盖了对应游标。
- 派生 HTML / 索引是否阻塞协作加载路径。
症状:远程光标乱跳
- 是否用普通数字 index 跨网络传输。
- 是否应改用 Relative Position。
- Awareness 更新是否过于频繁或乱序处理有误。
- 编辑器 binding 与 Schema 是否在各端一致。
最小可用实现
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { IndexeddbPersistence } from 'y-indexeddb'
const doc = new Y.Doc()
const fragment = doc.getXmlFragment('prosemirror')
// 本地离线持久化
const local = new IndexeddbPersistence('doc:123', doc)
local.on('synced', () => {
console.log('本地内容已恢复')
})
// 远端同步;真实项目应使用 WSS,并在服务端完成身份与文档授权
const remote = new WebsocketProvider(
'wss://collab.example.com',
'doc:123',
doc,
)
remote.on('status', ({ status }) => {
console.log('网络状态:', status)
})
remote.on('sync', isSynced => {
console.log('远端同步完成:', isSynced)
})
remote.awareness.setLocalStateField('user', {
id: currentUser.id,
name: currentUser.name,
color: '#7c3aed',
})
// 将 fragment 交给 Tiptap / ProseMirror 对应 binding
console.log(fragment)
// 页面销毁时清理
function destroyCollaboration() {
remote.destroy()
local.destroy()
doc.destroy()
}
这段代码只是“连起来了”,离生产可用还缺少服务端授权、持久化确认、限流、Schema 兼容、可观测性和故障恢复。
测试策略:不要只测两个人在线输入
至少覆盖:
- 两端在同一位置并发插入。
- 一端删除、另一端在被删区域输入。
- 消息随机乱序、重复、延迟和丢失后重试。
- 客户端离线编辑,服务端同时发生大量修改,随后重连。
- 三个以上客户端以不同顺序接收同一批 update,最终状态必须一致。
- 权限从可写变只读时,旧连接立即停止写入。
- 服务端在“收到、持久化、广播”各阶段崩溃并恢复。
- 快照生成期间仍持续写入,不能丢失截止游标之后的数据。
- 新旧 Schema 客户端同时编辑。
- 超长粘贴、大表格、输入法和高频 Awareness 更新。
测试收敛性时,不要只比较渲染 HTML;还应比较规范化后的文档状态,并确认各端交换完整更新后不再产生缺失 diff。
常见误区速查
-
**误区:**有 CRDT 就不需要服务器。
**事实:**仍需要身份、权限、持久化、限流、审计和连接治理。
-
**误区:**WebSocket connected 就代表数据安全。
**事实:**连接、同步、服务端接收和耐久化是不同状态。
-
**误区:**Awareness 可以保存用户角色。
**事实:**它是客户端可写的临时 Presence,不能作为授权依据。
-
**误区:**每次保存最新 JSON 最直观。
**事实:**可能丢失 CRDT 结构和并发修改。
-
误区:
mergeUpdates等于垃圾回收。**事实:**它会合并和去重更新,但不会完成加载
Y.Doc才能做的 GC。 -
**误区:**普通 index 足够保存评论锚点。
**事实:**并发插入会让 index 漂移,应使用 Relative Position。
-
**误区:**所有修改都应进入同一个 Y.Doc。
**事实:**权限、审计、计费和部分业务工作流应保留在可信服务端模型中。
生产落地自检清单
数据模型
- 已为可并发编辑字段选择合适的共享类型
- 没有把频繁变化的大对象当成单个原子值反复覆盖
- 富文本 Schema 支持多版本兼容
- 评论和选区锚点使用 Relative Position 或等价稳定方案
同步与离线
- update 支持乱序、重复和断线重放
- 已区分连接、同步、本地保存和远端耐久化状态
- IndexedDB 恢复失败有降级处理
- provider 使用 origin 防止更新回环
持久化
- 使用二进制安全字段存储 update
- 有快照、增量、压缩和清理策略
- 压缩过程使用截止游标避免删除新更新
- 已验证备份恢复,而不只是“有备份”
安全
- 服务端校验身份、租户和文档读写权限
- 权限撤销会影响现有连接
- 限制连接数、消息频率和载荷大小
- Awareness 与共享文档都不作为可信授权源
- 富文本渲染和导出经过安全处理
运维
- 监控首次同步、广播和持久化延迟
- 监控文档体积、重放时间和内存
- 有热门房间隔离与背压策略
- 做过进程崩溃、网络分区和消息乱序演练
- 可按 document / connection / request 追踪问题,但日志不泄露正文
延伸阅读
- Y.js 官方文档
- Document Updates 与 State Vector
- Y.js Internals
- Relative Position
- Awareness 与 Presence
- Y.UndoManager
- y-websocket
✅ **最终心智模型:**Y.js 负责“多个副本如何合并并收敛”;Provider 负责“更新如何传输”;持久化层负责“更新如何活下来”;业务服务负责“谁能做什么”;编辑器 binding 负责“用户操作如何映射到共享结构”。把这五件事分开设计,协作系统才容易正确、可扩展、可运维。