基于CRDT的一种文档冲突算法

2,958 阅读9分钟

当多个人同时编辑一个在线文档时,如何处理多人操作的冲突,一直是大家讨论的热点话题。解决协作冲突业界使用最多的两种思路是基于OT(Operation Transformation)的文档合并算法和基于CRDT的文档合并算法。其中OT算法我们之前已经详细介绍过,本文就不再讨论了。本文我们主要介绍基于CRDT的一种文档合并算法-YATA。它有自己的开源实现Yjs

以下内容主要参考自论文:Near Real-Time Peer-to-Peer Shared Editing on Extensible Data Types

简介

在最近的三十年中,CSCW社区对“近实时”协同技术进行了深入研究。其中在线编辑领域对OT算法的研究成果得到了广泛使用,比如在Google Docs中应用。此类文档合并算法有一个很大的优势,不需要依靠锁定,而是通过对多个冲突操作的合并,来确保文档内容的一致性。这样可以在保留用户操作意图的前提下,提高系统的运行效率,支持更多的人实时协同。但是也存在一些缺点:每一个文档必须依靠一个服务器实例进行合并冲突计算,会增加服务端的压力,而且需要冗余副本和重试保障高可用性。

随着Web通信协议的发展,WebRTC, Websockets, XMPP over Websockets, Server-Sent Events等通信技术也被工业界和学术界采用。在多人协作场景下点对点传输成为客户端-服务器方法的可替代方案,但是业界可用的OT算法基本都是为客户端-服务端架构设计的。CRDT算法支持客户端-服务器架构的同时也能很好的支持点对点的传输协议。

为了降低用户使用协作框架的成本,尤其是提供直接在浏览器中可运行的程序,论文的作者提出了“YATA”(基于CRDT思想的协作算法),并提供了开源实现Yjs。

YATA方法

创建YATA是为了给Web上的P2P并发控制提供可扩展的解决方案,主要目标是允许在Web浏览器中对Web页面(DOM元素)、图形、列表、对象和任意类型的数据进行P2P协作编辑,使用最先进的网络协议进行消息传播。因此,该算法提出了一种使用链表的基本结构,通过扩展链表结构可以实现更复杂的支持协作的共享数据类型。YATA的链表表示方法和预定义规则的集合限制了可能冲突的数量,并确保了用户意图的正确性和操作的收敛性。其核心思想是对共享数据类型强制进行全部排序。YATA还支持离线编辑,旨在应对来自Web和移动客户端的需求,例如低贷款时操作更新、打开和关闭连接、接收时的随机消息顺序等。

YATA目前支持线性数据、树、关联数组和图形的协作数据类型,同时可以使用这些类型,创建更复杂的数据类型。

前提说明

为每个用户分配唯一标识符操作计数器,每当用户发生一次操作时计数器递增,因此可以用标识符和计数器唯一标识一次操作。

YATA用双向链表表示线性数据(如文本)。我们只定义两种类型的操作:插入删除。当插入内容被删除时,并不会直接删除元素,而是把元素标记为删除状态,因此删除操作并不会直接影响插入逻辑。我们会通过专门设计的垃圾回收机制(后续介绍),真正删除用户删除的内容。

我们用Ok ( IDk , Origin(k) , Left(k) , Right(k) , IsDeleted(k) , Content(k) ),表示一次插入操作。其中IDk是用户的唯一标识,IsDeleted(k)代表是否删除,Content(k)代表实际的操作内容。Origin(k)、Left(k)、Right(k)是已经存在的插入操作。我们把线性数据用双向链表S表示,其中Left(k)和Right(k)代表双向链表的前置节点和后置节点,Origin(k)是创建节点的直接前身。

我们用<表示链表S上的前置节点。O1<O2代表O1是O2的前置节点。 O1<=O2代表O1是O2的前置节点或O1和O2是相同操作。例如:当用户在局部位置创建新的插入时,新的插入会在Oi和Oj之间,可以用公式表达:Onew (IDk , Oi , Oi , Oj , f alse, Content(new) )。对于S链表的最前侧和最后侧使用特殊分隔符代表。当用户本地处理完插入操作后,将其广播到其它客户端。

YATA

图一: 如图一,某客户端接收到操作Onew正在被插入到双向链表S中,红色的连线代表了左右两个节点,Onew最终会经过计算插入到红色连线的两个节点中间。如果插入中又有新的插入操作,此时会产生冲突,需要解决冲突合理分配插入位置。

意图保全:当且仅当Onew插入到Left(i)和Right(i)两个操作之间时,用户的操作意图才会被保留。因为用户在文档中插入的每个字符保持和其相邻字符的相对位置可以有效的保留用户意图,这和其它资料中对于意图保留的定义是一致的。

并发插入:在图一中Onew插入的字符串T本来应该直接插入到Y和A(最后一个A)之间,但是O2和O3插入的字符串AT已经插入到了字符串YA之间,此时Onew、O2和O3是并发插入存在冲突。

冲突插入:还是如图一推演,S = Left(new) ·C1 ·C2 ·..·Cn ·Right(new),我们认为Onew和C1..Cn冲突。

为了在冲突中找到严格全序操作,我们定义如下三条规则:
规则一:禁止互相冲突的操作之间有交叉连接的原点。允许的两种Case分别是:插入操作在其它操作和它的原始操作之间;一个操作的原点是另一个操作的后续。我们可以参照下图理解这句话,下图是被允许的两种情况。 规则二:当指定O1<O2时,不会存在另外一个操作比O2大同时比O1小。
规则三:当两个冲突的插入操作具有相同Origin时,用户ID小的操作在左侧。此规则参照了OT算法。

接下来论文根据三条规则进行了冲突操作严格全序的证明。证明过程以数学公式推导为主比较复杂,本文中省略,感兴趣的同学可以翻看论文。

插入算法

前面已经证明了冲突操作存在全序关系,那么当有一个有序的插入操作列表时,我们如何计算新插入操作的位置呢? 伪代码:

// Insert ’i’ in a list of
// conflicting operations ’ops ’.
insert(i, ops){
  i . position = ops [0]. position 
  for o in ops do
    // Rule 2:
    // Search for the last operation // that is to the left of i.
    if(o<i.origin
        OR i.origin <= o.origin)
        AND (o. origin != i . origin
        OR o.creator < i.creator) do
      // rule 1 and 3:
      // If this formula is fulfilled , // i is a successor of o.
      i . position = o. position + 1
    else do
        if i.origin > o.origin do
          // Breaking condition ,
          // Rule 1 is no longer satisfied // since otherwise origin
          // connections
          break
}

垃圾回收

理论上如果所有的客户端都接收到了删除操作,此操作是可以被真正删除的。但是如何判断所有客户端都已经成功处理删除操作需要消耗过多的网络资源,并导致同步性能下降。

当前的实现方法是,假设在固定时间段T之后检测所有客户端是否执行了删除操作来简化问题。

Yata使用了双层缓存来处理垃圾回收问题,一旦O可以被垃圾回收,它会被移到第一层缓冲器中。如果没有被恢复,T秒之后它被移动到第二个缓冲器中等待被真正移除。

由于YATA的三条规则,在某些情况下无法删除插入操作。因为在两个需要删除的插入操作之间有新的插入操作,如果删除了前置操作或后续操作都会导致这次插入存在问题,如下图示例一样。

为了确保一致性,YATA要求始终在最左边的未删除字符及其直接后继者之间进行新的插入操作。只有这样,垃圾回收器才能移除第一个删除的插入操作右侧的所有操作。 此外,YATA中的垃圾收集器对延迟连接支持不友好。这是因为当用户脱机时间超过T秒时,它仍将保留对已删除操作的引用,而已执行某些删除的联机用户则不会保留。因此,YATA不支持在网站离线时对其进行垃圾回收。

支持离线编辑

YATA支持每个客户端离线编辑,并把操作记录在本地,客户端联网后,YATA会检查本地数据和共享数据的不同并完成数据同步。

每一个网站保存一个状态向量。假设ID为1的用户1和ID为2的用户2在一个会话中,每个用户都有两个插入操作,此时状态向量表示为:[(1,2),(2,2)] 状态向量仅向所有客户端发送一次,一个用户接收状态向量,将其与本地状态向量进行比较,并将所有剩余操作发送到其它客户端。为了使操作在远程实例上可集成,操作以其创建的顺序和形式发送。YATA可以将集成操作转换为其原始形式。

算法复杂度

几种CRDT合并算法的时间复杂度分析如下图 :

扩展类型

本节主要描述了YATA支持的基本操作类型和通用数据结构。在基本数据结构的基础上,可以实现某些抽象数据类型,从而使通用数据格式(如JSON和XML) 可以协作编辑。当前支持的类型包括线性数据类型 (例如,数组、链表、排序数组、位图)、树、图 和关联数组。

List Manager Operation

List Manager Operation是管理插入操作的抽象数据类型。新的插入操作根据YATA的规则放在两个分隔符之间的某个位置。 List Manager Operation还处理如何寻址关联列表中的元素以及如何将其转换为特定数据类型(例如字符 串)。它表示线性数据结构,如列表和数组,也可以表示树状数据结构。

Replace Manager Operation

YATA仅支持插入和删除操作,但是在处理更复杂的类型时,为了简化开发还需要更新操作,因此YATA通过提供支持内容替换的专用类型来支持现有内容的更新。举个例子,考虑两个用户(用户ID分别为1和2)同时将文本中的数字0替换为其各自的用户ID的情况。为了保持一致性,每个站点都应执行替换操作并达成最终结果一致,即1或2将替换旧的数字0。Yata通过使用确保一致性的数据类型将其转换为已解决的问题。 The Replace Manage继承了List Manager Operation。The Replace Manager的第一个实际插入内容(不能是分隔符),为了在用户执行新插入后替换此内容,新内容将作为Replace Manager的第一次插入而添加。上图中红线引用了Replace Manager的当前内容。由于YATA保证所有网站最终都会有相同的内容,因此所有网站最终都会引用与Replace Manager内容相同的插入内容。

Map Manager Operation

上图展示了YATA对Map Manager的表示,为了支持共享map上的并发操作,为map的每一个key分配Replace Manager管理器。

更多数据类型表示

YATA通过组合上面介绍的简单类型,可以将 JSON或XML等数据格式实现为共享数据类型。

后半部分论文主要描述了Yjs的性能表现及当前已经使用Yjs的一些产品,本文就不再详述了。

总结

文中的内容主要翻译论文和少量的个人理解,感兴趣的同学建议在阅读本文之后再去完整读一遍论文。

本文我们主要介绍YATA实现的基本思路,作为CRDT类型的协作算法,YATA及其实现库Yjs有很好的性能表现,而且支持点对点传输,为我们实现非客户端-服务器模式提供了理论基础和实践方案。我们也会在后续的项目中落地基于Yjs的协作方案。

最后,我也在学习和使用Yjs中,欢迎大家私信一起探讨YATA和Yjs的相关技术。

诚邀关注公众号:一行舟
每周更新技术文章,和大家一起进步。