YATA线性数据插入算法

1,090

在一个协同编辑的场景中,多个用户会同时对一份文档进行编辑。同一时间,该文档存在多个副本。不同用户在不同副本上的增删改操作有时候会产生冲突。在 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、插入操作的内容和插入的意图 originLeftoriginRight。这里需要注意的是,YATA 中的插入意图只有 originLeft,Yjs 为了解决一种特殊情况,添加了 originRight,下文会讲述。操作的唯一标识符 ID 是由 ClientClock 组成的元组,其中 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 是一个元素。假设 ono_noriginnorigin_n 后插入,originnorigin_nono_n 的插入意图,则满足 originn<<onorigin_n << o_n,而不一定满足 originn<onorigin_n < o_n,因为在 originnorigin_nono_n 之间可能还会插入其他的元素。(注:这个约定只适合本节,跟论文没有关系)

为了解释插入算法,不失一般性,我们假设有两个副本在进行协作。副本 1 在 origin1origin_1 后面插入了 o1o_1 ,即 origin1<<o1origin_1 << o_1;副本 2 在 origin2origin_2 后面插入了 o2o_2 ,即 origin2<<o2origin_2 << o_2。插入算法的核心在于插入操作的集合满足全序关序,每两个插入都有一个前后关系。现在要将副本 1 产生的 o1o_1与副本 2 产生的 o2o_2 集合在一起,为了保证副本 1 和副本 2 有相同的前后顺序,就需要计算出 o1o_1o2o_2 的前后关系。

我们以将 o1o_1 插入到副本 2 为例,计算o1o_1o2o_2的前后关系。通过总结归纳,我们知道此时的副本 2 有以下四种排列。

  1. origin1<<origin2<<o2origin_1 << origin_2 << o_2
  2. origin2<<origin1<<o2origin_2 << origin_1 << o_2
  3. origin2<<o2<<origin1origin_2 << o_2 << origin_1
  4. origin2=origin1<<o2origin_2 = origin_1 << o_2

考虑第三种排列。因为 origin1<<o1origin_1 << o_1,我们可以得出副本 2 此时的排列是 origin2<<o2<<origin1<<o1origin_2 << o_2 << origin_1 << o_1,即 o2<<o1o_2 << o_1

接下来,通过分析剩下的三种排列,得出插入算法的规则。

第二种排列:origin2<<origin1origin_2 << origin_1

考虑第二种排列。此时 origin2<<origin1origin_2 << origin_1,即需要根据这个前提得出 o1o_1o2o_2 的相对位置,o2<<o1o_2 << o_1 或者 o1<<o2o_1 << o_2

假设可以通过 origin2<<origin1origin_2 << origin_1 可以得出 o2<<o1o_2 << o_1。我们考虑一种极端情况,origin1<o2origin_1 < o_2 并且在当前副本下的 origin1origin_1 后插入了一个 o1o_1,此时这四个的相对顺序是 origin2<<origin1<o1<o2origin_2 << origin_1 < o_1 < o_2。插入完成后,当前副本将 o1o_1 广播到其他副本后,根据当前规则,可以得出接到广播的副本的相对位置,即origin2<origin1<o2<<o1origin_2 < origin_1 < o_2 << o_1。可以发现两个副本的相对位置不一样了,即两个副本发生了冲突。综上所述,不可以根据此规则来判断的相对位置。

上述的假设失败后,只剩下通过 origin2<<origin1origin_2 << origin_1 可以得出 o1<<o2o_1 << o_2。该假设就是 YATA 提出的规则之一。在论文中有严格的证明,这里不做赘述。根据此规则,我们可以得出副本 2 中各个插入的相对顺序 origin2<<origin1<<o1<<o2origin_2 << origin_1 << o_1 << o_2

第一种排列:origin1<<origin2origin_1 << origin_2

origin1<<origin2origin_1 << origin_2,我们可以发现 origin1origin_1o2o_2 的之间有一个 origin2origin_2。所以先分析 origin1origin_1origin2origin_2o1o_1 的关系。不失一般性,我们总是可以找到在 origin1origin_1origin2origin_2 之间的元素,直到找到一个与 origin1origin_1 相邻的元素 neighbor1neighbor_1 。这种情况下,origin1<neighbor1<<=origin2<<o2origin_1 < neighbor_1 <<= origin_2 << o_2。因为 neighbor1neighbor_1 的插入意图 originneighbororigin_{neighbor}origin1origin_1 的关系是 originneighbor<<=origin1origin_{neighbor} <<= origin_1

如果 originneighbor=origin1origin_{neighbor} = origin_1,则此时元素的相对位置是 originneighbor=origin1<neighbor1origin_{neighbor} = origin_1 < neighbor_1。我们发现此时,o1o_1 的在副本 2 中的位置转化成了 o1o_1neighbor1neighbor_1 的关系。又因为 originneighbor=origin1origin_{neighbor} = origin_1,则与第四种排列是一样的。这里不做赘述。

如果 originneighbor<<origin1origin_{neighbor} << origin_1,则此时元素的相对位置是 originneighbor<<origin1<neighbor1origin_{neighbor} << origin_1 < neighbor_1。我们发现这种关系跟第二种排列是相同的,同样不做赘述。可以得出 o1o_1 应该插在 neighbor1neighbor_1 的前面,即originneighbor<<origin1<o1<neighbor1origin_{neighbor} << origin_1 < o_1 < neighbor_1。又因为 origin1<neighbor1<<=origin2<<o2origin_1 < neighbor_1 <<= origin_2 << o_2,可以得出 originneighbor<<origin1<o1<neighbor1<<=origin2<<o2origin_{neighbor} << origin_1 < o_1 < neighbor_1 <<= origin_2 << o_2。综上,可以得出 o1o_1o2o_2 的关系是 o1<<o2o_1 << o_2

第四种排列:origin2=origin1origin_2 = origin_1

origin2=origin1origin_2 = origin_1, 意味着 o1o_1o2o_2 同时在一个操作后面插入。这是一种特殊的关系,YATA 通过约定来解决这个问题。即通过比较副本 1 和副本 2 的客户端唯一标识 Client 来决定 o1o_1o2o_2 的相对顺序。但是,不能通过 Client 的大小,直接决定操作的插入位置。下面通过两个实际的例子去讨论。

考虑一种实际情况,文档的初始排列是 origin<<endorigin << end,副本 2 插入 o2o_2 后的排列是 origin<<o2origin << o_2,副本 1 在副本 2 的 o2o_2 前面插入了 o1o_1,它的排列是 origin<<o1<o2origin << o_1 < o_2。我们可以发现 o1o_1o2o_2的插入意图都是 originorigin。假设根据 Client 大小,可得出 o2<<o1o_2 << o_1。当副本 1 的 o1o_1集成到副本 2 后,如果直接根据该规则,可以得出 副本 2 的排列是 origin<<o2<<o1origin << o_2 << o_1。这会与副本 1 的排列冲突。为了解决这个问题。需要引入插入意图的右链接 originrightorigin_{right}。一个操作必须在它的右链接前插入。这样就可以避免这种冲突的出现。

除了上述的情况。还需要考虑一种情况。副本 1 的排列是 origin<<o1<o2origin << o_1 < o_2,副本 3 的排列是 origin<<o3origin << o_3。假设直接通过 Client大小,决定操作的插入位置,并且约定 o3<<o2<<o1o_3 << o_2 << o_1。那么当 o3o_3 插入副本 1 时,可以得到排练 origin<<o3<<o1<o2origin << o_3 << o_1 < o_2;当操作 o1o_1o2o_2 插入副本 3 时,可以得倒排练 origin<<o3<<o2<<o1origin << o_3 << o_2 << o_1。这会造成副本 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 是该类型对应线性数据中的 Itemparent 是该类型对应的父类型,当类型之间相互嵌套时,这个值指向它的上一级;_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>;
}