编辑器的冲突算法
本文主要讲解 OT 与 CRDT 实现,面向应用环境为在线协同编辑器,非综述性的原理讲解,所以会偏实践为主,由于作者对 OT 比较熟,会更偏重 CRDT 一些。前置文章可以参考如下文章
zhuanlan.zhihu.com/p/74562370 www.zhihu.com/question/50…
列举文档中主要冲突的几种场景
- 在相同位置插入
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 方法的实现
主要关注 transact 里的实现,其实非常简单,主要分成两步
- findPosition 找到插入的点,由于yjs 实现是纯链表的, 这里的返回的是插入的具体字符位置, 以及它的前一个节点,和后一个节点。
举个例子:
在 'ABCDEF' 中 insert(2, 'mmmm');
那找到的位置就是Pos {index: 2, left: ‘AB’, right: 'CDEF'}
- 在这个 pos 上插入节点
核心方法就两行,完成双向链表的连接 最终变成 AB -> mmmm -> CDEF
那在 yText1.insert(0, 'Edwards') yText2.insert(0, 'Wilson') 例子中是如何保证两端一致的呢,这里我们简单画一下时序图
-
user1插入: ‘Edwards’。 user1: 'Edwards' user2: ''
-
触发 user1 update 事件. user1: 'Edwards' user2: 'Edwards'
-
触发 user2 update 事件 user1 执行插入 'Edwards'
这里user1如何避免重复应用呢
核心就是比较 offset 这里的偏移计算就是和协同来的元素比较 clock
localClock = 该用户协同的最后一个消息的clock + 最后一个消息的length 这里由于已经收到过 Edwards 的消息了 必然比协同的clock 多个 length,所以不会应用所以最终结果 user1: 'Edwards' user2: 'Edwards'
-
user2 插入 ‘Wilson’
这里谁占据0这个位置 换言之 Wilson 是插在前面还是后面呢? 先说结论是在前面 ‘WilsonEdwards’
核心就是比较 this.right = this.parent._start 这里 this 新建的元素
-
.... 之后同上
所以 CRDT 解决冲突的实现本质就是保证两边同时顺序应用元素
- 在插入前删除元素
user1.delete(8, 2);
user2.insert(6, 'kkkk');
-
OT 的方案
user1.pos > user2.pos
user1.pos += user2.text.length -
CRDT 的方案
这里我们已经知道了 CRDT 是如何保证两边结果一致的,但是我们不知道它是如何完成删除操作的,以上面的例子:
user1.delete(8, 2);
实现依然大同小异,先找到位置 然后执行删除 最终变成
Wilson -> kkkk -> Ed -> wa(删除) -> rds
这里可以发现 yjs 并不会把内容真正删除 而是简单 markDeleted
它序列化的时候 会默认跳过删除元素
在这种方案下 CRDT 也不具有 OT 删除优先的主流解决方案,而是完全顺序应用
当然 yjs 还有很多细节的实现,比如如何保证协同的数据只包含当前文档变更,这里的_sm 是之前apply的状态,这里会 loop 之前状态,找出 clock 大于协同clock的 item 以及 apply 之前不存在的 item 进行协同。这里可以看出没有版本的坏处,基本都面临全文的检索。
总结一下
在编辑器领域
OT 优势:
- 主流: google文档, 飞书文档, 腾讯文档都是OT
- 性能好: 不会存在删除元素没有清理(tombstone)的情况
劣势:
- 相对复杂一些,每实现一种原子操作,都得实现 1 * all 的transform 实现
这里看一下 yjs 多余的性能开销
- 找操作位置 findPosition
得 loop 一遍链表
在收到协同删除操作时分裂节点的时候需要通过字符长度二分
备注: 这里实现存疑,为啥作者不通过链表去找,要通过clientId: [item] 去找呢,猜测是二分数组比loop链表更快 ??