在一个协同编辑的场景中,多个用户会同时对一份文档进行编辑。同一时间,该文档存在多个副本。不同用户在不同副本上的增删改
操作有时候会产生冲突。在 Near Real-Time Peer-to-Peer Shared Editing on Extensible Data Types 中讲述的 YATA 算法
就是用于解决这类冲突的一种方案。YATA 算法
首先提出了线性数据插入算法,然后将算法扩展到了不同数据类型的不同操作。
本文将介绍 YATA 算法
中线性数据插入算法。首先给出线性数据插入操作的定义,然后讲述该算法的感性的认识,接着给出 YATA 的插入算法
的 typescript 实现,最后讲述 Yjs 多类型扩展的实现。本文没有该算法的严格证明。如果读者对证明感兴趣,请移步论文原文。
线性数据插入操作的定义
type Client = number; // 副本的唯一标识符
type Clock = number; // 每个副本的操作计数器
type Id = [client: Client, clock: Clock]; // 操作的唯一标识符
// 插入操作
type Item<T = string> = {
content: T | null;
id: Id;
originLeft: Id | null;
originRight: Id | null;
};
// 各个副本协同的文档
interface Doc<T = string> {
content: Item<T>[];
}
线性数据插入操作的定义模仿了 Yjs 中对插入操作的定义。如上面的类型所示,一个插入操作 Item
包含了唯一的操作标识符 ID
、插入操作的内容和插入的意图 originLeft
和 originRight
。这里需要注意的是,YATA 中的插入意图只有 originLeft
,Yjs 为了解决一种特殊情况,添加了 originRight
,下文会讲述。操作的唯一标识符 ID
是由 Client
和 Clock
组成的元组,其中 Client
是副本的唯一标识符,Clock
是一个副本产生操作的累加计数器,每产生一个操作,计数器加 1。在 Yjs 中,如果连续的插入操作的内容都是同类型的文本,为了降低空间复杂度,会将这些连续的文本操作合并成一个操作。考虑到以后有把这些操作拆分的需求,所以在 Yjs 中,Clock
每次加的是内容的长度。
除了插入操作Item
的定义外,这里还给出了一个文档的定义Doc
。考虑一个纯文本文档,对于文档的编辑只包含插入和删除两种操作。在 YATA 中,删除采用了墓碑法,对于文档的操作只有插入文本一个操作。所以,文档可以看成是插入操作的集合
。为了保证各个副本之间的收敛,需要插入操作的集合
满足全序关序
,即每两个插入操作
都有一个前后关系。YATA 插入算法正是在这样一个前提下,提出了三条规则,最后根据这三条规则,提出了线形数据的插入算法。具体的细节,请读者移步论文原文或者我给出的论文翻译,有具体的解释。Yjs 对于文档内容Doc.content
的定义采用的是操作的链表结构,这里为了更方便的解释算法,在不考虑时间复杂度的情况下,采用了数组结构 Item<T>[]
。
插入算法的感性认识
在讲述本节之前,我们先对符号做一个约定。a < b
指 a 在 b 的左边且相邻;a << b
指 a 在 b 的左边,可能相邻;a = b
指 a 和 b 是一个元素。假设 在 后插入, 是 的插入意图,则满足 ,而不一定满足 ,因为在 和 之间可能还会插入其他的元素。(注:这个约定只适合本节,跟论文没有关系)
为了解释插入算法,不失一般性,我们假设有两个副本在进行协作。副本 1 在 后面插入了 ,即 ;副本 2 在 后面插入了 ,即 。插入算法的核心在于插入操作的集合
满足全序关序
,每两个插入
都有一个前后关系。现在要将副本 1 产生的 与副本 2 产生的 集合在一起,为了保证副本 1 和副本 2 有相同的前后顺序,就需要计算出 和 的前后关系。
我们以将 插入到副本 2 为例,计算与的前后关系。通过总结归纳,我们知道此时的副本 2 有以下四种排列。
考虑第三种排列。因为 ,我们可以得出副本 2 此时的排列是 ,即 。
接下来,通过分析剩下的三种排列,得出插入算法的规则。
第二种排列:
考虑第二种排列。此时 ,即需要根据这个前提得出 和 的相对位置, 或者 。
假设可以通过 可以得出 。我们考虑一种极端情况, 并且在当前副本下的 后插入了一个 ,此时这四个的相对顺序是 。插入完成后,当前副本将 广播到其他副本后,根据当前规则,可以得出接到广播的副本的相对位置,即。可以发现两个副本的相对位置不一样了,即两个副本发生了冲突。综上所述,不可以根据此规则来判断的相对位置。
上述的假设失败后,只剩下通过 可以得出 。该假设就是 YATA 提出的规则之一。在论文中有严格的证明,这里不做赘述。根据此规则,我们可以得出副本 2 中各个插入的相对顺序 。
第一种排列:
当 ,我们可以发现 和 的之间有一个 。所以先分析 、 和 的关系。不失一般性,我们总是可以找到在 和 之间的元素,直到找到一个与 相邻的元素 。这种情况下,。因为 的插入意图 和 的关系是 。
如果 ,则此时元素的相对位置是 。我们发现此时, 的在副本 2 中的位置转化成了 与 的关系。又因为 ,则与第四种排列是一样的。这里不做赘述。
如果 ,则此时元素的相对位置是 。我们发现这种关系跟第二种排列是相同的,同样不做赘述。可以得出 应该插在 的前面,即。又因为 ,可以得出 。综上,可以得出 与 的关系是
第四种排列:
当 , 意味着 和 同时在一个操作后面插入。这是一种特殊的关系,YATA 通过约定来解决这个问题。即通过比较副本 1 和副本 2 的客户端唯一标识 Client
来决定 和 的相对顺序。但是,不能通过 Client
的大小,直接决定操作的插入位置。下面通过两个实际的例子去讨论。
考虑一种实际情况,文档的初始排列是 ,副本 2 插入 后的排列是 ,副本 1 在副本 2 的 前面插入了 ,它的排列是 。我们可以发现 和 的插入意图都是 。假设根据 Client
大小,可得出 。当副本 1 的 集成到副本 2 后,如果直接根据该规则,可以得出 副本 2 的排列是 。这会与副本 1 的排列冲突。为了解决这个问题。需要引入插入意图的右链接 。一个操作必须在它的右链接前插入。这样就可以避免这种冲突的出现。
除了上述的情况。还需要考虑一种情况。副本 1 的排列是 ,副本 3 的排列是 。假设直接通过 Client
大小,决定操作的插入位置,并且约定 。那么当 插入副本 1 时,可以得到排练 ;当操作 和 插入副本 3 时,可以得倒排练 。这会造成副本 1 和副本 3 冲突。为了解决这个问题,需要保持插入的位置,继续向后比较,直到满足第二种排列的比较为止。
YATA 插入算法
的 typescript 实现
综上所述,我们可以得出线性插入算法。
// 把一个新的操作 Item 插入到一个副本Doc 中
export function integrate<T>(doc: Doc<T>, newItem: Item<T>) {
let left = findItem(doc, newItem.originLeft);
let destIdx = left + 1;
let right = newItem.originRight == null
? doc.content.length
: findItem(doc, newItem.originRight);
let scanning = false;
for (let i = destIdx;; i++) {
if (!scanning) destIdx = i;
if (i === doc.content.length) break;
if (i === right) break;
const other = doc.content[i];
let oleft = findItem(doc, other.originLeft);
let oright = other.originRight == null
? doc.content.length
: findItem(doc, other.originRight);
if (
oleft < left ||
(oleft === left && oright === right && newItem.id[0] <= other.id[0])
) {
break;
}
if (oleft === left) scanning = newItem.id[0] <= other.id[0];
}
doc.content.splice(destIdx, 0, newItem);
}
// 寻找 needle 操作在 doc.content 的索引。
function findItem<T>(doc: Doc<T>, needle: Id | null): number {
if (needle == null) return -1;
const idEq = (a: Id | null, agent: Agent, seq: Seq): boolean => a != null && a[0] === agent && a[1] === seq;
const [agent, seq] = needle;
const idx = doc.content.findIndex(({ content, id }) => idEq(id, agent, seq));
return idx;
}
Yjs 多类型扩展
Yjs 是 YATA 算法
的开源实现。她通过 AbstractType
实现了多种类型的数据扩展。AbstractType
本质上是起一个组织数据的作用,底层还是一个线性数据,任何数据的增删改查最后都会作用到底层的线性数据。
下面的代码是 AbstractType
的类型的定义。 _item
是该类型对应线性数据中的 Item
;parent
是该类型对应的父类型,当类型之间相互嵌套时,这个值指向它的上一级;_start
和 _first
存在于线性类型(Text 和 Array)中,分别代表线性类型的第一个 Item 和第一个没有被删除的 Item;_map
存在于健值类型(YMap)中,用于记录对应健的值。这里需要注意的一点是,健值类型对应的线性数据不是连续的,每个健对应一个独立的线性数据,在遇到冲突时,会直接将发生冲突的值删掉,换成一个新的值。
export class AbstractType<EventType> {
_item: Item | null;
get parent(): AbstractType<any> | null;
_start: Item | null;
get _first(): Item | null;
_map: Map<string, Item>;
}