跳到主要内容

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.Text
  • Y.Array
  • Y.Map
  • Y.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',
})

典型架构

  1. 客户端维护本地 Y.Doc
  2. Provider 通过 WebSocket 或 WebRTC 传输 update。
  3. 协作服务按 room / document 路由连接。
  4. 服务端验证身份和文档权限。
  5. Update 持久化到数据库或对象存储。
  6. 定期合并快照,控制加载和存储成本。

Hocuspocus 提供面向 Y.js 的 WebSocket 后端、鉴权钩子、扩展机制和持久化集成。

离线与重连

Y.js 可与 IndexedDB provider 配合,先在本地恢复文档,再与远端同步。UI 应明确区分:

  • 本地已保存
  • 正在同步
  • 已同步
  • 同步失败或无权限

权限边界

CRDT 解决合并,不解决授权。

  • 建立连接时校验身份与文档权限。
  • 服务端不能盲目信任客户端提交的文档 ID。
  • 权限变更后主动断开无权连接。
  • 业务级操作可能需要独立审计日志。
  • 不要把敏感权限状态仅存在共享文档中。

持久化策略

更新日志

按序保存 update,写入快,但日志会增长。

快照

定期保存合并后的完整状态,读取快,但生成成本更高。

混合方案

保存周期快照 + 后续增量 update;读取时加载快照并重放增量,再定期压缩。

⚠️ 不要在不了解并发写入和状态向量的情况下直接覆盖数据库中的“最新 JSON”。这会丢失其他客户端的并发修改。

Schema 迁移

协作文档可能长期存活,不同客户端版本会同时在线。应:

  • 保持节点降级兼容
  • 避免突然删除旧 Schema 节点
  • 通过明确版本和迁移流程演进
  • 在服务端或独立任务中测试迁移

观测指标

  • 活跃连接数与房间数
  • update 大小、速率和广播延迟
  • 首次同步耗时
  • 快照大小与重放时间
  • 断线重连率
  • 权限失败与持久化错误

自检清单

  • 文档更新与 Awareness 已分离
  • 鉴权发生在服务端连接边界
  • 支持离线、重连和重复消息
  • 有快照、增量和压缩策略
  • Schema 升级经过兼容性设计