协同场景的状态同步探索之一

背景

前两天在整理 消息系统进化史-零号机 的思路的时候,想到一种场景: 几个客户端之间想拥有一个公共的状态,任何一个客户端对该状态进行了更改,都能同步更改到其他客户端。

于是,在此思路上,做了 如何搞定前后端一体的状态管理 ,基本思路是: 用一个 map 去保存状态,并对 map 的每个值添加监听器,如果操作了某个值,则对监听者发送事件通知。 基于此,在对特定的事件进行封装,就能实现多客户端拥有全局统一的状态了。

简单思考

这确实有一些实用价值。在协同场景下,这类需求还是存在的。

  1. 没有 demo
  2. 没有进一步封装的 sdk

协同场景的简单思考

协同场景,简单来说,就是: 存在一个公共的资源,有多个用户可以对其进行操作。

最典型的场景是: 多人游戏、文档协作。 当然也有一些其他的场景,例如 在线协同设计、在线白板(教学、演示等)。

协同场景的基本逻辑,和上面做的 responsive map 是一样的,都是保证协同的多方所共同持有的那个公共资源拥有一致的状态。 而这个状态,可以是一个 map,也可能是其他数据结构,这是根据业务实际情况而定的。

为了实现协同,必不可少的步骤就是: 任意一方将状态更新时,都同步到其他方去。

因此,有两个隐形要求: ① 状态是能够传输的 ② 更新通知时能够及时发送的。 说人话一点大概是这样:

  • 状态的数据结构是可以被序列化的
  • 需要一套消息系统将消息发送出去

从工作职能上,我们可以按照上面的分析,划分成 协同业务消息系统 两个部分。 协同业务 负责从业务角度 设计数据结构协同事件序列化协同事件处理; 消息系统 负责从全局角度 设计消息传输系统

数据结构,目标是: 用符合资源模型的数据结构去描述共有资源。 这个跟业务强相关,姑且不描述了。

协同事件序列化,目标是:将当前操作变成能够通过网络传输的数据。

协同事件处理,目标是:将其他方发来的操作变动在自己的共有资源上进行还原。

一般来说,协同事件的传输内容有两个基本的方案: ① 同步操作。 ② 同步状态。 这两种方案均有其特点以及特定的应用场景。

例如,在一些棋牌类游戏中,由于公共资源的数据模型比较简单,因此,在任意游戏参与者做了操作之后,服务端都会把整个数据发送给各端。 比如,我打了一对 A,然后就剩下一张 3 了,服务器就会把当前所有牌的情况给各端发一次 (猜的,不一定真是这样啊)。

状态同步的缺点在于: 数据量大的时候会有性能问题。
状态同步的优点在于: 数据一致性很好保证。

因此,全量同步比较适用的场景是: 核心模型的数据结构不会特别复杂,对数据一致性要求很高。

而有一些场景下,数据结构十分复杂,每次全量同步就会存在性能瓶颈 (序列化瓶颈、网络瓶颈、反序列化瓶颈、diff 瓶颈)。 这种时候,就只能使用 操作同步 的方案。

操作同步,意味着在每次对资源进行操作时,把每个操作进行序列化,并把这些操作消息传输到其他端。 在接收到操作的端上,将操作反序列化后,把操作在本地进行 回放

举个例子,我们假设有一个直播间,他的基本数据模型是一个 map ,例如是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"room_name": "first room",
"desc": "this is a test room",
"picture": "https://xx.xx.com/xxxxxxx.jpg",
"room_detail": {
"user_counts": 2,
"users": [{
"name": "user001",
"avatar": "https://xx.xx.com/xxx001.jpg",
"id": "001"
},{
"name": "user002",
"avatar": "https://xx.xx.com/xxx001.jpg",
"id": "002"
}]
}
}

当一个用户对将这个房间的 desc 进行了修改,这将会产生这样的 option:

1
2
3
{
"desc": "this is a very fantastic room !"
}

或者,你也可以做一层封装,类似于这样:

1
2
3
4
5
6
{
"desc": {
"operation": "update",
"value": "this is a very fantastic room !"
}
}

实际操作的数据结构设计,是跟业务直接相关的,不同的业务会有不同的设计,这里就只是举个例子。

这种操作同步的方案,优点在于: 传输的数据轻量,能支持比较复杂的数据模型。
缺点在于: 数据一致性比较难保证。

当下比较火热的协同产品,大都是比较复杂的数据模型,基本都选择了这类操作同步的方案。比如 在线文档。

数据一致性的一些方案

上面我们有讲到,使用 操作同步 的方案,会有数据一致性的问题,那么,我们一起来看一下数据一致性一般有哪些常规方案。

解决数据一致性的问题,可以分成两类场景:

  1. 单主模型
  2. 多主模型

单主模型下,出现数据一致性问题的主要场景就是 事务,通常是直接通过加锁实现,差别基本只存在于锁类型。

多主模型是数据一致性的重灾区,一般来说,可以有这些方法:分布式锁、共识算法、OT (操作转换)、CRDT(无冲突复制数据类型) 。

在协同操作的场景中,由于对响应速度的追求是很高的,因此最终会走上 多主模型 的 OT 和 CRDT 的方案。由于 OT 有一些不好解决的缺陷,因此 CRDT 成为众多协同产品的技术实现方案。CRDT 的介绍,可以看这篇文章 ,里面的连接挺全。

关于 yjs 的介绍,可以参考 Yjs实现

vue-socket.io 的发现

在看 socket-io 的过程中,看到了一个有意思的项目: Vue-Socket.io , 这个项目在 github 上的赞是有 3.9k 的 (2022/11),其相关项目 vue-socket.io-extended 的点赞也达到了 600+ 。

前者的功能是很简约的,仅仅提供了接收事件的能力,一定程度上封装了 vue store 的 mutation 能力。

后者在实现上,封装了 vue.Use 用法,写法上更抽象一些,最大的优点在于 提供了 类型,对于开发是很友好的。

这两个项目其实都是比较简单,但不妨碍很多人对其点赞,因为这对前端同学来说,实现了很有想象空间的能力。所有,对于一个项目而言,评判的标准并不是项目有多复杂,而是这个项目有什么价值?实现了什么能力?占据了什么生态位?

另外提一嘴,一个后起的项目,如果没有一些强痛点的突破,一般很难发展起来,这是由于生态位被占据了。 但 vue-socket.io-extended 这个项目算是起来了,我归结其原因有 2 : ① 类型提示,开发更友好 ② 文档齐全,demo 很多。 整体来说,用户友好 算是可以和 生态位 匹敌的因素了。

vue-socket.io 能解决的问题是:

  • 将本地事件发送到其他客户端
  • 接收其他客户端发来的事件

事件本身可以作为需要传递的东西,用于在多端间进行一些操作触发,例如,在一个直播间里,一个用户操作了 鼓掌,该事件在其他端也可以同步进行动画效果展示。

事件还可以和 状态 相关联,这类事件 专门为 修改状态 服务,上面这两个库核心能力就是将 socket.io 这个事件传输机制 和 vuex 这个状态管理器 结合。

状态能相互关联了,想象空间就更大了。 一个 app 的各项能力,实际都是有其数据模型的,模型和展示之间存在着绑定关系 (中间一般挡着一些业务逻辑),那么,一个供多方使用的功能模块,只要在设计时,就按照多用户使用的场景进行建模,那么就能实现一个用户对自己客户端 model 的修改,也同步到其他端。

这样的好处是,开发多用户功能时,不用以全局的、通过网络通信的视角看待系统,只需将系统看做一个本地多用户系统即可,在降低开发者心智负担上,还是有不少的价值。

上面的两个库,实现了前端的 vuex 和 事件接收和发送的打通。在真实应用中,一些简单的场景可以直接使用,例如,一个聊天室中,房间人员房间消息 ,这种基本只有 appenddel 操作的类型,在不考虑严格的顺序情况下,就可以使用。

那么,如果对于 大家都会操作 的场景,或者有比较严格的 消息顺序 的场景,还是否适用呢?

这就涉及到协同冲突的问题了。

协同冲突问题

多端协同,每一端都拥有一份模型数据的拷贝,且每端都能对模型数据进行操作,那么,在操作的时候,由于有网络传输的存在,就有可能出现 客户端 A 操作了资源 x,然后操作开始往 客户端 B 传输,还没传输到的时候,客户端 B 也操作了 x,并且向 A 发送操作。 当双方都收到操作后,就变成了两个客户端的数据不一致了。

这种多端协同时,会产生各端数据不一致的情况,就叫做协同冲突。

处理冲突的方式一般有两种模式: 集中式 和 分散式。 集中式的代表是 OT,中文翻译为 操作转换,处理逻辑是,由中央程序来确定该给协同各方回复什么消息。优点是,数据结构简单明了。缺点是,场景复杂了之后,需要做的操作转换就非常复杂,开发上容易出错。

最常被举例的场景就是 文档协同,例如一段文字 abc A 操作了 删除1上的值a 预期是 bc,同时 B 操作了 在2的位置增加x 预期是 axbc, 如果直接把 A 的操作给到 B,就会在 B 处变成 xbc,而 A 处就会变成 bxc ,两边就不一样了。 而 OT ,可能会把给 A 的操作变成 在1的位置增加x,A就成了 xbc,相当于处理了冲突。

上面是对于数组型顺序冲突的处理。 还有一些其他数据类型的冲突,例如 map。

另一种分散式的冲突处理方式,典型是 CRDT,这是一种通过在数据结构上下功夫,实现分散式协同过程中保持一致性的一种方案,典型的数据结构设计方案可以参考 Yjs 的实现。

调研

  1. 跑通流程
  2. 协同冲突的问题
  3. 封装 sdk
  4. 内网网络性能量化评估
  5. 设计好的接口 几乎是写一个库最有价值的内容!!! 这就是 建模 的核心。这是软件工程师的核心技能之一。

I will not be concerned at other men’s not knowing me; I will be concerned at my own want of ability.
Confucius