Y.js CRDT 原理与协作编辑架构
🤝 Y.js 使用 CRDT 让多个客户端在离线、乱序和并发修改下最终收敛,适合协作编辑,但仍需要完整设计权限、持久化和运维边界。
CRDT 的目标
协作系统需要处理:
- 多个用户同时编辑
- 消息重复、延迟和乱序
- 断线后继续编辑
- 重连后合并修改
CRDT 通过带唯一身份和因果信息的操作,使副本在接收相同更新集合后得到一致结果,不依赖中心服务器决定每次冲突。
Y.Doc 与共享类型
import * as Y from 'yjs'
const doc = new Y.Doc()
const text = doc.getText('content')
const meta = doc.getMap('meta')
text.insert(0, 'Hello')
meta.set('title', '协作文档')
常用共享类型:
Y.TextY.ArrayY.MapY.XmlFragment
Tiptap / ProseMirror 协作通常使用 Y.XmlFragment 表示富文本文档。
Update 与同步
Y.js 把变更编码为二进制 update。Update 具备交换律、结合律和幂等性,可以安全地乱序、重复应用。
const update = Y.encodeStateAsUpdate(doc)
Y.applyUpdate(otherDoc, update)
通过 state vector 可只发送对方缺失的差异:
const vector = Y.encodeStateVector(remoteDoc)
const diff = Y.encodeStateAsUpdate(doc, vector)
Awareness
Awareness 用于在线状态、用户名、选区和远程光标等临时信息。它不属于持久文档,不应当作可靠业务数据保存。
provider.awareness.setLocalStateField('user', {
name: 'Ada',
color: '#7c3aed',
})
典型架构
- 客户端维护本地
Y.Doc。 - Provider 通过 WebSocket 或 WebRTC 传输 update。
- 协作服务按 room / document 路由连接。
- 服务端验证身份和文档权限。
- Update 持久化到数据库或对象存储。
- 定期合并快照,控制加载和存储成本。
Hocuspocus 提供面向 Y.js 的 WebSocket 后端、鉴权钩子、扩展机制和持久化集成。
离线与重连
Y.js 可与 IndexedDB provider 配合,先在本地恢复文档,再与远端同步。UI 应明确区分:
- 本地已保存
- 正在同步
- 已同步
- 同步失败或无权限
权限边界
CRDT 解决合并,不解决授权。
- 建立连接时校验身份与文档权限。
- 服务端不能盲目信任客户端提交的文档 ID。
- 权限变更后主动断开无权连接。
- 业务级操作可能需要独立审计日志。
- 不要把敏感权限状态仅存在共享文档中。
持久化策略
更新日志
按序保存 update,写入快,但日志会增长。
快照
定期保存合并后的完整状态,读取快,但生成成本更高。
混合方案
保存周期快照 + 后续增量 update;读取时加载快照并重放增量,再定期压缩。
⚠️ 不要在不了解并发写入和状态向量的情况下直接覆盖数据库中的“最新 JSON”。这会丢失其他客户端的并发修改。
Schema 迁移
协作文档可能长期存活,不同客户端版本会同时在线。应:
- 保持节点降级兼容
- 避免突然删除旧 Schema 节点
- 通过明确版本和迁移流程演进
- 在服务端或独立任务中测试迁移
观测指标
- 活跃连接数与房间数
- update 大小、速率和广播延迟
- 首次同步耗时
- 快照大小与重放时间
- 断线重连率
- 权限失败与持久化错误
自检清单
- 文档更新与 Awareness 已分离
- 鉴权发生在服务端连接边界
- 支持离线、重连和重复消息
- 有快照、增量和压缩策略
- Schema 升级经过兼容性设计