聊聊协同算法

前言

时至今日,多人协作以及分布式系统已经变得十分普遍了。本篇文章就来聊聊其中的协同问题是如何解决的。

方案

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.jsetherpad-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,一般来说这并不是一个理想的效果。其实大部分场景,用读写锁再加上一些友好的用户提示完全可以解决,没必要搞得太复杂。总之就是这样了,这篇文章写太长了点,断断续续写完的,可能不同的部分看上去会有比较强的割裂感,就酱。

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60616 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅7 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment7 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax