如何解决多人编辑场景下的内容覆盖问题

4,030 阅读18分钟

背景

最近做项目时,用户 A 反馈在使用后台编辑了某内容后,过了一段时间重新进入页面发现内容还是很久前的版本,自己的保存没有生效。经过查找日志发现了原因,是期间有多位用户编辑过该内容,用户 B 点击保存时由于自己页面的内容还是很早之前的,覆盖了用户 A 编辑后的版本。

现在前端和服务端已经商讨出了该问题的解决方案,但个人对于这个课题比较感兴趣,所以对此进行了一个较为深入的调研。

方案 A:编辑锁 —— 禁止多人编辑

解决上述问题第一个思路是,那么既然多人同时编辑可能导致内容覆盖,我就不让多人同时编辑,当有人在编辑某个文档时,系统会将这个文档锁定,避免其他人同时编辑,这种方式一般被称为编辑锁。

编辑锁的思路来源于关系型数据库的悲观锁。悲观锁,正如其名,具有强烈的独占和排他特性。它假定当前事务操纵数据资源时,肯定还有其他事务同时访问该数据资源,为了避免当前事务的操作受到干扰,先锁定资源,其他事务无法访问该资源直到锁结束。

编辑锁实现思路

当用户 1 进入某个内容的编辑时,向服务端发送一个请求,服务端将该内容的锁定人标记为用户 1。其他用户访问该内容时,显示用户 1 正在编辑当前内容而无法访问,直至用户 1 编辑完毕将该内容解锁。

为了提高效率以及防止某一用户长时间锁定某个资源无法释放,通常编辑锁可做以下优化手段:

  • 当用户 1 锁定某个资源时,其他用户没有该内容的编辑权限,但是有访问权限。
  • 当前锁定某个资源的用户,需每隔一段时间向服务端发送请求,将资源的锁续期。若超出间隔时间没有发送,则自动将该资源解锁。
  • 用户 2 访问该资源时,假如当前资源被用户 1 锁定,用户 2 可向用户 1 申请锁权限转移,若用户 1 同意,则服务端将该资源的锁定人由用户 1 改为用户 2。

image.png

编辑锁的优缺点

编辑锁同时只能有一人编辑的特点,可以有效避免用户因为版本覆盖而导致的无效工作,同时因为其实现相对来说比较简单,所以是多人协作编辑场景中应用最广泛的一种方案(考虑到实现成本,我们的项目中最终也采取了这种折中的方案)。

但是编辑锁无法做到多人同时编辑,就使用体验来讲还是不够友好。

方案 B:允许多人编辑,防止覆盖

版本变更提示

上面写到的背景中,引起我们项目问题的原因,主要是用户在不知情的情况下发生了内容的覆盖。那么对症下药,我们可以增加一个版本变更提示的功能:当用户编辑内容时,如果在编辑的过程中有其他用户提交了新的版本,则页面给出提示发生了版本变更,是否要覆盖

版本变更提示实现思路

版本变更提示的实现上非常简单,用户获取内容时,服务端同时返回该内容的当前版本号。当用户保存时,将修改后内容和拿到的版本号一并提交,服务端校验版本号与数据库中的版本号是否一致:

  • 若一致,表示在该用户编辑的过程中没有版本变更,直接保存成功
  • 若不一致,表示在该用户编辑的过程中发生了新的版本变更,保存失败并通知用户,用户可选择是否要进行覆盖

image.png

版本变更提示的不足

版本变更提示这种方案只是避免了用户未知情况的版本覆盖问题,但是最终还是只能选择进行版本覆盖或者舍弃当前版本,不能解决多人协作时的冲突问题

优化:版本合并

版本变更提示的方案中,最终我们只能选择保留当前编辑的版本或者数据库中的最新版本。小孩子才做选择,作为成年人,我们当然是全都想要。

5fe7e06547b79fb63b057e26fb903ec1.jpeg

那么我们可以使用版本合并,将每个版本的变更都保留下来。说起版本合并,那作为研发的我们很容易联想到 git 的版本合并,git 的 merge 其实也是一个 diff-patch 的过程。diff 和 patch 是一对工具,diff 可以比较两个内容之间的差异并记录下来,根据差异生成一个 patch,然后将 patch 应用于其他内容从而更新内容。

在我们的协作编辑场景中,当用户将文件内容由版本 A 改为版本 B 时,保存时将版本 B 和版本 A 的 patchBA 发送给服务端,服务端获取数据库中该文件最新版本 X,然后将通过 patchBA 应用于版本 X,从而实现更新。

上面 diff-patch 过程中最大的问题,是应用 patchBA 时可能发生冲突,如果有冲突版本 X 无法直接更新。此时,服务端需要将 patchXA 发送给用户,用户手动解决 patchBA 和 patchXA 之间的冲突并进行合并,冲突解决后,发送一个 patchBX 给服务端,从而最终实现更新。

image.png

基于行的 diff

常见的文本 diff 一般分为基于行的 diff 和基于字符的 diff,例如我们常用的 git 就是基于行的 diff。

例如现在有如下一组原始数据 A,然后用户 1 基于版本 A 修改了小钱的手机号。数据如下:

const versionA = `
小张 18866277777=
小吴 12233333111
小钱 19277788888
小叶 12111111222
`;
// 修改后:
const versionB = `
小张 18866277777
小吴 12233333111
小钱 18888888888
小叶 12111111222
`;

在 javascript 中,我们可以借助 diff 这个 npm 库进行基于行的内容 diff 和 patch 操作。createPatch 方法第一个参数为文件名,第二个参数为修改前的内容,第三个参数为修改后的内容,最后返回两个版本的 patch 结果:

import { createPatch, applyPatch } from 'diff';

const patchBA = createPatch('data', versionA, versionB);
console.log(patchBA); // 输出结果如下

上面的 patch 结果输出如下,前面带 - 的表示要删除的行,带 + 的表示要新增的行:

Index: data
===================================================================
--- data
+++ data
@@ -2,6 +2,6 @@
 小张 18866277777
 小吴 12233333111
-小钱 19277788888
+小钱 18888888888
 小叶 12111111222

那么假如此时又有一个用户 2,基于版本 A 将内容修改成了如下的版本 C,在中间添加了一行小黑的数据。我们不能直接将版本 C 保存为最终结果,而需要拿到远程最新版本 B,将 patchCA 应用于版本 B,以获得最终的结果:

const versionC = `
小张 18866277777
小黑 19222221111
小吴 12233333111
小钱 19277788888
小叶 12111111222
`;

const patchCA = createPatch('data', versionA, versionC);
let result = applyPatch(versionB, patchCA);
console.log(result);

最终 result 的结果输出如下,可以看到小钱所在行数据的修改以及增加的小黑那一行都出现在了最终的数据结果中:

小张 18866277777
小黑 19222221111
小吴 12233333111
小钱 18888888888
小叶 12111111222

但是基于行的算法很容易产生冲突。例如有如下一行信息需要进行填写,其中有姓名年龄手机号所在省份进行填写。用户 A 填写了其中的姓名年龄,用户 B 填写了其中的手机号所在省份

const data = '姓名:; 年龄:; 手机号:; 所在省份:;';

const dataA = '姓名:小周; 年龄:23; 手机号:; 所在省份:;';
const dataB = '姓名:; 年龄:; 手机号:18866668888; 所在省份:北京市;';

如果现在将 dataA 和 dataB 按照基于行的 diff 合并,因为一个只有一行数据,那么一定会发生冲突。但其实它们可以合并为'姓名:小周; 年龄:23; 手机号:18866668888; 所在省份:北京市;' 这样一条完整的数据,为了解决这个问题,我们可以使用更细粒度的基于字符的 diff。

基于字符的 diff

实际上上面提到的 diff 库也有基于字符粒度的 diff,但是缺少了基于字符粒度的 patch 封装,我们使用 diff-match-patch 这个库进行基于字符粒度的 diff。

import DiffMatchPatch from 'diff-match-patch';
const dmp = new DiffMatchPatch();

const patchA = dmp.patch_make(data, dataA);
const result = dmp.patch_apply(patchA, dataB);
console.log(result);

result 结果是一个数组,数组第一项是合并后的结果,第二项是一个 patch 是否成功的数组:

['姓名:小周; 年龄:23; 手机号:18866668888; 所在省份:北京市;', [true]];

所以从结果看,基于字符的 diff-patch 效果要远好于基于行的 diff-patch。

不可避免的冲突

个人进行了一些其他的文本基于字符粒度 diff-patch 的样例测试,在部分情况下还是会有冲突,但是 diff-match-patch 面对冲突时会自动选择一部分版本进行保留,这可能导致我们部分内容的丢失:

import DiffMatchPatch from 'diff-match-patch';
const dmp = new DiffMatchPatch();

const data = '篮球,足球';
const dataA = '棒球,足球,羽毛球';
const dataB = '台球,足球,乒乓球';

const patchA = dmp.patch_make(data, dataA);
const res = dmp.patch_apply(patchA, dataB);
console.log(res);

如上述代码,版本 A 中将篮球改成了棒球,版本 B 中将篮球改成了台球,理论上 patch 是会发生冲突的。但最终 res 的输出如下,diff-match-patch 直接舍弃了 台球 保留了 棒球

['棒球,足球,羽毛球,乒乓球', [true]];

进一步优化:协同编辑

上面的版本合并我们知道,最大的问题就是版本冲突,结合我们使用 git 的经验,其实我们可以发现,我们编辑时的版本,与数据库最新版本之间的版本差异数量越大,越容易产生冲突。那么我们自然可以联想到,只要让我们当前正在编辑的版本与数据库最新版本尽量保持一致,就不容易产生冲突了。这就来到了我们本篇的重点——协同编辑。

在追求高用户体验的场景,例如各种在线文档,几乎都会采用协同编辑的方案,保证多人在线实时编辑。要实现协同编辑,主要需要实现几个关键技术点:

  • 用于增量传输的 Diff 算法:在协同编辑领域,常用的两种技术为 OT(Operational Transformation) 和 CRDT (Conflict-free Replicated Data Type)
  • 文档的实时更新:可以采用 WebSocket 或者是轮询的方法,在追求性能和体验的情况下,通常我们会选择 WebSocket
  • 更新内容的富文本编辑器:此项是可选的,通常多人在线编辑的场景需要支持丰富的内容编辑,因此需要一个富文本编辑器,普通的文本编辑场景不需要。

OT

OT 是多用于协同编辑领域的一种技术,正如其英文全称 Operational Transformation 一样,分为两个步骤:首先是将用户的编辑行为转换成可枚举的操作(Operational);如果是有多人操作同时进行,则对这些操作进行转换(Transformation)。

在协同编辑领域,OT 技术应用广泛,一些优秀的在线文档如飞书云文档、Google Doc 等都是使用了 OT,可以说是久经考验,因此值得我们学习。

首先从 Operational 说起,以最简单的字符串类型数据为例,我们对于字符串的修改,可以分为以下三类操作:

  • retain(n):保留 n 个字符不做更改
  • insert(str):插入字符串 str
  • delete(str):删除字符串 str
我要吃烤地瓜
↓↓↓
今天吃了三个烤地瓜

例如上述的文本变化,在 OT 算法的 Operation 中,实际上是经过了以下的操作:

delete '我要';
insert('今天');
retain(1);
insert('了三个');
retain(3);

当有多个用户在同时进行 operation 时,传到服务端的 operation 同步到各个用户端可能产生冲突。 image.png 例如上面的用户 A 和用户 B 同时操作了同一段文本内容 abcd

  • 用户 A 的前端显示肯定是先应用了自己的 optA,文本内容变为 abcde,然后再应用来自服务端的 optB,文本内容变为 abcdfe
  • 用户 B 先应用了自己的 optB,文本内容变为 abcdf,然后再应用来自服务端的 optB,文本内容变为 abcdef 最终用户 A 和用户 B 展示的修改结果就发生了冲突。解决上面冲突的方法,我们就需要一个转换算法 transformation 对 operation 进行转换。根据转换视角的不同,常用的转换算法有两种:EasySyncundo

基于 EasySync 的双边转换

EasySync 是一种双边操作转换,即上面的案例中,用户 A 和用户 B 会站在各自的视角,都先各自执行自己的操作,再分别执行服务端转换后的对方的操作,最终得到一致的结果。即:

image.png 其中的 transform() 的结果如下:

transform(optA, optB) = () => {
  retain(4);
  insert('f');
  retain(1);
}

transform(optB, optA) = () => {
  retain(5);
  insert('e');
}

EasySync 算法中,假如把初始状态 abcd 记为状态 O,那么需满足如下公式恒成立:

O -> optA -> transform(optA, optB) === O -> optB -> transform(optB, optA)

基于 undo 的单边转换

undo 是一种单边转换算法,即无论站在用户 A 还是用户 B 的视角,他们最终应用的操作都是一样的,从而保证结果的一致。

例如上述案例,假如 optA 先传到服务端,那么用户 A 先应用了 optA,等 optB 也传到服务端之后,再应用 transform(optA, optB) 操作。而用户 B 在本地应用了 optB 之后,传到了服务端发现过程中有 optA 先修改了内容,则先执行 undo 操作,将状态改回 O,然后再执行 optA 和 transform(optA, optB),最终保持与 A 的内容一致。流程图如下:

image.png

其中,transform(optA, optB) 相当于如下操作:

retain(4);
insert('f');
retain(1);

基于 OT 的开源库

在基于 OT 的在线编辑实现中,我们可以发现难点主要有以下几点:

  • 根据前端的内容变更来创建对应的 operation

  • 服务端对相应的 operation 进行转换

  • 通过 WebSocket,实现内容的实时响应 其中尤其前两点实现起来不是特别的容易,好在 github 有一些开源库可以帮助我们快捷的进行实现(第一点和第二点各有不少的开源库,但是难点在于前端和服务端所采用的 operation 需要保持一致)。

  • etherpad: 基于 EasySync OT 实现的在线编辑器,将上面的三个技术点全部进行了封装。如果是个人开发者,可以使用其提供的中心化 WebSocket;如果是公司使用,可以根据指引将自己在服务单部署 WebSocket 服务,保证数据的安全。
    在线体验地址:video.etherpad.com/

  • ot.js:字符串格式的 ot 实现,上述的 OT 用例中就是以该库的 operation 进行讲解。封装了根据字符串的变更创建对应的 operation 以及 operation 的 transform 操作,缺点是需要自己手动实现 WebSocket 及相关的前后端通信逻辑,且只支持字符串格式。
    教学 demo:github.com/Operational…

  • shareDB:支持多种字符串、富文本、json 等多种 operation 及其 transform,由于支持的转换格式丰富,所以如果是想要针对自己的产品实现 OT 协同,应用比较广泛。
    教学 demo:github.com/share/share…

CRDT

CRDT (Conflict-free Replicated Data Type)即“无冲突复制数据类型”,它主要被应用在分布式系统中,保证分布式应用的数据一致性。文档协同编辑可以理解为分布式应用的一种,它的本质是数据结构,通过数据结构的设计保证并发操作数据的最终一致性。

CRDT 的提出时间比 OT 要晚很多,所以在多人协作编辑场景下的成熟产品相对较少,但是也有了一些应用,例如:atom 编辑器的 teletype、PingCode Wiki、以及 figma 的协作编辑也是借鉴了 CRDT 的思想。

CRDT 核心思想

前面提到 CRDT 主要应用于分布式系统,那么它的数据操作,都需要符合可交换性幂等性,已解决以下可能遇到的问题:

  • 网络问题导致发送接收顺序不一致(可交换性)
  • 以及多次发送(幂等性) 那么放到我们的编辑场景中,首先要保证操作的可交换性,那么我们只需要知道所有操作的顺序,最后对操作进行排序就可以了。我们可以依据每个操作的 timeStamp,对操作进行排序。每个用户都有一个 UID,多个不同用户如果出现 timeStamp 相同的情况下,我们可以按照 UID 进行升序,保证并发操作的顺序。

同时,UID 和 timeStamp 组合在一起,就保证了每一个操作都具备唯一的 ID,可以实现幂等性,解决统一操作多次发送的问题。

CRDT 树状结构

image.png

还是以上面的这个场景为例,在 CRDT 中,会为每一个字符都创建一个操作标识,假如初始状态 abcd 中的 d 的标识为 UserO@T4,那么用户 A 和用户 B 的操作都是基于操作标识的:

UserA@T5: insert('e') at UserO@T4
UserB@T6: insert('f') at UserO@T4

那么这两个操作只需要保证好他们的顺序,不需要转换就能够保证最终修改结果的一致,实际的操作数据结构如下:

image.png

从这个操作结构我们可以看得出,为了保证 CRDT 的实现,数据库中需要存储每一个字符的标志符,同时当有新的操作产生时,需要遍历操作的树状结构,找到与当前操作所关联的那个节点。

基于 CRDT 的开源库

  • Yjs:社区最知名的 CRDT 框架,从 V8 的角度去优化 Yjs 结构对象的创建,整体思路就是让 Yjs 创建对象的过程能够被浏览器优化,无论是内存占用还是对象创建速度。 其他的基于 Yjs 的框架还有 SyncedStore 等。

OT 与 CRDT 对比

OT 算法由于发展时间长,已经相对成熟,但是社区很多人对 CRDT 在多人协作场景的应用表示看好,并且认为未来 CRDT 会比 OT 更加有前景。就目前来说,二者对比如下:

框架优势劣势
OT1. 高性能
2. 能够保存用户操作意图
3. 不影响文档体积
1. 需要中心化服务器
2. 算法设计复杂
CRDT1. 去中心化
2. 算法设计相对简单
3. 稳定性高
1. 比较消耗内存和性能
2. 损失用户操作意图

总结

本文总结了多种解决多人编辑场景下的内容覆盖的方案,针对不同场景,我们可以选择不同的方案。

  • 如果只是想解决内容覆盖问题,没有多人协作的要求,那么推荐使用编辑锁
  • 如果有多人协作需求,但对内容的实时性要求低,那么可以考虑采用版本合并的方案
  • 如果想要实现多人实时协作,那么只能考虑采用 OT 或者 CRDT 实现协同编辑。

参考