1. 背景
项目中存在多人在线编辑的场景,由于设计之初只是小范围人员使用,当支持的业务逐渐扩大、使用人员增多之后就暴露了许多问题,其中比较突出的是多人在线编辑画布时出现节点丢失的情况,复现的流程大致如下:
- 编辑者A 和 编辑者B 同时打开
画布a
,此时画布处于初始状态,只包含节点【a,b】
- 编辑者A 首先操作画布a,添加了
节点z
并点击保存画布,此时画布内容更新为节点【a,b,z】
- 画布内容改变,但是编辑者B 的窗口未刷新,编辑者B 没有感知到画布的变化,在其画布上依然只包含
节点【a,b】
- 编辑者B 操作画布a,添加了
节点y
并点击保存画布,此时保存到数据库中的画布内容更新为节点【a,b,y】
- 由于编辑者B 对同一资源的修改覆盖了编辑者A 的改动,当编辑者A 关闭窗口并重新打开就会发现自己增加的
节点【z】
不见了,也就是所谓节点丢失了
其实这个问题本质上可以看作是一个
并发环境下多线程安全问题
,解决这个问题也就是解决临界资源
的操作对其他线程可见性的问题。参考 Java 中解决线程安全问题的方案,也就是 关键字 synchronized 和 关键字 volatile ,可以整理出两种解决方案:
给临界资源加锁
简单来说,就是每次要修改画布时,需要编辑者进行显式的加锁操作锁定资源,只有持锁的编辑者能够修改画布,也就杜绝了多人编辑产生的修改覆盖问题。这种方式直观简洁,比较容易实现,但是并发度太低保持临界资源可见性
多人编辑时,如果画布有更新,其他编辑者能够即时感知到更新,很大程度上也能避免多人编辑产生的修改覆盖问题。这种方式支持更高的并发度,但是实现起来难度更高,系统会更复杂
2. 设计方案
2.1 方案选择
针对上一节提出的两个解决方案,考虑到项目必须支持多人在线编辑的需求,评估后只能选择保持临界资源可见性
的方案
如果采用
加锁
的方式锁住资源,由于数据结构设计的历史遗留,锁住资源的粒度会非常大,基本上一个画布同一时间只能允许一个人编辑,这是业务方不能接受的
2.2 方案设计
保持临界资源可见性
的关键是资源一旦变动客户端就需要即时刷新数据,这其中客户端必然要监听服务端资源变化,方案的大体流程如下:
- 编辑者A 和 编辑者B 同时打开
画布a
,此时画布处于初始状态,只包含节点【a,b】
。当编辑者进入画布时,客户端同时开启一个线程不断轮询服务端的接口,这个线程根据服务端返回决定是否进行画布刷新,每次处理完毕后再次发起请求- 编辑者A 首先操作画布a,添加了
节点z
并点击保存画布,此时画布内容更新为节点【a,b,z】
。与此同时,新增一个操作记录表,每当画布更新则插入一条新记录到表中- 服务端内部新起一个定时任务扫描操作记录表,当扫描到某个画布有更新时,将消息通知出来。
采用数据库表实现类似消息队列的功能主要是为了减少外部依赖,另外消息中间件的消息通常只能被消费者组里面的某个实例消费一次,如果需要组内多个消费者都能消费会增加复杂度,这也是考虑的一个点
- 服务端长轮询接口的逻辑有比较重要的几点,可使用
Spring WebMVC
提供的DerferedResult
实现长轮询,其原理可参考 Spring WebMVC 源码分析(3)-异步请求 DeferredResult 的原理。采用长轮询是因为可极大降低服务端负担,避免服务端忙于响应客户端轮询影响性能
- 接口被调用时,首先去查操作记录表,如果请求方的画布有更新则直接返回,通知客户端刷新画布
- 如果请求刚刚到达时画布没有更新,则将请求挂起,设置一定时长后默认返回客户端不需要刷新
- 请求挂起等待期间,如果定时任务扫描到画布有更新,则需要将消息通知出来,将挂起的请求唤醒,立即通知客户端更新
- 通过长轮询机制,客户端可以很快感知到画布的变化并刷新展示在窗口上
- 编辑者B 的窗口上画布内容刷新为
节点【a,b,z】
,此时操作画布a,添加节点y
并点击保存画布,保存到数据库中的画布内容更新为节点【a,b,z,y】
- 通过长轮询机制,客户端可以感知到画布的变化并刷新展示在窗口上,编辑者A 的画布更新为
节点【a,b,z,y】
,修改覆盖问题缓解
方案存在的问题:
定时任务扫描间隔的确定
服务端定时任务扫描操作记录表的时间间隔很重要,如果太小会对数据库造成压力,如果太大又无法及时将数据变动广播到各个客户端,还是会存在修改覆盖的问题高并发下的处理
考虑两个编辑者修改画布后同时提交,如果不做任何处理,必然有一个人的修改要被覆盖掉。如果不能容忍这种情况,可以考虑加一个version
乐观锁放在 redis 中作为分布式锁存在,每次修改后version
自增。客户端提交修改时将自己的version
给到服务端,服务端将其与 redis 中的version
比较,相同则允许操作数据库,否则返回最新的version
。 这样就可以保证同一时间只有一个修改请求能够执行,其他的失败