跳到正文

协同编辑系统设计:OT 与 CRDT、在线状态与同步

实时协同编辑器如何工作:OT 与 CRDT 的对比、文档会话服务器、在线状态、持久化和离线编辑,附架构图。

· 创始人兼开发者

发布于

两个客户端经由负载均衡器,通过 WebSocket 连接到会话服务器 A;根据 Redis 目录,doc-123 归 A 所有。A 把编辑追加到 Postgres 中的操作日志,把快照写入对象存储,并通过身份提供商检查访问权限。服务器 B 拥有其他文档。

两个人在同一时刻往同一段落里打字。不到一秒,两块屏幕就显示出相同的文本,而且谁打的字都没有丢。要做到这一点,需要解决一个具体问题,即对共享状态的并发编辑,再加上一系列常见的基础设施问题:路由、持久化、在线状态和重连。

本文设计一个实时协同编辑器:用一个实例说明并发问题,比较操作转换(OT)与无冲突复制数据类型(CRDT),介绍 Figma 采用的由服务器做主的做法,以及围绕它们的架构,并配有一张图。

用五个字符说明核心问题

从文档 ABC 开始。位置从零开始计数。

  • Alice 在位置 1 插入 X,她的屏幕上变成 AXBC。
  • 同一时刻,Bob 删除位置 2,也就是 C,他的屏幕上变成 AB。

现在每个客户端都收到了对方的操作,并原样应用:

  • Bob 把“在 1 处插入 X”应用到 AB 上,得到 AXB。
  • Alice 把“删除位置 2”应用到 AXBC 上,删掉的是 B,得到 AXC。

两份文档出现了分歧,而且 Alice 的副本丢了一个谁都没删的字符。每一个协同编辑器都是为了防止这种情况而存在的。解决方案分为两大类。

操作转换

OT 把操作保存为位置,并把每个收到的操作针对它尚未见过的并发操作进行转换。Bob 的“删除位置 2”是在 Alice 于位置 1 插入之前做出的,而这次插入会把其后的所有内容向右挪一位。针对这次插入转换之后,它就变成了“删除位置 3”,Alice 的 AXBC 随之变成 AXB,与 Bob 一致。

生产环境中的 OT 系统用一台中央服务器为操作排序,这种做法可以追溯到 1995 年的 Jupiter 系统,Google Docs 在 2010 年重建编辑器时也介绍过:

  1. 每个客户端发送操作时,标注这些操作所基于的服务器修订版本。
  2. 服务器把每个操作针对自该版本以来已接受的所有操作进行转换,分配下一个修订号,并广播结果。
  3. 每个客户端把收到的操作针对自己尚未被确认的操作进行转换。

OT 的优势是操作紧凑,在文本领域有很长的实践历史。它的弱点在于转换函数本身:对于富文本、表格和嵌套结构,要把每一对操作类型都处理正确是出了名的难,而且这种设计依赖服务器来确定顺序。

CRDT

CRDT 是一种数据类型,其设计保证并发更新无论以什么顺序应用,所有副本最终都会收敛,Shapiro 等人在 2011 年对它做了形式化定义。对于文本,每个插入的字符都会获得一个唯一且有序的标识符,例如客户端 ID 加计数器;插入操作说的是“在某某 ID 的字符之后”,而不是“在位置 1”。删除操作把字符标记为已删除,留下墓碑(tombstone)。由于操作引用的是稳定的 ID,而不是会移动的位置,所以没有什么需要转换。

在这个例子里,Alice 的插入是“X 放在 A 之后”,Bob 的删除是“删除 C”。无论以什么顺序应用,两个副本最终都是 AXB。

Yjs 和 Automerge 等 CRDT 库让这种做法变得实用。它们的优势在于:不需要中央排序,所以离线编辑和点对点同步可以自然地工作,而且随时都能合并。它们的代价是:每个字符都有额外的元数据,墓碑需要压缩清理,还有一些边缘情况下合并结果虽然一致,却不符合任何一位用户的本意。服务器在访问控制、持久化和扇出方面依然有用,只是不必由它来决定顺序。

由服务器做主的折中方案

并不是每个编辑器都在编辑长文本。Figma 既没有选择经典 OT,也没有选择真正的 CRDT:它的服务器决定事件的顺序,文档是由带属性的对象组成的树,当两个人修改同一对象的同一属性时,服务器最后收到的值获胜。它的服务器为每个多人文档运行一个单独的进程,编辑该文档的所有人都连接到这个进程。对于冲突以属性为单位的设计工具、电子表格和白板来说,这比另外两种方案都简单。

操作转换 CRDT 服务器排序,按属性后写者胜
由谁为编辑排序 中央服务器 无人;合并可交换 中央服务器
离线编辑 有限 天然支持 有限
元数据开销 低 较高,按元素计 低
难点 转换函数 压缩与意图 选择冲突粒度
最适合 有服务器的富文本 离线优先和点对点应用 带属性的对象:设计工具、白板

架构

无论选择哪种算法,外围系统看起来都差不多:

  1. 客户端通过 WebSocket 连接到协作服务,并在文档打开期间保持连接。
  2. 每个文档只归一台会话服务器所有。 doc-123 的所有客户端都必须连到同一台服务器,这样它才能在内存中为编辑排序或合并编辑。一个目录(例如放在 Redis 中)用租约把文档映射到服务器,负载均衡器或路由层再把连接发往那里。也可以对文档 ID 做一致性哈希,前提是绝不能出现两台服务器同时认为自己拥有同一个文档的情况。
  3. 会话服务器在内存中保存实时文档,应用每个收到的编辑,并把它广播给其他客户端。
  4. 每个编辑都先追加到持久的操作日志中,服务器才会确认它。整个文档的定期快照写入对象存储,因此加载文档就是读取最新快照以及其后的操作。
  5. 在连接时和每次操作时都检查权限;查看者的编辑会被拒绝,而不只是被隐藏。

评论、版本历史、搜索和导出都是消费操作日志的独立服务,不属于实时路径。

在线状态是另一类数据

光标、选区和“谁在这里”不断变化,只在当下有意义,一分钟后就毫无价值。把在线状态当作临时数据:通过同一台会话服务器广播,限制在每个用户每秒几次更新,在客户端断开时丢弃,并且绝不写入操作日志。例如,Yjs 把在线状态放在与文档状态并列的独立 awareness 通道中。

离线编辑与重连

连接随时都会断,在火车上,在电梯里。客户端在本地保留尚未确认的操作,并在重连时重新发送:

  • 使用 OT 时,客户端把待处理的操作连同它看到的最后一个修订版本一起发送;服务器再把它们针对此后的所有操作进行转换。
  • 使用 CRDT 时,客户端和服务器交换各自已见内容的紧凑摘要(通常称为状态向量),只把对方缺少的更新发过去。

长时间离线正是 CRDT 的用武之地。使用 OT 时,离线一天的客户端也能重连,但服务器要针对一整天的操作进行转换。

扩展与故障

  • 热点文档。 一个有成千上万查看者、只有少数编辑者的文档,不应该让会话服务器亲自把每次按键推送到成千上万个套接字;应通过 pub/sub 层向查看者扇出,并批量发送更新。
  • 会话服务器崩溃。 客户端重连,目录中的租约过期,新服务器加载快照和操作日志,客户端重新发送待处理的操作。只确认已持久记录的编辑,是保证不丢数据的关键。
  • 脑裂。 两台服务器同时拥有同一个文档,就会接受相互冲突的顺序。用租约隔离所有权(fencing),并拒绝租约已过期的服务器发来的写入。
  • 撤销应该撤销你自己的上一次修改,而不是协作者的修改,因此它会变成一个新的逆操作,像其他操作一样被转换或合并。

协同编辑图

两个客户端经由负载均衡器,通过 WebSocket 连接到会话服务器 A;根据 Redis 目录,doc-123 归 A 所有。A 把编辑追加到 Postgres 中的操作日志,把快照写入对象存储,并通过身份提供商检查访问权限。服务器 B 拥有其他文档。

11 个组件 · 9 个连接

在 Archboard 中打开
图表内容
  • Alice: editor
  • Bob: editor
  • WebSocket entry
  • Collaboration service
  • Directory: doc-123 → A (lease)
  • Session server A
  • Session server B
  • Op log
  • Snapshots
  • Identity provider
  • edits + presence
  • doc-123 sessions
  • who owns doc-123?
  • renew lease
  • append, then ack
  • snapshot
  • check access on connect
  • other documents
一个实时协作后端。同一文档的所有客户端都通过 WebSocket 连接到拥有该文档的唯一会话服务器;该服务器对编辑进行排序或合并,连同在线状态一起广播,并持久化操作日志和定期快照。

这张图的读法如下:

  • 两个客户端经由负载均衡器通过 WebSocket 连接。
  • Redis 目录指明哪台会话服务器拥有 doc-123;两条连接都落在服务器 A 上。
  • 服务器 A 在内存中为编辑排序或合并编辑,并把编辑和在线状态广播回两个客户端。
  • 持久性:每个编辑在确认之前都写入 Postgres 中的操作日志;快照写入对象存储。
  • 身份提供商在客户端连接时检查访问权限;服务器 B 拥有其他文档,展示了服务如何横向扩展。
  • Markdown 块用三行比较了 OT 和 CRDT。

清晰地绘制实时系统

  • 展示所有权。 这里最重要的一个事实是一台服务器拥有一个文档;把目录和路由画出来。
  • 区分持久数据和临时数据。 画出操作日志和快照,并把在线状态标注为不持久化。
  • 至少画两个客户端。 并发就是全部问题所在;只画一个客户端会把它掩盖掉。
  • 标出确认点:编辑一旦进入操作日志就是安全的。

关于长连接和扇出,聊天应用架构图涉及类似的内容。把文档路由到服务器,是负载均衡器图中思路的一种应用,通用方法则见系统设计图示例。

在 Archboard 中构建

  1. 使用 Google 或 GitHub 登录,并新建一张图。
  2. 为客户端添加两个网络节点包中的 设备 节点,并添加一个云节点包中的 云负载均衡器 作为 WebSocket 入口。
  3. 为协作服务添加一个基础节点包中的 框架,在其中放两个 计算实例 节点:拥有 doc-123 的会话服务器 A,以及会话服务器 B。
  4. 为文档目录添加一个数据节点包中的 Redis 节点,为操作日志添加一个 Postgres 节点,并为快照添加一个云节点包中的 对象存储 节点。
  5. 添加一个安全节点包中的 身份提供商,并用带标签的连线把一切连起来:WebSocket、编辑与在线状态、追加、快照、租约。
  6. 添加一个基础节点包中的 Markdown 块,写上 OT 与 CRDT 的对比摘要。

这个示例共用 11 个图表节点,在 Free 套餐的限制之内。

常见问题

OT 和 CRDT 有什么区别? OT 把并发操作相互转换,通常由一台服务器确定顺序。CRDT 给每个元素一个稳定的标识,因此操作可以交换顺序,副本无论以什么顺序应用都会收敛,无需中央排序。

Google Docs 用的是 OT 还是 CRDT? Google 介绍其 2010 年重写的编辑器时说,它使用操作转换,由服务器为修改排序。许多较新的编辑器使用 Yjs 或 Automerge 等 CRDT 库。

为什么一个文档的所有编辑者都必须连接到同一台服务器? 这台服务器保存着实时文档,并决定编辑的顺序。如果两台服务器各自独立地接受同一文档的编辑,它们就得在每次按键时相互协调。

在线状态是如何存储的? 大多数时候根本不存储。光标和选区实时广播、限制频率,并在客户端断开时丢弃。把它们持久化只会增加负载,毫无价值。

协同编辑器可以离线工作吗? 可以。客户端在本地把操作排入队列,重连时再同步。CRDT 处理长时间离线最为自然;OT 也能工作,但重新上线时要转换更多操作。

免费开始 — 1 个席位,无需信用卡。

相关文章

← 返回博客

一分钟打开画布

使用 Google 或 GitHub 登录。单席位免费,团队加入后再升级。