前言
时至今日,多人协作以及分布式系统已经变得十分普遍了。本篇文章就来聊聊其中的协同问题是如何解决的。
方案
LWW覆盖(Last write wins)
顾名思义,这个方案的效果就是,以最后提交的版本的为准,先于该版本的编辑都会被覆盖掉,一般来说这个方案不用特殊实现,默认即是这个效果。以一个文本的多人编辑过程为例子:
这个方案的缺点不言而喻,谁都不想自己辛辛苦苦编辑了一上午的内容被其他人覆盖掉。
读写锁
读写锁这个概念我最早应该是在学操作系统的时候接触到的,其实就是加锁的概念,不过不同的是,它分成了读锁和写锁,读操作是线程安全的,因此多个线程一起加读锁是被允许的;写操作则不是,因此应该被设计成是互斥锁,而一旦一个线程为一个资源加了写锁,另外的线程就不能再访问该资源了,读写操作都将不被允许。另外注意,使用读写锁的前提是你得有只读模式和可写模式,如果只有可写模式的话,读写锁其实相当于退化到互斥锁了。读写锁主要有以下三个特点,用伪代码描述一下:
读读不互斥
ts
const [readLock, writeLock] = new ReadWriteLock();
// 线程A
try {
readLock.lock();
console.log('线程A得到读锁');
await sleep(2000);
console.log('2s后');
readLock.unlock();
console.log('线程A释放读锁');
} catch (err) {
console.log('线程A没有得到读锁');
}
// 假设是另一个线程,线程B
try {
await sleep(1000);
console.log('1s后');
readLock.lock();
console.log('线程B得到读锁');
readLock.unlock();
console.log('线程B释放读锁');
} catch (err) {
console.log('线程B没有得到读锁');
}
/** 最终打印结果应为:
* 线程A得到读锁
* 1s后
* 线程B得到读锁
* 线程B释放读锁
* 2s后
* 线程A释放读锁
*/
读写互斥
ts
const [readLock, writeLock] = new ReadWriteLock();
// 线程A
try {
writeLock.lock();
console.log('线程A得到写锁');
await sleep(2000);
console.log('2s后');
writeLock.unlock();
console.log('线程A释放写锁');
} catch (err) {
console.log('线程A没有得到写锁');
}
// 假设是另一个线程,线程B
try {
await sleep(1000);
console.log('1s后');
readLock.lock();
console.log('线程B得到读锁');
readLock.unlock();
console.log('线程B释放读锁');
} catch (err) {
console.log('线程B没有得到读锁');
}
/** 最终打印结果应为:
* 线程A得到写锁
* 1s后
* 线程B没有得到读锁
* 2s后
* 线程A释放写锁
*/
写写互斥
ts
const [readLock, writeLock] = new ReadWriteLock();
// 线程A
try {
writeLock.lock();
console.log('线程A得到写锁');
await sleep(2000);
console.log('2s后');
writeLock.unlock();
console.log('线程A释放写锁');
} catch (err) {
console.log('线程A没有得到写锁');
}
// 假设是另一个线程,线程B
try {
await sleep(1000);
console.log('1s后');
writeLock.lock();
console.log('线程B得到写锁');
writeLock.unlock();
console.log('线程B释放写锁');
} catch (err) {
console.log('线程B没有得到写锁');
}
/** 最终打印结果应为:
* 线程A得到写锁
* 1s后
* 线程B没有得到写锁
* 2s后
* 线程A释放写锁
*/
读写锁的缺点当然也很明显,当有一人处于编辑模式下,其他所有人都不能访问该资源,显然是不合理的。当然也有一些优化的空间,比如如果有人处在编辑模式下,其他人可以以只读的方式访问该资源,至于要不要同步编辑者的修改,可以再定。另外对于在线文档这种场景,可以对编辑者增加一个长期占用提示,如果编辑者没有选择继续编辑,可以释放掉它的写锁,防止占着茅坑不拉屎的情况发生。
基于diff-patch的算法
使用这个方案最典型的代表莫过于Git了。首先是合并能自动合并的部分,然后不能合并的冲突部分,丢给用户手动选择应该怎么处理。怎么判断哪些是可以合并,哪些是不可以合并的呢,就要用到文本diff算法了,这里边又有基于行diff、词diff和字符diff等等。
这里就用行diff演示一下,本文不介绍算法的具体思想,只是讲述一下思路哈。行diff的话,直接用 diff 就好,底层是Myers算法,如果感兴趣可以参考 Myers算法。当然diff算法有很多,甚至直接用LCS(Longest Common Subsequence)算法也行,只是时间复杂度高点。
简单使用一下。假设有三个用户A、B、C,他们都从originVersion拉出一个分支,然后在本地有一次提交。先看一下A、B之间没有冲突,可以自动合并的情况:
ts
import { createPatch, applyPatch } from 'diff';
// 初始版本
const originVersion = `
11111
22222
33333
44444
55555
`;
// 用户A修改后版本
const versionA = `
11111
aaaaa
33333
44444
55555
`;
// 用户B修改后版本
const versionB = `
11111
22222
33333
44444
55555
bbbbb
`;
// A用户基于originVersion的改动
const patchAO = createPatch('filename', originVersion, versionA);
// 输出
// Index: filename
// ===================================================================
// --- filename
// +++ filename
// @@ -1,6 +1,6 @@
// 11111
// -22222
// +aaaaa
// 33333
// 44444
// 55555
console.log(patchAO);
// B用户基于originVersion的改动
const patchBO = createPatch('filename', originVersion, versionB);
// 输出
// Index: filename
// ===================================================================
// --- filename
// +++ filename
// @@ -3,4 +3,5 @@
// 22222
// 33333
// 44444
// 55555
// +bbbbb
console.log(patchBO);
// A用户push到远程,远程版本更新
const mergeAVersioin = applyPatch(originVersion, patchAO);
// 输出
// 11111
// aaaaa
// 33333
// 44444
// 55555
console.log(mergeAVersioin);
// B用户尝试push,git告知需要先更新远程版本,此处省略该过程
// B用户pull,通过merge方式合并远程版本
const mergeABVersion = applyPatch(versionB, patchAO);
// 输出
// 11111
// aaaaa
// 33333
// 44444
// 55555
// bbbbb
console.log(mergeABVersion);
// 之后B用户push到远程分支
然后我们看一下A、C之间有冲突的情况:
ts
// 用户C修改后版本
const versionC = `
11111
ccccc
33333
44444
55555
`;
// C用户基于originVersion的改动
const patchCO = createPatch('filename', originVersion, versionC);
// 输出
// Index: filename
// ===================================================================
// --- filename
// +++ filename
// @@ -1,6 +1,6 @@
// 11111
// -22222
// +ccccc
// 33333
// 44444
// 55555
console.log(patchCO);
// C用户push到远程,git告知需要先更新远程版本,此处省略该过程
// C用户pull,通过rebase方式合并远程版本
const mergeABCVersion = applyPatch(mergeABVersion, patchCO);
// 打印false,说明版本冲突,丢给用户C手动处理冲突
console.log(mergeABCVersion);
// 可怜的用户C手动处理冲突后,push到远程分支
那么这种方案的劣势相信大家在日常使用Git的过程已经深有体会,看到那一片一片的冲突头都大了。
OT算法(Operation Transformation)
接下来介绍一下本篇文章的重点,OT算法。OT被广泛运用在各大在线文档产品中,可以说是处于垄断地位。
依然是"望文生义",O是Operation,T是Transformation,OT算法的核心思想,既是定义原子化操作Operation,对文档的任何操作都能够通过多个Operation组合得到;而Tranformation,则是对基于同一版本进行的不同操作,进行的一致性处理,画一个状态图:
其中节点表示状态,边表示操作,边的起点表示进行该操作前的状态,边的终点表示进行该操作后的状态。上图描述的意思即是:有两个用户,他们从服务端获取到的初始状态均是初态 Sstart;用户A进行了 Oa 操作后,变成了 Sa 态;用户B进行 Ob 操作,变成 Sb 态;然后使用transform函数,通过transform(Oa, Ob)得到 Tb,Ta,然后用户A在 Sa 态的基础上进行 Tb 操作,变成终态 Send,用户B在 Sb 态的基础上进行 Ta 操作,变成终态 Send;至此,就达成了一致性结果。
没讲明白,还是一脸懵?那就对了,举个栗子🌰:
为了简单起见,我们先做好约定,假设文档只支持文本数据,客户端与服务端之间通过Websocket双向通信,传递Operation和版本信息,文档只有两种Opration,插入和删除,函数定义如下:
ts
/**
*
* @param {string} originStr 原始字符串
* @param {number} pos 插入起始位置
* @param {string} str 插入内容
* @returns {string} 插入后的字符串
*/
const insert = (originStr: string, pos: number, str: string) => {
const originStrArr = originStr.split('');
return originStrArr.slice(0, pos).join('') + str + originStrArr.slice(pos).join('');
};
/**
*
* @param {string} originStr 原始字符串
* @param {number} pos 删除起始位置
* @param {number} len 删除字符长度
* @returns {string} 删除后的字符串
*/
const delete1 = (originStr: string, pos: number, len: number) => {
const originStrArr = originStr.split('');
return originStrArr.slice(0, pos).join('') + originStrArr.slice(pos + len).join('');
};
假设用户A和用户B打开了同一篇在线文档,文档的内容是"超级无敌大怪兽!",定义为 Sstart,则有:
ts
const S_start = '超级无敌大怪兽!';
然后用户A在感叹号前插入了"最帅"两个字,我们定义如下:
ts
const O_a = (S: string) => insert(S, 7, '最帅');
// 超级无敌大怪兽最帅!
const S_a = O_a(S_start);
接着用户B在感叹号前插入了"最强",定义如下:
ts
const O_b = (S: string) => insert(S, 7, '最强');
// 超级无敌大怪兽最强!
const S_b = O_b(S_start);
此时我们假设A的操作 Oa 先到达服务端,此时服务端的版本更新为 Sa;接着B的操作 Ob 到达服务端,服务端发现它们基于 Sstart 产生了分歧,因此需要transform操作(注意,很多人会在这里产生误区,误以为transform是比较的A用户和B用户版本的差异,其实不是的,A用户的操作到达服务端之后,由于它们上一个版本是一致的,因此服务端可以直接合并A的操作,生成新的版本,此时服务端和A用户已经达到了同步状态,所以之后其实比较的其实是服务端和B用户版本的差异;因此这里也就解答了一些朋友的一个疑惑,你这里讲的是两个人的协作,如果我有三个人、四个人或者更多应该怎么办?其实不是的,对于某一个用户而言,它所面对的只有两个版本,自身版本以及服务端版本,其他用户的版本跟它没有关系,同步其他用户的版本是服务端做的事,它只需要和服务端之间达成同步就好;因此这个模型,实际上跟协作人数多少没有关系)。我们定义transform函数如下:
ts
const transform = (Op1, Op2) => {
// to do
return [T2, T1];
}
const [T_b, T_a] = transform(O_a, O_b);
关于transform函数的实现,我们这里先卖个关子,暂时只需要知道它的入参是两个Operation,返回的是两个Transformation就好。
服务端把 Tb 操作返回给用户A,Ta 操作返回给用户B,A、B分别执行操作后达到终态 Send:
ts
// 用户A
const S_end = T_b(S_a);
// 用户B
const S_end = T_a(S_b);
// 超级无敌大怪兽最帅最强!
console.log(S_end);
至此,用户A、B的文档就达成了同步状态。
那么现在,再回过头去看看一开始提到的状态图模型,是不是就要好理解一些了。理解了吗?真的理解了吗?好,如果真的理解了之后咱们上点强度。
上述提到的其实是客户端与服务端分支diff 1 * 1 的模型,也即客户端和服务端都基于一个父节点各自只产生了一个新节点,接下来我们看看 1 * n 的模型:
基于初态 Sstart,A用户产生了两个操作 Oa1 和 Oa2 后变成 Sa2 态;B用户产生了一个操作 Ob1 后变成 Sb1 态;现在我们假设A用户的两个操作先到达服务端,并被服务端顺利合入生成新版本,然后B用户的操作到达服务端,产生版本diff,然后服务端使用transform函数来进行处理。首先对 Oa1 和 Oa2 进行transform生成 Tb1 和 Ta1,然后再对 Oa2 和 Tb1 进行transform生成 Tb1' 和 Ta2;最后服务端和A用户执行 Tb1' 操作达到 Send 态,B用户执行 Ta1 和 Ta2 两个操作达到 Send 态。怎么理解这个模型呢,我们可以先只关注右上角的菱形,将其理解成为A和B从初态 Sstart 分别进行了 Oa1 和 Oa2 操作,经过 1 * 1 模型处理后,Sa1 态执行 Tb1,Sb1 态执行 Ta1 得到终态 S1;然后看左下角的菱形,可以看成是以 Sa1 态为初态,Send 态为终态的 1 * 1 模型,Sa2 态执行 Tb1' 操作变成终态,S1 态执行 Ta2 操作变成终态。
看看代码:
ts
// 初始文本
const S_start = '超级无敌大怪兽!';
// A用户操作
const O_a1 = (S: string) => insert(S, 7, '最帅');
const O_a2 = (S: string) => delete1(S, 0, 4);
// 超级无敌大怪兽最帅!
const S_a1 = O_a1(S_start);
// 大怪兽最帅!
const S_a2 = O_a2(S_a1);
// B用户操作
const O_b1 = (S: string) => insert(S, 7, '最强');
// 超级无敌大怪兽最强!
const S_b1 = O_b1(S_start);
// 对 Oa1 和 Oa2 进行transform生成 Tb1 和 Ta1
const [T_b1, T_a1] = transform(O_a1, O_b1);
// 对 Oa2 和 Tb1 进行transform生成 Tb1' 和 Ta2
const [T_b1_prime, T_a2] = transform(O_a2, T_b1);
const S_end = T_b1_prime(S_a2);
const S_end = T_a2(T_a1(S_b1));
// 大怪兽最帅最强!
console.log(S_end);
理解了 1 * n 模型后,根据对称性,自然也就能理解 n * 1 模型,不再赘述。
加大难度,如果是 m * n 模型呢?
如果你真的理解了一开始的 1 * 1 菱形模型和 1 * n 模型后,我相信上面这张图你也能够很容易理解,依然是把它拆分成一个个的 1 * 1 模型,看成是从初态转成中间态、中间态再转成另外的中间态 ...... 最后把中间态转成终态。我都不想讲了,思路跟 1 * 1模型演进到1 * n 模型一模一样,没趣。
还是放下代码吧:
ts
// 初始文本
const S_start = '超级无敌大怪兽!';
// A用户操作
const O_a1 = (S: string) => insert(S, 7, '最帅');
const O_a2 = (S: string) => delete1(S, 0, 4);
// 超级无敌大怪兽最帅!
const S_a1 = O_a1(S_start);
// 大怪兽最帅!
const S_a2 = O_a2(S_a1);
// B用户操作
const O_b1 = (S: string) => insert(S, 7, '最强');
const O_b2 = (S: string) => delete1(S, 0, 2);
// 超级无敌大怪兽最强!
const S_b1 = O_b1(S_start);
// 无敌大怪兽最强!
const S_b2 = O_b2(S_b1);
// 对 Oa1 和 Oa2 进行transform生成 Tb1 和 Ta1
const [T_b1, T_a1] = transform(O_a1, O_b1);
// 对 Oa2 和 Tb1 进行transform生成 Tb1' 和 Ta2
const [T_b1_prime, T_a2] = transform(O_a2, T_b1);
// 对 Ob2 和 Ta1 进行transform生成 Ta1' 和 Tb2
const [T_a1_prime, T_b2] = transform(O_b2, T_a1);
// 对 Ta2 和 Tb2 进行transform生成 Tb2' 和 Ta2'
const [T_b2_prime, T_a2_prime] = transform(T_a2, T_b2);
const S_end = T_b2_prime(T_b1_prime(S_a2));
const S_end = T_a2_prime(T_a1_prime(S_b2));
// 大怪兽最帅最强!
console.log(S_end);
讲到这儿,其实OT算法的大致过程你就应该清楚了,但是还有一个坑没填,还记得我们上面的transform函数吗,我们当时没有实现它,现在来聊聊:
其实OT算法本身并不涉及transform函数的实现,虽然平时管OT都叫OT算法,但是OT其实是一种思想,而不是具体实现,OT对transform函数只有一个定义,输入两个Operation O1和O2,输出两个Transformation T1和T2,使得T1(O1(S)) === T2(O2(S))。你品,你细品,品出来了吗?没品出来,嘻嘻,没关系,我来帮你品。transform的定义,只是要求保障最终一致性,至于达成什么样的一致,并没有做要求,如果我像这样定义transform函数呢:
ts
// 撤销操作
const undo = (op) => {
// do something
// return a redo function
};
const transform = (Op1, Op2) => {
const T2 = undo(Op1);
const T1 = undo(Op2);
return [T2, T1];
}
看懂这个transform函数干了什么吗,它相当于是把产生分歧的操作给撤销了,也就是说从S态开始,用户A执行了OA操作,用户B执行了OB操作,最终经过transform的处理,又变回了S态!
哈哈,你可能说这玩意没用,可是你想想,上述实现是不是符合transform的定义呢,最终是不是达成了一致性结果?是滴,所以我们还可以玩得再花一点:
ts
const transform = (Op1, Op2) => {
const hijackStr = '我是wanna cry,你的文档已经被我劫持啦,打钱!';
const T2 = () => {
delete1(S_start, 0, INFINITY);
insert(S_start, 0, hijackStr);
};
const T1 = () => {
delete1(S_start, 0, INFINITY);
insert(S_start, 0, hijackStr);
};
return [T2, T1];
}
这段代码是啥,把文档清空了,然后插入一段话让我打钱???
好了,打住。你可以对transform的实现为所欲为,用它实现一个LWW也不是不可以,只要能达成最终一致性即可。可是正儿八经的在线文档肯定不能这么搞呀,那我们来看看正经的transform函数应该如何实现:
ts
// 首先明确一个点,上文为了简单起见,所有操作都是定义成函数的,
// 但在实际项目中,因为要在客户端和服务端传递操作,所以操作必须是可序列化的,
// 因此往往会自定义一种特殊的JSON结构来定义操作,比如这样的:
// {
// "operationName": "insert",
// "position": "7",
// "content": "最强"
// }
// 当然,实际项目的结构会复杂很多,同时为了减小传输体积,字段名会压缩
// 因此,下边的实现中Operation和Transformation都作为对象调用
const transform = (Op1, Op2) => {
let T2 = null;
let T1 = null;
// 服务端是插入
if (Op1.op === 'insert') {
// 客户端是删除
if (Op2.op === 'delete') {
// 客户端的删除操作在服务端的插入操作之前
if (Op2.pos < Op1.pos) {
// 服务端直接用客户端的删除操作
T2 = { ...Op2, pos: Op2.pos };
// 客户端需要调整一下插入的位置
T1 = { ...Op1, pos: Op1.pos - Op2.len };
} else { // 客户端的删除操作在服务端的插入操作之后
// 服务端的插入操作需要调整一下位置
T2 = { ...Op2, pos: Op2.pos + Op1.content.length };
// 客户端直接用服务端的插入操作
T1 = { ...Op1, pos: Op1.pos };
}
} else if (Op2.op === 'insert') { // 客户端是插入
// 客户端的插入操作在服务端的插入操作之前
if (Op2.pos < Op1.pos) {
// 服务端直接用客户端的插入操作
T2 = { ...Op2, pos: Op2.pos };
// 客户端的插入操作需要调整一下位置
T1 = { ...Op1, pos: Op1.pos + Op2.content.length };
} else { // 客户端的插入操作在服务端的插入操作之后
// 服务端的插入操作需要调整一下位置
T2 = { ...Op2, pos: Op2.pos + Op1.content.length };
// 客户端直接用服务端的插入操作
T1 = { ...Op1, pos: Op1.pos };
}
} else {
// ...
}
} else if (Op1.op === 'delete') {
// ...
} else {
// ...
}
return [T2, T1];
}
这里简单演示了一下只有插入和删除操作时如何实现一个可用的transform,实际项目肯定比这复杂得多,我只是抛砖引玉,讲个思路,大伙如果感兴趣可以参考ot.js、etherpad-lite等优秀库的实现,可以这么说:tranform函数的实现就是OT算法的灵魂。
照例是该讲讲OT的缺点,OT的缺点很明显,理解困难,实现复杂,没有一套通用的解决方案,需要根据具体的场景去定义不同的Operation和Transform,一般只应用于文本处理,并且如果分支差异过大整个算法的复杂度过大,如果一个分支有m个操作,另一个分支有n个操作,整体的时间复杂度是O(mn * O(transform))的。并且OT最终的版本如果想要达到预期的话,有一个重要的前提,所有客户端和服务端的网络都是畅通的,为什么这么说?对于一个字符串'abc',A改成了'abcd',然后B改成了'abce',一致性处理完后合理的预期应该是'abcde',但如果A网络不好,可能B后编辑但是却先到达服务端生成新版本,最终结果变成了'abced',这就是不符合预期的情况,这也是为什么说OT并不适合离线场景。OT还有另外一个很伤的点,它是典型的C/S结构,如果所有的客户端都是直接与服务端通信的,服务端会有很大的压力,需要大量的服务器成本,目前市面上做得最好的在线文档应该能支持200号人左右同时编辑。
CRDT(Conflict-free Replicated Data Type)
好,最后来讲讲CRDT,这是一个广泛运用于各大分布式系统的一套方案,同时如figma这样的协同设计稿网站也在用。
CRDT不是一种算法哈,从名字上也能够看出来,它是从数据结构的设计上解决一致性问题的。CRDT又分为State-based CRDTs 和 Operation-based CRDTs,分别基于状态和基于操作。对于CRDT,我了解不多,它的实现也是千奇百怪,让人眼花缭乱,只能简单讲一下我自己对于Operation-based CRDTs的理解。对于插入操作,生成ID和对应的timestamp,加上其它信息比如插入哪个节点所对应的ID,生成一个节点,然后把所有的插入操作串起来,按照timestamp的先后顺序生成一个双向链表;而删除操作,就是在链表中移除对应的几个节点,但是注意,这里的移除不是真的把这些节点从链表中删掉,而是说用一个字段来表示这个节点在文档中已被删除;修改可以看做是先执行删除操作,再执行插入操作。
在一众CRDT的实现中,yjs的性能可以说是吊打一切。但是CRDT的缺点也很明显,作为一个P2P方案,虽然服务端的压力没那么大,但对客户端本身,需要连接其他客户端却是一个很大的开销;另外因为它的节点是只会增加,不会减少的,所以存储压力也很大。最后就是CRDT只是达成最终一致性,但是可能不是用户预期的结果。
总结
这篇文章介绍了我对于各类协同算法的理解,其实主要是想说OT的,顺便也把其他的一些方案提了一下。希望看完这篇文章之后,你能有所收获,对于常见的协同方案有所了解,并且在面对需要协同的场景,一定要想到一致性处理,不然就变成LWW,一般来说这并不是一个理想的效果。其实大部分场景,用读写锁再加上一些友好的用户提示完全可以解决,没必要搞得太复杂。总之就是这样了,这篇文章写太长了点,断断续续写完的,可能不同的部分看上去会有比较强的割裂感,就酱。