两个人在同一时刻往同一段落里打字。不到一秒,两块屏幕就显示出相同的文本,而且谁打的字都没有丢。要做到这一点,需要解决一个具体问题,即对共享状态的并发编辑,再加上一系列常见的基础设施问题:路由、持久化、在线状态和重连。
本文设计一个实时协同编辑器:用一个实例说明并发问题,比较操作转换(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 年重建编辑器时也介绍过:
- 每个客户端发送操作时,标注这些操作所基于的服务器修订版本。
- 服务器把每个操作针对自该版本以来已接受的所有操作进行转换,分配下一个修订号,并广播结果。
- 每个客户端把收到的操作针对自己尚未被确认的操作进行转换。
OT 的优势是操作紧凑,在文本领域有很长的实践历史。它的弱点在于转换函数本身:对于富文本、表格和嵌套结构,要把每一对操作类型都处理正确是出了名的难,而且这种设计依赖服务器来确定顺序。
CRDT
CRDT 是一种数据类型,其设计保证并发更新无论以什么顺序应用,所有副本最终都会收敛,Shapiro 等人在 2011 年对它做了形式化定义。对于文本,每个插入的字符都会获得一个唯一且有序的标识符,例如客户端 ID 加计数器;插入操作说的是“在某某 ID 的字符之后”,而不是“在位置 1”。删除操作把字符标记为已删除,留下墓碑(tombstone)。由于操作引用的是稳定的 ID,而不是会移动的位置,所以没有什么需要转换。
在这个例子里,Alice 的插入是“X 放在 A 之后”,Bob 的删除是“删除 C”。无论以什么顺序应用,两个副本最终都是 AXB。
Yjs 和 Automerge 等 CRDT 库让这种做法变得实用。它们的优势在于:不需要中央排序,所以离线编辑和点对点同步可以自然地工作,而且随时都能合并。它们的代价是:每个字符都有额外的元数据,墓碑需要压缩清理,还有一些边缘情况下合并结果虽然一致,却不符合任何一位用户的本意。服务器在访问控制、持久化和扇出方面依然有用,只是不必由它来决定顺序。
由服务器做主的折中方案
并不是每个编辑器都在编辑长文本。Figma 既没有选择经典 OT,也没有选择真正的 CRDT:它的服务器决定事件的顺序,文档是由带属性的对象组成的树,当两个人修改同一对象的同一属性时,服务器最后收到的值获胜。它的服务器为每个多人文档运行一个单独的进程,编辑该文档的所有人都连接到这个进程。对于冲突以属性为单位的设计工具、电子表格和白板来说,这比另外两种方案都简单。
| 操作转换 | CRDT | 服务器排序,按属性后写者胜 | |
|---|---|---|---|
| 由谁为编辑排序 | 中央服务器 | 无人;合并可交换 | 中央服务器 |
| 离线编辑 | 有限 | 天然支持 | 有限 |
| 元数据开销 | 低 | 较高,按元素计 | 低 |
| 难点 | 转换函数 | 压缩与意图 | 选择冲突粒度 |
| 最适合 | 有服务器的富文本 | 离线优先和点对点应用 | 带属性的对象:设计工具、白板 |
架构
无论选择哪种算法,外围系统看起来都差不多:
- 客户端通过 WebSocket 连接到协作服务,并在文档打开期间保持连接。
- 每个文档只归一台会话服务器所有。
doc-123的所有客户端都必须连到同一台服务器,这样它才能在内存中为编辑排序或合并编辑。一个目录(例如放在 Redis 中)用租约把文档映射到服务器,负载均衡器或路由层再把连接发往那里。也可以对文档 ID 做一致性哈希,前提是绝不能出现两台服务器同时认为自己拥有同一个文档的情况。 - 会话服务器在内存中保存实时文档,应用每个收到的编辑,并把它广播给其他客户端。
- 每个编辑都先追加到持久的操作日志中,服务器才会确认它。整个文档的定期快照写入对象存储,因此加载文档就是读取最新快照以及其后的操作。
- 在连接时和每次操作时都检查权限;查看者的编辑会被拒绝,而不只是被隐藏。
评论、版本历史、搜索和导出都是消费操作日志的独立服务,不属于实时路径。
在线状态是另一类数据
光标、选区和“谁在这里”不断变化,只在当下有意义,一分钟后就毫无价值。把在线状态当作临时数据:通过同一台会话服务器广播,限制在每个用户每秒几次更新,在客户端断开时丢弃,并且绝不写入操作日志。例如,Yjs 把在线状态放在与文档状态并列的独立 awareness 通道中。
离线编辑与重连
连接随时都会断,在火车上,在电梯里。客户端在本地保留尚未确认的操作,并在重连时重新发送:
- 使用 OT 时,客户端把待处理的操作连同它看到的最后一个修订版本一起发送;服务器再把它们针对此后的所有操作进行转换。
- 使用 CRDT 时,客户端和服务器交换各自已见内容的紧凑摘要(通常称为状态向量),只把对方缺少的更新发过去。
长时间离线正是 CRDT 的用武之地。使用 OT 时,离线一天的客户端也能重连,但服务器要针对一整天的操作进行转换。
扩展与故障
- 热点文档。 一个有成千上万查看者、只有少数编辑者的文档,不应该让会话服务器亲自把每次按键推送到成千上万个套接字;应通过 pub/sub 层向查看者扇出,并批量发送更新。
- 会话服务器崩溃。 客户端重连,目录中的租约过期,新服务器加载快照和操作日志,客户端重新发送待处理的操作。只确认已持久记录的编辑,是保证不丢数据的关键。
- 脑裂。 两台服务器同时拥有同一个文档,就会接受相互冲突的顺序。用租约隔离所有权(fencing),并拒绝租约已过期的服务器发来的写入。
- 撤销应该撤销你自己的上一次修改,而不是协作者的修改,因此它会变成一个新的逆操作,像其他操作一样被转换或合并。
协同编辑图
图表内容
- 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 连接。
- Redis 目录指明哪台会话服务器拥有
doc-123;两条连接都落在服务器 A 上。 - 服务器 A 在内存中为编辑排序或合并编辑,并把编辑和在线状态广播回两个客户端。
- 持久性:每个编辑在确认之前都写入 Postgres 中的操作日志;快照写入对象存储。
- 身份提供商在客户端连接时检查访问权限;服务器 B 拥有其他文档,展示了服务如何横向扩展。
- Markdown 块用三行比较了 OT 和 CRDT。
清晰地绘制实时系统
- 展示所有权。 这里最重要的一个事实是一台服务器拥有一个文档;把目录和路由画出来。
- 区分持久数据和临时数据。 画出操作日志和快照,并把在线状态标注为不持久化。
- 至少画两个客户端。 并发就是全部问题所在;只画一个客户端会把它掩盖掉。
- 标出确认点:编辑一旦进入操作日志就是安全的。
关于长连接和扇出,聊天应用架构图涉及类似的内容。把文档路由到服务器,是负载均衡器图中思路的一种应用,通用方法则见系统设计图示例。
在 Archboard 中构建
- 使用 Google 或 GitHub 登录,并新建一张图。
- 为客户端添加两个网络节点包中的 设备 节点,并添加一个云节点包中的 云负载均衡器 作为 WebSocket 入口。
- 为协作服务添加一个基础节点包中的 框架,在其中放两个 计算实例 节点:拥有
doc-123的会话服务器 A,以及会话服务器 B。 - 为文档目录添加一个数据节点包中的 Redis 节点,为操作日志添加一个 Postgres 节点,并为快照添加一个云节点包中的 对象存储 节点。
- 添加一个安全节点包中的 身份提供商,并用带标签的连线把一切连起来:WebSocket、编辑与在线状态、追加、快照、租约。
- 添加一个基础节点包中的 Markdown 块,写上 OT 与 CRDT 的对比摘要。
这个示例共用 11 个图表节点,在 Free 套餐的限制之内。
常见问题
OT 和 CRDT 有什么区别? OT 把并发操作相互转换,通常由一台服务器确定顺序。CRDT 给每个元素一个稳定的标识,因此操作可以交换顺序,副本无论以什么顺序应用都会收敛,无需中央排序。
Google Docs 用的是 OT 还是 CRDT? Google 介绍其 2010 年重写的编辑器时说,它使用操作转换,由服务器为修改排序。许多较新的编辑器使用 Yjs 或 Automerge 等 CRDT 库。
为什么一个文档的所有编辑者都必须连接到同一台服务器? 这台服务器保存着实时文档,并决定编辑的顺序。如果两台服务器各自独立地接受同一文档的编辑,它们就得在每次按键时相互协调。
在线状态是如何存储的? 大多数时候根本不存储。光标和选区实时广播、限制频率,并在客户端断开时丢弃。把它们持久化只会增加负载,毫无价值。
协同编辑器可以离线工作吗? 可以。客户端在本地把操作排入队列,重连时再同步。CRDT 处理长时间离线最为自然;OT 也能工作,但重新上线时要转换更多操作。