OT 与 CRDT

1,470 阅读3分钟

编辑器的冲突算法

本文主要讲解 OT 与 CRDT 实现,面向应用环境为在线协同编辑器,非综述性的原理讲解,所以会偏实践为主,由于作者对 OT 比较熟,会更偏重 CRDT 一些。前置文章可以参考如下文章

zhuanlan.zhihu.com/p/74562370 www.zhihu.com/question/50…

列举文档中主要冲突的几种场景

  1. 在相同位置插入
    user1.insert(0, 'Edwards') 
    user2.insert(0, 'Wilson')
  • OT 的方案

优先级按照 isAAfterB 决定,也就是谁后到服务器来决定,本地操作会先占住 0 这个位置,协同的操作会往后偏移,这里假设 user1 为本地操作,则最终结果为 EdwardsWilson

  • CRDT 的方案 这里用 yjs 来作为CRDT的实现
import * as Y from '../src/index.js'

const doc1 = new Y.Doc()
const doc2 = new Y.Doc()
const yText1 = doc1.getText()
const yText2 = doc2.getText()

doc1.on('update', (update) => {
   Y.applyUpdate(doc2, update)
})

doc2.on('update', (update) => {
   Y.applyUpdate(doc1, update)
})

yText1.insert(0, 'Edwards') 
yText2.insert(0, 'Wilson')    

这里可以简单介绍一下 yjs 的原理,可以看到 insert 方法的实现

image.png 主要关注 transact 里的实现,其实非常简单,主要分成两步

  1. findPosition 找到插入的点,由于yjs 实现是纯链表的, 这里的返回的是插入的具体字符位置, 以及它的前一个节点,和后一个节点。

举个例子:

在 'ABCDEF' 中 insert(2, 'mmmm');

那找到的位置就是Pos {index: 2, left: ‘AB’, right: 'CDEF'}

  1. 在这个 pos 上插入节点

image.png

核心方法就两行,完成双向链表的连接 最终变成 AB -> mmmm -> CDEF

那在 yText1.insert(0, 'Edwards') yText2.insert(0, 'Wilson') 例子中是如何保证两端一致的呢,这里我们简单画一下时序图

  1. user1插入: ‘Edwards’。 user1: 'Edwards' user2: ''

  2. 触发 user1 update 事件. user1: 'Edwards' user2: 'Edwards'

  3. 触发 user2 update 事件 user1 执行插入 'Edwards'

    这里user1如何避免重复应用呢

    image.png 核心就是比较 offset 这里的偏移计算就是和协同来的元素比较 clock

    localClock = 该用户协同的最后一个消息的clock + 最后一个消息的length 这里由于已经收到过 Edwards 的消息了 必然比协同的clock 多个 length,所以不会应用所以最终结果 user1: 'Edwards' user2: 'Edwards'

  4. user2 插入 ‘Wilson’

    这里谁占据0这个位置 换言之 Wilson 是插在前面还是后面呢? 先说结论是在前面 ‘WilsonEdwards

    image.png 核心就是比较 this.right = this.parent._start 这里 this 新建的元素

  5. .... 之后同上

所以 CRDT 解决冲突的实现本质就是保证两边同时顺序应用元素

  1. 在插入前删除元素
    user1.delete(8, 2);
    user2.insert(6, 'kkkk');
  • OT 的方案

    user1.pos > user2.pos
    user1.pos += user2.text.length

  • CRDT 的方案

    这里我们已经知道了 CRDT 是如何保证两边结果一致的,但是我们不知道它是如何完成删除操作的,以上面的例子:

    user1.delete(8, 2);

    image.png

    实现依然大同小异,先找到位置 然后执行删除 最终变成

    Wilson -> kkkk -> Ed -> wa(删除) -> rds

    这里可以发现 yjs 并不会把内容真正删除 而是简单 markDeleted

    它序列化的时候 会默认跳过删除元素

image.png

在这种方案下 CRDT 也不具有 OT 删除优先的主流解决方案,而是完全顺序应用

当然 yjs 还有很多细节的实现,比如如何保证协同的数据只包含当前文档变更,这里的_sm 是之前apply的状态,这里会 loop 之前状态,找出 clock 大于协同clock的 item 以及 apply 之前不存在的 item 进行协同。这里可以看出没有版本的坏处,基本都面临全文的检索

image.png

总结一下

在编辑器领域

OT 优势:

  • 主流: google文档, 飞书文档, 腾讯文档都是OT
  • 性能好: 不会存在删除元素没有清理(tombstone)的情况

劣势:

  • 相对复杂一些,每实现一种原子操作,都得实现 1 * all 的transform 实现

这里看一下 yjs 多余的性能开销

  1. 找操作位置 findPosition

得 loop 一遍链表

image.png

在收到协同删除操作时分裂节点的时候需要通过字符长度二分

image.png

image.png

image.png

备注: 这里实现存疑,为啥作者不通过链表去找,要通过clientId: [item] 去找呢,猜测是二分数组比loop链表更快 ??