随着云上办公和远程工作的普及,越来越多的在线办公软件加入了协同编辑功能,使得多人能够同时编辑同一份资源,提高工作效率。
对于多人协作,开发人员通常使用 git 作为版本管理工具来并行开发需求,通过 merge
指令将各自的修改合并。然而,当多个人同时更改同一处内容时,可能会产生冲突,需要开发人员手动解决:
这种模式对一般用户并不友好。以前,大多采用悲观锁的方式,即一个文档只允许一个用户编辑,其他用户处于锁定状态,以避免协同编辑冲突。然而,悲观锁方式简单粗暴,效率较低。我们希望多人能够同时编辑同一份文档,且在出现冲突时能够自动解决,无需像 git 那样手动处理,确保文档一致性,降低用户心智负担。这就需要应用复杂的协同处理算法。
协同算法的发展
协同编辑并非新兴技术,它已经有着数十年的发展历史。谈到协同编辑,常常会涉及两类算法:OT(Operational Transformation) 和 CRDT(Conflict-free Replicated Data Type)。
- 1989 年,OT 算法正式提出,标志着协同编辑技术的起步
- 2006 年,Google 首次将 OT 成功运用于商业产品 Google Docs
- 2011 年,微软在 Office 365 中基于 OT 实现了协同编辑
- 2011 同年,CRDT 算法提出,代表了一种新的协同编辑方案的出现
- 2012 年,Quill 编辑器开源,其数据模型 Delta 基于 OT 算法设计,降低了协同编辑门槛,随后被更多中小公司产品采用
- 2013 年,ShareDB 开源,一套基于 OT 的完整解决方案落地
- 2015 年,Yjs 开源,基于 CRDT 的协同方案逐步发展起来
OT 相较于 CRDT 发展更早,技术体系更为成熟。其压力主要在服务端,客户端无需像 CRDT 那样存储大量额外元数据,因此压力较小。目前,许多具备协同编辑功能的产品大多基于 OT 进行开发,例如语雀、钉钉文档、石墨文档等。
然而,随着 CRDT 的逐步崛起以及去中心化的流行( 毕竟从 Web 3.0 的韭菜热度来看,未来人们更想到 去中心化 的世界),新的产品也更多地开始基于 CRDT 开发协同编辑功能,例如 Figma、Miro 等。在当前环境下,若希望增强协同编辑能力, CRDT 也许是一个更合适的选择。
什么是 CRDT?
让我们从一个简单的例子开始,假设有一个购物车,它只能添加商品,并且每种商品只能添加一件。
现在有两个用户,Alice 和 Bob,他们都向同一个购物车添加商品
- 购物车中最初有:🍺 🍔 🍙
- Alice 添加了:🍎
- Bob 添加了:🍋 🥝
我们希望通过某种方式将 Alice 和 Bob 各自的数据发送到远端后,他们两个的购物车最终都包含:🍺 🍔 🍙 🍎 🍋 🥝。购物车中商品的顺序不重要,只要保持种类一致即可。
为了实现上述需求,我们可以使用如下的代码,实现了一个 merge
函数,当接收到远端的数据后,可以通过该函数将两个购物车的数据合并,最终达到一致的状态:
当购物车中的商品数量很多时,每次都将全部数据发送到远端会带来性能开销。为了提高传输效率,我们可以选择只将新增的商品直接发送到远端,提升传输效率。
至此,我们就实现了一个基于 CRDT 协同能力的黑心购物车。
回顾上面的例子,实现协同能力的关键在于数据结构在 merge
操作时满足以下性质:
- 交换律: <math xmlns="http://www.w3.org/1998/Math/MathML"> A ∨ B = B ∨ A A ∨ B = B ∨ A </math>A∨B=B∨A
- 结合律: <math xmlns="http://www.w3.org/1998/Math/MathML"> A ∨ B ) ∨ C = A ∨ ( B ∨ C ) A ∨ B) ∨ C = A ∨ (B ∨ C) </math>A∨B)∨C=A∨(B∨C)
- 幂等律: <math xmlns="http://www.w3.org/1998/Math/MathML"> A ∨ A = A A ∨ A = A </math>A∨A=A
由于网络问题的不稳定性,我们无法保证更新按固定顺序成功发送到远端。如果发送失败,可能需要多次重发。因此,CRDT 要求:只要各个端最终都接收到所有的更新信息,无论这些更新数据的顺序如何,是否存在多个相同的更新,只要能接受这些更新数据,最终都能达到一致的状态。只要数据结构的 merge
操作满足三定律,那么就符合 CRDT 的条件。
CRDT 有两种类型: 基于状态的 State-based CRDT(也称为 CvRDT) 和 基于操作 Op-based CRDT(也称为 CmRDT):
-
State-based CRDT 的思路为:各个端保存完整的数据,用户之间通过同步全量 States 来达到最终一致状态,这就是上面例子中第一种形式的 CRDT
-
Op-based CRDT 的思路为:各个端保存对数据的所有操作(Operations),用户之间通过同步 Operations 来达到最终一致状态,这就是上面例子中第二种形式的 CRDT
在形式上,Op-based CRDT 和 State-based 是可以互相转换的(下文中的例子的另一种形式也容易转换得到,就不再单独列出)。
CRDT Registers
CRDT 的发展已经有很长时间了,针对常用的数据结构,已经有现成的结构封装套路,可以将基础数据转换为满足 CRDT 条件的数据结构。
上面例子中使用的是 Grow-only Set(G-Set)的 Register。下面再介绍一些常用的 Register 类型。
Last Write Wins Register(LWW)
当多个用户同时对一个值进行设置时,可以采用"最新一个"值的原则来处理冲突,以确保最终达到状态一致性。
假设 Alice 和 Bob 同时在一个输入框中进行编辑,可以使用 Last-Writer-Wins(LWW)策略为每个用户的修改操作增加时间戳。在将数据分发到其他端时,根据时间戳获取最新的编辑操作。
Last Write Wins Map
在实际应用中,配置通常涉及多个值,因此通常会使用对象类型。
而 LWW 主要用于处理单个值的情况,当涉及到多个值时,我们需要使用 LWW Map,它的每个值实际上都是 LWW Register。当进行操作时,我们对每个的键值对应的 LWW Register 进行更新。
Lamport TimeStamp
前面 Register 都依赖 timestamp 来决定哪个操作是最新的,在中心化系统中,可以使用事件到达中心服务器的时间作为时间戳来判断事件的先后顺序,从而确定最新的操作。然而,在分布式环境中,这种方法不再适用。在分布式系统中,我们可以使用 Lamport Timestamp 作为事件发生的先后顺序依据。
Lamport Timestamp 的结构非常简单,它基本上是一个从 0 开始递增的计数器,每个端在开始编辑时,初始化一个为 0 的计数器,然后在本地有更新,或者接收到远端更新时,更新计数器
更新规则为:
- 发生本地事件时:
localClock += 1
。 - 接收到远程事件时:
localClock = max(remoteClock, localClock) + 1
List
在文档类的编辑工具中,文本之间确实存在明确的前后顺序,而前面提到的 Registers 并不适用于这种情况。
在这种情况下,我们可以考虑使用数组来表示文本数据的结构,其中数组的每一位代表一个字符。用户对文本的操作,我们可以抽象出 Ins
(插入)和 Del
(删除)指令,并将相应的坐标和数据与指令进行关联:
Ins
指令:表示在指定的坐标位置插入字符Del
指令:表示删除指定坐标位置的字符
通过使用这样的指令结构,我们可以记录用户对文本的编辑操作,在进行操作时,可以根据指令的坐标信息来确定操作的位置,并相应地更新文本数组。
如果在同时更改文本时仅使用坐标进行处理,可能导致 Del
指令在 Ins
指令之后执行,从而导致删除的文本位置不准确:
OT 的解法
前面提到,除了 CRDT 外,还有一种处理方式是 OT(Operational Transformation),翻译过来就是操作转换。
在使用 OT 时,每个用户都会将其编辑操作发送给中央服务器。服务器会根据当前的文档状态和接收到的操作,执行操作转换算法来调整操作的顺序和内容,以确保最终的结果是符合用户意图的。
OT 需要中央 Server 负责解决操作转换逻辑,以确保转换后的指令能够正确反映用户的编辑意图,这意味着 OT 对网络的要求较高,并且可以实现强一致性。如果某个用户出现网络异常,导致缺少了一些编辑指令,那么基于已有状态进行的转换可能会出现问题。此外,随着使用场景的复杂性增加,需要考虑多个操作之间的顺序、位置和内容,以及可能的冲突解决策略,OT 转换算法的复杂性也会呈指数级增长。
OT 的大概介绍就到此打住,我们回到 CRDT 的领域继续讨论。既然数组不能很好的满足我们的需求,那我们需要换一种数据结构,就有明确的前后顺序,插入和删除的成本也较低。自然,我们会想到使用链表:
- 文本每个文字使用链表连接,每个文本都有唯一 uid 作为标识
Ins
指令:表示在指定的节点后面插入字符Del
指令:表示删除指定的节点
既然文档中节点作为了定位的依据,那么节点就不能真正的被删除,比如 Alice 在 hello 的 o 后面增加了 m, 但是 Bob 删除这个 o,如果该节点真正的被删除,那么 Bob 在接收到 Alice 的更改后,就无从得到应该往哪里增加这个 m, 所以删除不能是真正的删除,只是将它标记为删除即可,这在 CRDT 中被称为墓碑(tombstone):
链表只是其中一种实现方式,实际情况中还会用到 B 树这种对数级插入和删除时间复杂度的数据结构来达到更优异的性能。
The Hard Parts
性能
相对于 OT,CRDT 整体算法复杂度较小,只需设计满足三定律的数据结构和 merge
能力即可实现基础版协同编辑工具,因此可以说是 easy to implement
。
但是同时也会变成 easy to implement badly
,如前所述,CRDT 相对于原本的属性或文本内容会增加大量元信息,用于处理协同过程中的冲突。删除的内容以 tombstone 的形式存在,加上需要考虑 undo/redo 的能力,CRDT 实际上需要维护一个单调增加的历史记录,类似于 git,无论是新增还是删除,都会让历史记录增长。与 git 不同的是,CRDT 很多时候需要在内存中维护这些记录。
因此,在早期,相对于 OT 将所有数据冲突解决都在服务器上,客户端只需维护很少量的元数据而言,CRDT 的性能较低,所以在选择上会更倾向 OT 的方案。
然而,近年来以 Yjs 为代表的框架将性能提升到了令人难以置信的地步。至少从现在来看,性能不再是选择 CRDT 的顾虑之一。
移动语义
在基础 CRDT 的冲突解决方案中,修改只有两种:insertion
和 deletion
,不存在 movement
。将移动操作看作是删除和插入的组合:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> m o v e m e n t = d e l e t i o n + i n s e r t i o n movement = deletion+insertion </math>movement=deletion+insertion
最初的协同编辑主要应用于文档编辑,在文档中不存在移动操作可能不会带来太大问题。如果用户想要移动一部分段落到其他地方,可以先删除该段文字,然后在目标地方插入。需要注意的是,即使新增的文字和之前删除的文字相同,对于内部来说它们是两个不同的实体,因为每个操作指令对应的 uid 是不同的。因此,如果两个用户同时移动相关的文本到不同的地方,可能会出现两段相同的文本。在文本编辑软件中,这样的结果可能还能被接受。
但是,对于像 Figma 这样的绘图或图表类软件,移动图表是基本的交互语义。如果不同的用户同时拖动同一个图表到不同的位置,就会在不同的位置出现多个相同的图表,这个问题就变得很严重。
因此,为了解决这个问题,需要根据具体的产品交互逻辑完善移动语义。之前的 List 实体模型可能无法直接使用,需要对其进行拆分,将位置信息和配置信息分离,借助类型为分数的索引方式来实现移动语义。关于这方面的详细讲解可以参考 CRDT: Fractional Indexing。
交错问题
以上述 Fractional Indexing 的方式为例,如果我们为每个节点分配一个小数作为其位置的参考,当两个用户同时在相同的位置新增文本时,可能会出现以下多种情况:
CRDT 并不总是能准确反映编辑者的意图,其关注点主要在于确保数据最终的一致性。也许上面的合并结果中,用户期望的是 my your
,但从数据层面无法准确得知。在这种情况下,重点在于确保包含指令合并后,各端的内容都是相同的结果,要么都是第一种,要么都是第 n 种。。
即使最终展示的结果一致,但数据的交错性可能会让人感到困扰。通常,我们希望同一端用户新增的数据是连续的,比如 my
和 your
这两个单词始终是在一起的。不同的 CRDT 框架对于这类交错(Interleaving)问题的处理方式各不相同,其中比较有名的是 RGA(Replicated Growable Array)。Figma 也实现了类似 RGA 的效果。关于这方面的详细讲解可以参考 CRDT: Tree-Based Indexing。
Tree Cycle
在一棵树内移动子树的确是一个复杂的问题,尤其在具有并发编辑的场景下。这种情况相当常见,例如文件系统就是一种树状结构,或者类似 Figma 这样的产品,其绘图信息也是树状结构的。
如果两个不同的用户同时将一个节点设置为对方节点的子节点,就会出现树循环(tree cycle)的问题,这种情况下,形成了一个循环,其中节点 A 同时是 B 的子节点,而 B 又是 A 的子节点,导致树结构出现问题。如下图所示:
C 形成了一个环,根本就不是一颗树了,而 D 就类似于之前没有 move 语义,而生成的新的副本,这两种情况都不是我们期望的,我们期望的是 A 或者 B 其中一种情况,可以采用类似 LWW 的方式选取最后一次更改来保证一致性。
解决这个问题的一种方法是在系统中引入一些规则或策略,以确保树的结构不会出现循环。例如,可以规定在移动子树时,系统检测并拒绝形成循环的操作,或者在发现循环时进行自动修复。关于这方面的详细讲解可以参考 CRDT: Mutable Tree Hierarchy。
基于 Yjs 的实践
在开发基于 CRDT 的应用时,通常无需从零开始理论开发,因为现在有许多优化的 CRDT 开源库可供使用。比如 AutoMerge 以及今天要讲到的 Yjs 都是其中的代表。
如前所述,Yjs 在性能方面表现出色。下图清晰地展示了其性能相比于其他框架的代差:
Shared Types
Yjs 中的冲突解决数据结构被称为 Shared Types。这是 Yjs 中的核心概念,用于表示可协同编辑的数据结构,如 Y.Text
,Y.Map
,Y.Array
等。
通常,我们的项目中使用 JavaScript/JSON 对象来表示应用的状态。现在只需增加一个简单的 Binding 层,将其转化为 Yjs 的 Shared Types,应用就能够自然地获得多人编辑的能力:
上面的 API 使用起来非常简单,它的内部就已经包含了冲突解决的机制了,也就是说我们只是简单的使用 Shared Types 所提供的 API,就已经实现了协同冲突处理的能,为应用快速增加多人协同编辑能力的支持。
Workflow
在使用 React 框架的前端项目中,通常会使用状态管理框架如 Redux 来管理全局状态。然而,Redux 的 subscribe 通常获得的是全量的数据,不能直接将全量数据直接设置到 Yjs 的 Binding 层,否则可能将实际上没有更新的配置也识别为已更新。因此,需要对已同步的实体模型和现在更新后的实体模型进行差异比较(diff),只针对真正更新的配置进行处理。
可以使用 deep-diff 这样的工具进行 JSON 的差异处理。拿到差异后,可以调用 Yjs 提供的 Shared Types 更新 API,对 Binding 层模型进行增量更新。基本的操作链路如下:
- subscribe Redux 状态的变化,获取全量的数据
- 使用 deep-diff 对比前后两次数据的差异,得到更新的配置
- 调用 Yjs 的 Shared Types 提供的更新 API,对 Binding 层模型进行增量更新
- 通过 Provider 将更新分发出去
- 远端在接受到更新后,Binding 层反向更新 Redux 数据
当然,在 React 的生态中,也不乏一些专注于通过响应式更新数据的状态管理库,比如 SyncedStore。Binding 层的设计可以更为简单。
Provider
CRDT 本身和网络方案是解耦的,我们可以选择任意的通信方案,只要能保证更新数据成功的同步到远端即可。Yjs 自己也提供了多种更新通信的能力,在 Yjs 的语义中,称之为 Provider,比如:
-
基于 websocket 的 y-websocket
-
基于 webrtc 的 y-webrtc
如果采用 C/S 模式,可以使用 y-websocket。后端走 Node 服务,需要提供 WebSocket(ws)服务进行消息分发,同时还需维护一份协同过程中的最新副本。新用户在加入后能够立即同步最新的副本,以保持与各个端的状态一致。
如果选择 P2P 模式,可以使用 y-webrtc。后端只需提供简单的 WebSocket 服务,用于 Signaling 服务以及相关的 STUN/TURN 服务。这种架构更加纯粹,适合那些受限于架构体系、无法部署 Node 的情况。然而,新用户加入时需要向各个客户端建立 P2P 连接,然后从各个客户端同步最新的副本,效率上可能相对较低。
由于笔者所开发的产品是内网产品,且后端架构都是基于 Spring,因此无法直接使用现有的 Provider。最终,我们结合了 y-websocket 和 y-webrtc 两者的特性,自定义了符合业务场景的 Provider:
- 服务端只需要提供基础的消息转发能力,前后端之间增加了几个指令用于副本同步和消息分发。自定义 Provider 在各个端维持副本,后端完全和相关的副本处理逻辑解耦
- 前后端通过 Socket.IO 进行通信,能够十分方便地提供各种指令,并进行指令和二进制数据同时的分发处理,以保证即使在大量更新的情况下,数据传输的实时性 Ï
如果自定义 Provider,就需要深入了解状态同步过程中的一些处理细节。这部分内容官方文档相对较简单,可以参考 Yjs Fundamentals --- Part 2: Sync & Awareness 这篇文章,作者详细介绍了 Yjs 的同步过程。了解这些基础知识对于理解自定义 Provider 的实现过程非常重要。配合着 Provider 部分的源码食用更佳。
走自定义 Provider 的路线确实可以将独属于 Yjs 的优化处理放在前端处理,这样后端可以专心处理通信,而无需再实现特定语言的 Yjs 的胶水层。这种方式在开发成本上通常会更为灵活和高效。
Awareness
在协同编辑过程中,除了保证数据的一致性外,为了优化交互体验,还需要提供诸如:当前有哪些用户在编辑 ,每个用户在编辑哪部分,光标在哪里 等等信息:
这些信息在 Yjs 的语义中被称之为 Awareness。通常,这些数据量都比较少,因此 Yjs 内部采用了 State-based CRDT 这种处理起来更简单的方式来发送更新。
在默认的 Provider 中,每个客户端每 30 秒向服务器发送一次 Awareness。即使数据本身没有变化(此时只是简单的将内部的 clock+1),如果在过去 30 秒内未从客户端收到感知消息,其他客户端会将其标记为离线。
至此就是本文的全部内容,希望能对你有所帮助,如有错误欢迎指正。
参考资料
受限于文章篇幅,以及笔者自身对协同编辑的诸多技术细节也还在研究中,所以文章没有面面俱到 CRDT 的所有。这里列出一下学习和开发过程中阅读到的不错的参考资料。
CRDT
- 多人协同编辑技术的演进:协同编辑历史发展和 OT、CRDT 的介绍
- crdt.tech:CRDT 世界的大门,几乎包含了大部分 CRDT 相关优质资料的索引
- An Interactive Intro to CRDTs:带你打造一个简易的 CRDT 绘图工具
- Designing Data Structures for Collaborative Apps: CRDT 所涉及到的常见 Registers 的设计
- An introduction to Conflict-Free Replicated Data Types: 关于 CRDT 的由浅入深讲解
- Conflict-Free Replicated Data Types (CRDT) for Distributed JavaScript Apps:CRDT 基于 LSEQ 的 text 模型实践
Figma
- How Figma's multiplayer technology works:Figma 如何实现协同编辑
- Algorithm List: Figma 对 移动语义,Tree Cycle 等问题的解题思路
Yjs
-
Near Real-Time Peer-to-Peer Shared Editing on Extensible Data Types:Yjs 算法设计,以及正确性证明
-
How Yjs works from the inside out:长达三个小时的安眠视频,Yjs 作者讲述了他在框架里面所做的优化细节
-
Are CRDTs suitable for shared editing? :同样是 Yjs 作者对框架内部优化细节的讲解
-
探秘前端 CRDT 实时协作库 Yjs 工程实现:AFFiNE 核心开发者对 Yjs 工程实现的细节讲解,[AFFiNE] affine.pro/)是一款基于 yjs 打造的开源协同编辑文档工具
-
5000x fater CRDTs: An Adventure in Optimization: OT 库 ShareDB 作者 Seph Gentle 深度剖析了 CRDT 工程性能改进的历程