XMBSMDSJ

2026

< Back to index

YATA 与协同编辑

我最近在做一个多人协作的领域驱动设计工具,多个用户可以在同一个房间里通过贴纸在画布上进行事件风暴(Event Storming),它用了 YJS 作为协同编辑层。

YJS 是一种 CRDT 实现,可用于构建实时协作应用。它是 YATA 算法的开源实现,本文主要讲底层的 YATA。

YATA

术语

YATA 使用双链表表示线性数据。每个字符属于一个插入操作。删除不真正移除节点,只是把对应的插入标记为已删除(tombstone)。

链表头尾各有一个特殊的 delimiter(哨兵)节点,保证任何插入都有左右邻居可以参照(在开头/结尾插入时也不例外)。

YATA 使用以下元组来表示一个操作:

\[o_k(id_k, origin_k, left_k, right_k, isDeleted_k, content_k)\]

全序 $\lt_{c}$ 只依赖永不改变的 $origin$,这正是不同副本能算出同一顺序、从而收敛的根本原因。

例如,在 $o_i$, $o_j$ 之间插入一个字符 $c$,则会生成一个新的操作 $o_k$,其元组为 $(id_k, o_i, o_i, o_j, false, c)$。

YATA 要保证两个性质:

为什么能收敛:冲突消解归结为在冲突操作上定义一个严格全序 $\lt_{c}$(由下面规则 1~3 给出),而 $\lt_{c}$ 只依赖永不改变的 $origin$。因此每个副本对同一批操作都算出同一个顺序 → 内容一致,不需要中心协调,也与到达顺序无关。

解决冲突,需要全序关系 $\lt_{c}$.

规则 1: 插入和原点的连线不能相交。两次插入要么是嵌套的,要么是先后发生的。

规则 2: 传递性。保证不会有第三个元素排到 o1、o2 之间,破坏已定的先后关系。

规则 3: 两次插入原点相同时,创建者 id(creator,即 client/user id,不是操作的 $id_k$)小的排在左边。

插入

插入是最基本的操作类型

// 在一串互相冲突的操作 ops 中,为新操作 i 定位
insert(i, ops):
    i.position = ops[0].position          // 先假设 i 站在冲突区最左边
    for o in ops:                         // 从左往右扫描每个已存在的冲突操作 o
        // 组1 = 规则 1(禁止 origin 红弧交叉):o 与 i 的两条 origin 弧只能「并排」或「嵌套」
        //   o < i.origin        —— 并排/先后:o 整个在 i 的原点左边(弧不相交)
        //                          注:本算法里 ops 全在 i.origin 右边,此项恒假、永不触发,
        //                          仅为忠实照搬规则 1 的完整定义而保留
        //   i.origin <= o.origin —— 嵌套:o 的原点在 i 的原点右边(或相同),arc(o) 套在 arc(i) 内
        // 组2 = 区分两种「谁在前」的判据:
        //   o.origin != i.origin —— 原点不同:由上面的位置(规则 1)决定
        //   o.creator < i.creator —— 原点相同:id 小的在前(规则 3)
        if (o < i.origin or i.origin <= o.origin) 
          and 
        (o.origin != i.origin or o.creator < i.creator):
            // o 应排在 i 前面 → i 往右让一格(规则 1 的嵌套支 / 规则 3)
            i.position = o.position + 1
        else:
            // 条件不成立:要么同原点但 o.creator 更大(不让、继续),
            // 要么 o.origin 在 i.origin 左边——此时再往右让红弧就会交叉,
            // 触发规则 1 的中断条件,停止扫描
            if i.origin > o.origin:
                // rule 1 broken:origin 连线将要交叉
                break

派生操作

其它数据类型都建立在底层的「有序链表 + 冲突消解」之上。论文把这些封装叫 Manager(List Manager / Replace Manager / Map Manager):底层只负责定序,Manager 负责「怎么写、怎么读」这套视图。冲突消解只写一次,换一种 Manager 就换一种读写解释。

List(List Manager)

头尾两个 delimiter 之间的一串有序插入。写就是按 YATA 规则在两邻居间插入,读就是按序拼接所有未删除的插入(如文本就是把字符依次连起来)。

替换(Replace Manager)

YATA 没有替换原语,替换的问题会被转化为插入问题。Replace Manager 继承自 List Manager:写就是往最左插,读就是只取最左那个(新值盖住旧值)。并发替换时,队头的插入冲突由规则 3(比 creator)确定性地选出唯一赢家,所以本质是不依赖时钟的确定性 LWW

Map(Map Manager)

Map = 每个 key 挂一个 Replace Manager,于是每个 key 各自独立地”往最左插、读最左”,实现并发覆盖并收敛。key→Replace Manager 那层字典本身不必是 CRDT,一致性都下压到各自的 Replace Manager 里解决。

附录:收敛性证明(通俗版)

要证什么:$\lt_{c}$ 是冲突操作上的一个严格全序。只要它是全序,每个副本对同一批冲突操作就会排出完全相同的顺序 → 内容一致 → 收敛。

回忆 $\lt_{c}$ 由三条规则合成:规则 1(位置/嵌套,红弧不交叉)、规则 2(传递性)、规则 3(同原点比 creator id)。要证它是全序,需要三件事:反对称、传递、完全

1. 反对称:不会”既 A 在前又 B 在前”(除非是同一个)

分两种情况看两个冲突操作 A、B:

所以定序无歧义,A、B 的先后唯一。

2. 传递:A 在 B 前、B 在 C 前 ⇒ A 在 C 前

这正是规则 2 量身定做的。规则 2 说:若 $A \lt_{c} B$,则任何排在 B 后面的元素,A 也排在它前面。于是由 $A \lt_{c} B$、$B \lt_{c} C$ 直接得 $A \lt_{c} C$——不会出现某个 C 卡在 A、B 之间把顺序打乱。

3. 完全:任意两个冲突操作总能比出先后(不会”平局”)

反证:假设 A、B 谁也排不到谁前面。

所以不存在”平局”,任意两个都能比出先后。

结论

三条都成立 → $\lt_{c}$ 是严格全序。又因为 $\lt_{c}$ 只依赖永不改变的 $origin$(和 creator id),任何两个副本在比较同一批冲突操作时都会得到同一个顺序,因此所有副本最终收敛到相同内容——且与操作到达顺序、是否有中心节点都无关。

附录:规则 1 的本质与 laminar family

把每个插入 i 看成它 origin 圈定的一段区间 $[origin_i, i]$(”我这一段属于这里”)。规则 1(红弧不交叉)的本质,就是要求这族区间构成一个 laminar family(层级族 / 嵌套族)

什么是 laminar family

一族集合是 laminar 的,指任取其中两个 A、B,必满足:

\[A \subseteq B \quad\text{或}\quad B \subseteq A \quad\text{或}\quad A \cap B = \varnothing\]

要么嵌套、要么不相交,绝不部分重叠(交叉)。用括号看最直观:

laminar:    [ [ ] [ ] ]     (嵌套 + 并排)
非 laminar:  [ ( ] )         (交叉 = 括号错配)

核心性质:laminar ⟺ 一棵树

让每个集合连到”严格包含它的最小集合”作为父节点。因为没有交叉,这个最小包含者永远唯一 → 每个节点恰好一个父亲 → 构成森林/树。所以 laminar family 和树是一回事(配平括号 ↔ 语法树、文件系统目录、层次聚类树状图,本质都是 laminar)。

补一个漂亮的规模界:$n$ 个元素上的(去重非平凡)laminar family 最多只有 $2n-1$ 个集合——即一棵树的节点数,非常省。这也是组合优化里 “uncrossing”(把交叉约束解成 laminar/树)反复被用的原因。

为什么这就是收敛的关键

而”交叉”对应的排列 $origin_A < origin_B < A < B$,意思是 B 声称插在 A 区间内部、却排到了 A 外面——自相矛盾,两个插入的意图无法同时满足,允许它就会发散。

所以 YATA 没有事后去”解交叉”,而是用不变的 origin + 规则 1 从一开始就禁止交叉,直接把冲突结构钉成一棵所有人一致、可唯一线性化的树;规则 3(同 origin 比 creator)只是给兄弟节点定左右次序的最后一环。