跳到主要内容

Y.js CRDT 原理与协作编辑架构

🤝 **一句话理解:**Y.js 把每个客户端的编辑变成可合并的 CRDT 数据。即使多人同时输入、消息重复或乱序、设备离线后再上线,只要各副本最终收到同一批更新,文档就会收敛到同一结果。

先给小白一个画面

想象三个人各拿着同一份草稿的复印件:

  • A 在第一段前面加了标题;
  • B 同时删除了第二段的一句话;
  • C 在断网状态下修改了结尾;
  • 网络恢复后,三个人的修改以不同顺序到达服务器。

普通“保存整个 JSON”方案很容易变成后保存的人覆盖先保存的人。Y.js 不把“整份文档的最新值”当作唯一事实,而是给插入内容分配稳定身份,并传播二进制更新。每个副本都能用同一套规则合并这些更新,因此不需要服务器逐次裁决谁覆盖谁。

🧠 **CRDT 保证的是最终收敛,不是所有人每一毫秒都看到相同画面。**网络仍有延迟;只有在各方收到相同更新集合后,状态才会一致。

协作编辑到底难在哪里

单机编辑只有一条时间线;多人协作则会同时遇到:

  • **并发:**两个人在同一个位置输入。
  • **乱序:**更新 2 可能比更新 1 更早到达。
  • **重复:**重试或消息中间件可能重复投递。
  • **离线:**客户端离线数小时后带着本地修改回来。
  • **光标漂移:**远端在光标前插入内容后,数字索引已经失效。
  • **撤销边界:**用户通常只想撤销自己的操作,而不是撤销其他人的输入。
  • **富文本结构:**标题、列表、表格不是一段简单字符串。
  • **工程边界:**鉴权、持久化、审计、扩缩容并不会因为用了 CRDT 自动消失。

一个“覆盖保存”事故

初始文档是 Hello

  1. A 读取 Hello,改成 Hello Alice
  2. B 也读取 Hello,改成 Hello Bob
  3. A 保存完整 JSON。
  4. 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,避免更新回环。

离线优先与同步状态

推荐启动顺序:

  1. 创建 Y.Doc
  2. 连接 IndexedDB,先恢复本地内容。
  3. 初始化编辑器 binding。
  4. 建立网络连接并鉴权。
  5. 交换 state vector 与缺失 update。
  6. 收敛后标记为已同步。

UI 至少应区分:

  • **仅本地已保存:**刷新不丢,但远端未确认。
  • **正在连接 / 同步:**可能仍在拉取远端差异。
  • **已同步:**当前已完成一次与服务端的状态交换。
  • **离线编辑中:**本地允许继续写。
  • **同步失败:**网络、鉴权、协议或持久化发生错误。
  • **只读 / 权限已撤销:**不能把它伪装成普通断网。

⚠️ “WebSocket 已连接”不等于“文档已同步”,“update 已发出”也不等于“服务端已耐久化”。产品状态文案必须对应真实语义。

服务端持久化:推荐快照 + 增量日志

方案 A:只存增量 update

优点:

  • 写入快;
  • 天然追加;
  • 易于重放和调试。

缺点:

  • 日志持续增长;
  • 冷启动需要重放大量更新;
  • 需要压缩和损坏恢复策略。

方案 B:每次覆盖完整状态

优点是读取简单,但每次编码和写入完整文档成本高,并且若实现不正确,容易在多节点并发保存时覆盖新数据。

方案 C:周期快照 + 快照后的增量

生产环境常用流程:

  1. 读取最近快照。
  2. 重放快照之后的 update。
  3. 将新 update 追加到日志。
  4. 达到条数、字节数或时间阈值后生成新快照。
  5. 新快照耐久化成功后,再安全清理旧增量。

建议每条日志至少包含:

  • document_id
  • 单调递增的服务端序号或可比较游标
  • update 二进制
  • 接收时间
  • actor / connection / request 追踪信息
  • 协议或 Schema 版本

压缩时的竞态

压缩任务不能简单执行“读取旧日志 → 写快照 → 删除全部日志”。在压缩期间仍可能有新 update 写入。安全做法是:

  • 在事务中记录压缩截止游标;
  • 快照只覆盖该游标之前的数据;
  • 仅清理已被快照覆盖的日志;
  • 或按文档串行化压缩与写入。

Y.mergeUpdates 可合并并去重二进制更新,但不会执行完整垃圾回收。要真正缩减已删除内容相关负担,需要把状态加载进 Y.Doc 后重新编码,并评估历史恢复需求。

鉴权与安全边界

CRDT 解决合并,不解决授权。服务端至少要做:

  1. **连接鉴权:**验证 session / token,拒绝匿名伪造。
  2. **文档授权:**根据服务端可信数据判断 read / write 权限,不能只信 room 名称。
  3. **写入校验:**只读连接收到 update 时立即拒绝并记录。
  4. **权限撤销:**权限变化后主动断开连接或使后续写入失效。
  5. **租户隔离:**缓存、Pub/Sub channel、对象存储 key 都要包含可信 tenant 边界。
  6. **资源限制:**限制单条 update、awareness 载荷、消息频率、连接数与文档大小。
  7. **审计分层:**Y.js 内部删除信息不等于“谁在何时删除了业务字段”,合规审计要独立记录。
  8. **内容安全:**富文本渲染仍需处理 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 恢复失败和本地存储配额错误

故障排查手册

症状:两个客户端内容不一致

依次检查:

  1. 是否错误地用编辑器 JSON 覆盖了 Y.Doc。
  2. Provider 是否过滤错了 origin,导致某些更新没发出。
  3. 持久化层是否截断或错误编码了二进制。
  4. 多节点 Pub/Sub 是否订阅了错误 tenant / room。
  5. 客户端是否意外创建了不同的顶层共享类型名称。
  6. 是否有不兼容 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 兼容、可观测性和故障恢复。

测试策略:不要只测两个人在线输入

至少覆盖:

  1. 两端在同一位置并发插入。
  2. 一端删除、另一端在被删区域输入。
  3. 消息随机乱序、重复、延迟和丢失后重试。
  4. 客户端离线编辑,服务端同时发生大量修改,随后重连。
  5. 三个以上客户端以不同顺序接收同一批 update,最终状态必须一致。
  6. 权限从可写变只读时,旧连接立即停止写入。
  7. 服务端在“收到、持久化、广播”各阶段崩溃并恢复。
  8. 快照生成期间仍持续写入,不能丢失截止游标之后的数据。
  9. 新旧 Schema 客户端同时编辑。
  10. 超长粘贴、大表格、输入法和高频 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 负责“多个副本如何合并并收敛”;Provider 负责“更新如何传输”;持久化层负责“更新如何活下来”;业务服务负责“谁能做什么”;编辑器 binding 负责“用户操作如何映射到共享结构”。把这五件事分开设计,协作系统才容易正确、可扩展、可运维。