前端知库

实时协同编辑应该怎样设计状态和冲突解决?

从共享文档和在线光标场景区分传输、操作、权威状态与临时 presence,比较版本检查、OT 和 CRDT,并说明离线、权限、顺序与资源成本。

参考答案

简答

  • 只靠 WebSocket 广播输入事件,保证不了协同内容最终正确。
  • 实时协同分为传输连接、变更协议、服务端权威状态和客户端临时状态四层。
  • 文档状态要持久化可恢复,presence 可以丢失并按心跳清理。
  • 传输层只提供通道,不提供一致性。

两个人同时编辑同一份文档时,单纯让浏览器连上 WebSocket 并广播输入事件,无法保证最终内容正确:不同客户端的操作可能以不同相对顺序到达服务端,离线用户会暂时没有连接,两个用户可能同时修改同一位置,权限也可能在文档打开后发生变化。单条 WebSocket 连接中的消息传输保持顺序,不应把并发操作排序问题解释成它会任意打乱消息。

实时协同至少包含四层:传输连接、操作或变更协议、服务端权威状态、客户端临时界面状态。把它们都叫“同步状态”,会让冲突、重连和权限问题混在一起。

先区分文档状态和 presence

文档标题、正文和权限设置属于需要持久化、可恢复和可审计的业务状态。在线光标、输入中提示、用户是否正在查看页面属于 presence,通常可以丢失、过期和按心跳清理。

presence 不应写进文档版本,也不应让“某人离线”回滚文档内容。它可以通过 WebSocket 广播,但要设置超时和客户端销毁清理;页面冻结或网络断开时,服务端不能等一个最终的“我离开了”事件才移除用户。

传输层只提供通道,不提供协同一致性

WebSocket 保留消息边界,适合发送操作和确认;SSE 更适合服务端单向通知,普通 HTTP 也可以用于提交操作和获取快照。选择传输时要考虑代理、浏览器覆盖、弱网、消息大小和部署,而不是认为协议名称决定了冲突解决能力。

每条业务消息都应有版本、文档 ID、客户端操作 ID 和必要的因果信息:

json
{
  "version": 1,
  "type": "document-operation",
  "documentId": "note-42",
  "operationId": "client-abc-17",
  "baseVersion": 28,
  "operation": { "kind": "insert", "index": 12, "text": "好" }
}

操作 ID 用于去重,baseVersion 说明客户端基于哪份状态修改;它们不是用户权限,也不是只靠客户端生成就可信。服务端仍要验证当前用户是否能编辑该文档、操作结构是否合法、大小是否受限。

简单业务可以先使用版本检查

对于整条记录或少量字段编辑,可以让服务端只接受当前版本:客户端提交 baseVersion=28,服务端当前仍是 28 时写入并生成 29;如果当前已是 29,则返回冲突和最新表示。

这种方案简单、容易审计,但冲突时通常需要用户选择本地或远端版本,或按字段合并。不能把冲突直接当 500 重试,否则可能把一个明确的并发修改问题变成无限覆盖。

服务端成功响应应包含新的版本和操作结果。客户端收到重复消息时要能去重,收到较旧版本时不能把当前界面回滚;重连后应先获取快照或从最后确认的游标重放操作。

文本协同需要 OT 或 CRDT 等模型

如果两个用户同时在长文本中插入、删除,简单使用字符索引会失效:用户 A 在位置 10 插入文字后,用户 B 基于旧文档删除位置 10,操作含义已经变化。

Operational Transformation(OT)通常通过变换并发操作,让它们在不同到达顺序下得到一致结果;CRDT 通过可合并的数据结构和因果信息,使不同副本最终收敛。两者都不是加一个库就自动完成产品,仍要选择文本结构、删除语义、光标映射、持久化、压缩和权限模型。

最终一致性不等于每个时刻所有用户看到同样内容。产品要明确本地回显、远端操作到达、撤销、历史版本和冲突提示的体验;支付、权限和库存等强一致操作通常不适合直接套用协同编辑模型。

CRDT 收敛依赖具体算法的前提,例如副本最终收到需要的更新并采用一致的合并规则;不同副本若因权限过滤拿到不同更新集合,并不会凭空收敛成同一文档。也不能把数据库中的 JSON 随意深度合并就称为 CRDT。

本地回显的修改被服务端拒绝时,还需要产品恢复策略。用户可能在离线期间失去编辑权限,重连后不能仅显示“拒绝”却继续把本地未授权状态当作已同步文档。可以冻结本地分支供导出,或重新加载权威版本并明确提示;直接应用一个逆操作并不总能撤销已经合并的协同状态。

离线编辑需要操作日志和恢复协议

离线客户端应把本地变更和待发送操作持久化,重连后使用稳定操作 ID 发送,服务端确认后再从队列删除。网络超时不代表服务端没有应用操作,重试必须幂等,不能每次都生成新操作。

如果客户端长时间离线,服务端可能已经清理了历史操作。此时应下载当前快照,再按规则重放或合并本地变更;无法安全合并时,应让用户选择,而不是静默覆盖。

离线队列属于用户数据,账号退出、权限撤销、设备共享和存储配额都要处理。不要把操作日志发送到日志平台,也不要让下一个账号读取上一个账号的待同步内容。

权限和服务端权威不能靠 UI

宿主页面隐藏编辑按钮不是权限控制。每个操作都要在服务端检查文档、用户、租户和当前版本;用户从编辑状态变为只读后,已经打开的连接仍可能发送旧操作,服务端必须拒绝并返回明确结果。

协同协议还要限制操作大小、频率、嵌套深度和广播范围。一个客户端发送巨大的操作或极高频光标事件,可能消耗所有连接的内存和带宽。presence 可以合并和降频,文档操作应有队列上限和背压策略。

连接、重连和发布要支持混合版本

连接断开后,客户端需要重新认证、恢复订阅、提交最后确认版本并补齐缺失操作。新旧客户端可能同时在线,消息协议应有兼容窗口和版本协商;不能发布一个服务端立刻拒绝所有旧客户端仍会发送的字段。

服务器扩容后,多个实例需要共享文档状态、操作日志和广播机制,不能只把内存 Map 放在某个进程里。消息总线也不会自动提供业务顺序、幂等和持久化,数据层仍要定义权威版本和恢复方式。

测试要覆盖双客户端并发编辑、乱序和重复消息、断线重连、离线合并、权限撤销、大文本、快速光标移动和旧版本客户端。一个“两个浏览器都收到了消息”的演示,只证明了通道可用,不能证明协同系统已经一致、可恢复且安全。