10 分钟看懂 treedoc 的设计原理

190 阅读20分钟

Designing a commutative replicated data type

1 Introduction

共享编辑解决方案有很多,其基本原理就是将本地操作广播给其他的节点并以某种规则进行应用。最终所有的节点都应用了所有的编辑行为。各种方案旨在解决因为不同的执行顺序可能会导致各个副本文档产生不一致的问题。本文列举了三种:

  1. 序列化操作
  2. operational transform
  3. commutative replicated data type

前两种无论是设计还是实现都非常复杂,并且难以扩展,而第三种是本文将要讨论的主题,这种方案对初期的设计要求较高,应用会比前两种更简单,也更易于扩展。

社区中提出了一种名为 WOOT 的 crdt 实现,这个模型会为文档中每一个被插入的字符分配唯一标识,并且使用了墓碑机制处理删除操作,因此会浪费大量的存储空间,还不支持快操作如剪贴。其

本文介绍一种 non-trivial 的 crdt 实现,叫做 treedoc,这玩儿不仅满足 crdt 的要求,还支持块操作,并且相比WOOT显著减小了空间占用:没有内部的元数据,删除信息可以被清理,identifier能够比较短。

虽然和 WOOT 一样,对文档的更新是增量的(即使是删除操作),并且identifier不可变,但其余部分没一点儿像WOOT的(比它更好)。treedoc的基本结构是一个由原子节点组成的二叉树,节点路径用二进制序列来表示,为了效率起见,结构化的操作会在扁平的数据和树形结构之间切换,为了避免结构化操作可能引起的冲突问题,在后台会创建一个非强制的审查机制。

本文的结构如下

  1. 设计原则
  2. 对两种方案:操作合并/操作优先级 的权衡
  3. treedoc的设计细节
  4. 列举一些对非破坏性更新和标识符的实现方案,并提出新的方案
  5. 提出一种新的技术用于操作合并
  6. 展示以较低的成本实现crdt的transaction

2 System Model

2.1 Replicated execution and eventual consistency

一个由网络连接的N个节点组成的异步分布式系统。假设节点之间的网络通信是可信的。一个节点可能暂时断开连接,但最终会重连。我们假设这样一种通信方式,就是说,一个节点以随意的时间间隔和其它节点进行通信,将本地更新和收到的来自其它节点的更新发送出去。最终,整个系统中的更新操作会被发送给所有的节点。

为了增加通用性,我们可以想象一个对象被复制到了任意数量的节点上。节点上的程序通过本地副本访问对象,并执行修改的操作。本地执行的操作会被记录下来,然后发送给其它节点,并以某种顺序执行。

如果在同一个节点,操作 oo 执行后,创建了操作 o1o_1,记为 oo1o \rightarrow o_1. 如果oo1o \rightarrow o_1,那么在所有的节点上,操作o1o_1都会在操作oo之后执行(但不一定是连续地执行)。Common epidemic 协议,如 anti-entropy,能够确保这种被称为“因果顺序”的优先级。要实现这种优先级,就得保证延迟某些操作的执行,知道这个操作之前的所有操作都被执行完成。比较致命的技术比如时序向量和版本向量可以被用于跟踪这种基于逻辑顺序的依赖关系。

无法确定先后关系的操作被称为并发操作。

当两个操作执行后对状态的修改,与执行他们的顺序无关时,称这两个操作是 commute 的。

所谓的 Commutative Replicated Data Type (CRDT) 就是指所有对该类型数据的并发修改都是 commute 的数据类型。我们证明了 CRDTs 能保证最终一致性:只要所有节点以正确的先后顺序执行同样的一批操作,那么每个节点上副本的最终状态都是一致的。

2.2 Ensuring that operations commute

有两种基础的方式能够实现操作的 commute,分别是合并和排序。

合并就是说,通过设计使得针对数据类型产生的操作能够进行合并,能够进行合并的含义就是,两个操作对彼此的意图不会发生干扰,例如加法和减法操作,不会对彼此的执行产生影响,对同一个数字n,执行 n+a 并不会影响执行 n-b 的效果,因此 (n+a)b(n+a)-b 等价于 n+(ab)n+(a-b),也等价于 n+(b+a)n+(-b+a)

而排序就是说对操作进行优先级的设置,并以优先级高的操作结果覆盖优先级低的操作的结果。 显然,合并看起来是更加合理的方式,虽然优先级更加容易实现。

3 Shared buffer replicated data type

假设共享文档由一连串的atom组成,atom可能是一个字符,或者一张图片,或者其它类型的无法进行更细粒度操作的数据单元。我们尽可能灵活地设计treedoc这种数据结构,以使其适应各种各样的应用场景。

每个参与编辑的用户可以通过两种操作来修改文档:

  • insert(insertpos, newatom, S) insertpos 用于表示位置,所有小于inertpos的atom都在newatom的左边,所有大于inserpos的atom都在newatom右边,s表示 initiating site。
  • delete(delpos, s) 将某个位置的atom标记为删除。

我们稍后在第七节才会介绍 transaction 的构造方法,transaction用于实现一次对一组atom进行操作,例如剪切和粘贴多个文本,或者根据模式匹配替换所处文本。

我们的 treedoc 被设计使用 合并 的方式来实现 commutativity,也就是说某一个 insert 和 delete 操作的效果对所有节点来说都是一样的。

在更加具体的实现中,可能对应用有着更加强的要求。例如,可能不允许在已经删除的文本之间插入新的文本,以及要保证文档中不同元素之间满足一定的层级关系。维护这种语法层面的层级关系 可能需要实现冲突检测和解决机制(这种机制是non-commutative的,但不在我们关注的范围内)。

当然了,stringtree structure 应当和顶层应用之间的结构解耦。

3.1 关于唯一的位置标识

我们假设一种具备唯一性的对位置的标识符,其具备以下性质

  • 对整个文档的生命周期来说,不可变,并且唯一
  • 对于任意两个 position identifiers,都可以进行比较
  • 任意两个 position identifiers 之间都可以生成新的 position identifier

我们将position identifiers简称为 UIDs。实数满足前两个性质,但是第三个性质要求无限的精度,这对具体实现来说不太现实,我们会在第四节介绍一种基于树的实现方式。

3.2 Abstract atom buffer CRDT

假设文档的状态 T 是由 (uid, atom) 对组成的集合,其中每个uid都是唯一的。而文档被转化为可视化元素时,就会将每个 atom 按照其 uid 的顺序进行排列。操作 insert(u, a, S) 会将 (u, a) 加入到集合 T 中。如果 (u, a) 存在于集合中,那么操作 delete(u, S) 就会将 (u, a) 从这个集合中删除。现在让我们来证明在这种情况下的并发操作是 commute 的。

定理1. Insert operations commute. 对任意的数据状态 T,任意两组插入操作 insert(u1, a1) 和 insert(u2, a2) 是 commute 的。

证明:因为 u1 和 u2 各自都是唯一,且具备确定的大小关系,因此在完成插入后,a1和a2的相对顺序也是确定的,不论谁先执行,最终的文档都会以一致的顺序包含这两个数据。

定理2. 针对不同uid的删除和插入操作是 commute 的

证明:关于这个定理有两种情况需要考虑,一种是集合中存在被删除位置的元素,一种是相反的情况。对于第一种情况,由于uid是唯一且有序的,因此这俩操作不论以那种顺序执行,最终结果都是新增了新增的,删除了删除的。对于第二种情况,这个位置会被标记为删除,但是由于则个位置实际上并没有被使用,因此会被垃圾回收机制进行回收并重新分配,当然最终状态也是一致的。

定理3. 如果一个删除操作和一个插入操作指向了同一个位置,那么插入操作一定发生在删除操作的前面

证明:没啥好证明的,如果一个atom不存在,为啥会有删除它的操作呢?

定理4. 删除操作之间是 commute 的

证明:因为位置标识符唯一且有序,因此无论以哪种顺序执行两个位置的删除操作,都不会对彼此的结果产生影响。

Theorem 1. 这部分所描述的数据结构符合 CRDT 的特征

证明:通过对上述 4 种情况的讨论,所有并发操作的组合都是可交换(commute)的。

4. Treedoc abstract data type

我们先从一个简单的设计开始,能够基本满足合并策略的要求,虽然有一些局限性,后面咱们再优化。

4.1 Paths

我们是用二叉树来表示文档结构。树上的每个节点都可以包含一个 atom,或者是一个空节点。而节点的id就是这个节点在这棵树中的路径。

根节点的 id 是一个空串 ε ,而路径的合并操作标记为 ⊙ 。一个节点的左侧子节点记为 0,右侧子节点记为 1.我们以固定的顺序遍历这棵树,跳过空的节点(但不跳过其后代节点)。

对于 id,我们定义如下的偏序关系: 满足以下条件之一的,则有 id1<id2id_1<id_2

  • id1=[c1...cn]id_1 = [c_1...c_n]id2=[c1...cnj1...jm]id_2 = [c_1...c_nj_1...j_m] 的前缀,并且 j1=1j_1=1
  • id2=[c1...cn]id_2 = [c_1...c_n]id1=[c1...cni1...im]id_1 = [c_1...c_ni_1...i_m] 的前缀,并且 i1=0i_1=0
  • id1=[c1...cni1...in]id_1 = [c_1...c_ni_1...i_n]id2=[c1...cnj1...jm]id_2 = [c_1...c_nj_1...j_m] 拥有共同的前缀,并且 i1=0i_1=0,前缀可能是空序列

我们定义祖先关系如下: 节点 u 是节点 v 的父节点,记为 u/v ,如果 u/v,那么 id(v) = id(u) ⊙0或者 id(v) = id(u)⊙1;节点 u 是节点 v 的祖先节点,记为 u/+vu/^+v

4.2 Deleting

我们从一个最简单的过程开始,删除一个 atom:简单地讲这个节点的内容置空。由于被删除节点的 id 是唯一的,因此删除操作的发起一侧和同步到删除操作的节点,在执行此操作时删除的都会是同一个节点。某些情况下,在执行删除操作的时候,这个节点已经被删除过了。

当一个删除操作被所有节点都执行时,我们认为这个删除操作是 stable 的。在这之后产生的任何操作都不会在引用这个被删除内容的 id 了。因此,如果一个节点是叶子节点,当这个节点被稳定删除后,这个节点就可以被彻底删除了。更进一步,如果某棵子树中的所有节点都被稳定删除,那么这颗子树就可以被从文档状态中完全删除。

对这个过程我们引入一个术语 gc(N) 用来表示从文档状态中删除已经被稳定删除的叶子节点 N 的过程。这个过程仅仅发生在本地,且不会被进行同步。

另外定义过程 stabledel(N) 用来检验对 N 的删除是否是 stable 的。这个过程简单来讲就是等待足够的信息来确认所有的文档副本都执行了对这个 N 的删除操作。本文参考Weak-consistency group communication and membership使用基于逻辑时间戳矩阵或者向量的方案来实现对删除操作稳定性的判断。

4.3 实现插入操作

插入操作重点在于创建一个新的位置,也就是id(atomp)<id(newatom)<id(atomf)id(atom_p)\lt id(newatom)\lt id(atom_f)。在这部分,我们先展示一种简单的算法实现,其并不会对树进行平衡(这样会造成较高的操作复杂度)。之后我们再来解决这个问题。

算法 1 创建新的位置
function newUUID(idp, idf){
    const idm = between(idp, idf)
    if(idm){
        return newUUID(idp, idm); // 找到最接近 p 的那个位置
    }
     // 创建新的位置,在 idp 和 idf 之间
    else if (isAncestor(idp, idf)){
        return concat(idf, 0);
    }else if(isAncestor(idf,idp)){
        return concat(idp, 1)
    }
}

4.4 并发插入

为了解决多个插入发生在同一个位置的问题,可以为每个节点添加一堆数据,称成为副节点,副节点为 (siteID, counter) 组成,siteID 是当前数据副本的唯一标识,其对整个系统来说是唯一的,并且符合 total order 的特性。而 counter 则是当前副本内的逻辑时间戳。这样一来,即使两个节点的 path 一样,也可以根据(siteID, counter) 信息来得到一致的顺序。

实现并发插入的算法和算法 1 一致,只是在进行比较的时候会加入副节点的逻辑。并且在创建节点时,也会同时生成新的副节点信息。

与删除操作相对应的是,一旦所有的并发插入操作都被稳定执行,那么多余的副节点信息就可以被删除了(以节约存储资源)。判断一个并发的插入操作是否稳定执行的方法为:当前副本收到了来自其他副本的,发生在插入操作之后的操作信息(为了保证这个过程尽快发生,那些没有频繁产生编辑行为的副本,会定时发送心跳数据)。

5 序列转换为 binary tree

目前为止,我们的实现尚存在许多的不足之处。首先路径的长度变化很大,在不是平衡树的状态下,特别是极端不平衡的状态下(比如一直向树的最右侧插入位置)会造成很高的复杂度。节点的元信息(如disambiguators)也会占用很多的内存资源。最后,被删除但是尚无法确定被稳定删除的节点也会造成空间的浪费。

与分别解决上述问题不同,我们提出了一个相对激进的方案来一举解决。在这一部分我们讲述一种在用于高效传输的扁平数据结构和用于提供编辑能力的树形结构之间进行切换的结构化操作的方案。这些操作定义如下:

  • explode(atomstring) 返回一个与 atomstring 对应的树形文档
  • flatten(path) 返回一个与 path 对应的子树的 atomstring
算法 3
explode(atomstring)
  1. depth = log2(length(atomstring)+1)log_2(length(atomstring) + 1)
  2. T = 创建深度为 depth 的完全二叉树
  3. 以 infix order 取出字符串中的 atoms
  4. 删除剩下的节点
  5. 返回 T
flatten(tree)
  1. 以 infix 的顺序(中序遍历)遍历树
  2. 将所有非空的节点连接成字符串并拼接后返回

infix是编译原理中的一种用于表示语法的方法,与之对应的有 prefix 和 postfix 。比如 a + b * c 使用 prefix 的顺序表示为 + a * b c,使用 postfix 表示为 1 2 3 * +,使用 infix 则表示为 a + b * c。是的 infix 就是我们习惯的常见顺序。

本地操作和同步来的操作的结果必须一致。特别的,explode 方法在每个副本上都应该返回完全一样的结构。

使用这俩操作,当 treedoc 变得不平衡或者存在大量冗余节点时,只要执行 flatten 然后在 explode 就可以解决问题。

然而,这俩操作与编辑操作是不满足 commute 特性的。

6 混合树

这一部分将研究如何将结构操作和编辑操作进行结合。 算法 3 中的 explode 某种意义上来说是多此一举。explode 可以理解为从字符串到树形结构之间的映射关系。将一组 path 应用给一个字符串隐式地将其转换为了树形结构,规避显式的 explode 操作,就可以无需考虑其与编辑操作是否是 commute 的。

对应地,flatten 也并非在任何情况下都需要被执行。停止一个 flatten 操作不会产生任何不良后果。因此,如果 flatten 和编辑操作并行发生了,那么直接中止这次 flatten 就好了。换一种说法就是,如果一次 flatten 操作要生效,那么就不能存在与其并发的编辑操作。具体到实现层面,当对当前节点的副本进行 flatten 操作时,需要发起一个分布式的投票决策,只有当所有参与编辑的节点都确认,没有与这次 flatten 并行的编辑操作时,才会执行 flatten 操作,否则就会中止。

任何分布式投票协议都可以做到这一点,例如 two-phase commit 或者 Gray and Lamport's fault-tolerant protocol

我们现在可以设想一种混合结构的数据,其中被频繁编辑的那一部分保持着树形结构,而另外的部分则被表示为字符串。

6.1 容错机制和离线操作

无比悲伤和难过的部分,这篇文章所阐述的 crdt 实现因为其设计上的原因无可避免地会存在数据丢失的情况。并且由于其依赖中心化的审核机制,无法做到离线状态保持与在线完全一致的能力。下面让我了解作者团队对离线操作的设计方案吧。

保证容错和离线操作的方式非常简单直接。每个站点都将其操作(无论是本地发起的还是同步过来的)记录到持久化存储中,以在未来的某个时机,一单实现了重连,再与在线的节点们同步数据。如果某个节点出现了错误状态并最终恢复,也会与其它线上节点交换数据来同步。但是,一旦某个节点因为外力崩溃了,比如炸了之类,当这个站点重连至系统中时,其行为将会和一个新加入的节点一样,因为本地已经没有任何可以被重复利用的数据了。

离线和崩溃的场景对于 flatten 操作来说会更加复杂,因为其依赖所有副本节点的投票机制。这种情况下我们假设可以识别出离线的原因是因为崩溃还是仅仅因为网络问题,那么因为网络原因下线的节点会在投票环节被认为投了反对票(flatten 操作将不会执行),而那些因为崩溃而掉线的站点,将失去投票权。

类似的,在 stabledel 和 stableinsert 的确认规则中也会将参与的节点范围缩小至所有没有崩溃的节点。

注意了,这一坨大的精华所在:如果某个节点被错误判断为崩溃了,那么这个节点上产生的,对文档中已经被 flatten 操作转换为字符串的编辑行为将无法被同步给其它节点,也就是说,那些操作都会丢失。

数据丢失的情况还会出现在,当这个本地产生的操作依赖于某个已经被系统认为稳定删除(当前节点被误判崩溃所以系统在判断 stabledel 的时候直接放弃)的节点时,所有后续操作都会丢失,简单来说就是不靠谱,除非牺牲掉垃圾回收功能,这样会导致空间复杂度非常高。

7 块操作和事务

根据 crdt 的属性,要实现事务非常简单,因为每个操作之间都是 commute 的,那么将多个连续操作组合到一起形成的操作组合之间也将会是 commute 的。

熟悉数据库的同志们应该比较了解,事务对数据库具有独占性,这里讲的事务也是一样,在执行一个事务操作的时候,不允许对数据进行其它的修改操作,直到这个事务结束。并且,事务中的操作,要么被全部执行成功,要么就会全部被取消,也就是说,事务必须具备取消的机制。

由于正常产生的操作之间不会发生冲突,因此当前语境下的事务不会被中止,只要开始执行,就一定会完成,唯一需要实现的就是记录事务的开始和结束状态,并缓存在事务执行期间收到的操作,以实现批量执行。

当站点在执行本地事务的时候,会缓存收到的同步过来的操作,并在当前事务结束后依次执行。当站点接收到其它站点发送的事务类操作时,会先缓存事务中包含的操作序列,直到确认收到了事务中的所有操作时,才会执行这个事务。后面这种情况下,就依赖于两个特殊的标识:

  • begin_transaction
  • end_transaction

注意,现在我们就可以定义诸如移动块和全局搜索替换这样的操作了。从可交换性的角度,这种操作类型也就相当于是将insert 和 delete 操作组合为一个事务进行执行。

8 谈谈 OT

Operational transformation 主张基于不可交换的单字符操作来实现协作。为此,OT转换远程操作的参数,以考虑并发执行的影响。OT需要两个正确性条件:转换应该使并发操作能够以任意顺序执行,此外,转换函数本身必须是可交换的。前者相对容易,后者则更复杂,Oster等人证明所有现有的转换都违反了这一点。

OT 通过转换来让操作的应用变得可交换,但我们认为最初就将对文档的修改设计为可交换操作是更加好的方式。

9 结论

显而易见,可交换性会让一致性非常容易实现,但是设计一个可交换系统并不简单。尤其设计一个不会丢失数据的可交换系统更是难上加难,本文展示的设计,尽量考虑到了实际系统中可能会遇到的挑战,并提出了解决的方式,如二叉树实现唯一位置、 unambiguators 、 abortable consensus 、 transaction等。至于用于语法约束的冲突检查程序,我们认为更加适合作为独立的主题单独讨论。