使用 socket.io 解决多用户同时更新同一文档的问题
发布于 9 年前 作者 russj 5671 次浏览 最后一次编辑是 8 年前 来自 分享

问题

对于涉及文档编辑的应用,一个常见的应用场景就是多个用户试图修改同一个文档的问题。

某些情况下,多个用户可能先后读取同一个文档到本地浏览器,然后编辑修改。有的人可能只是需要简单修改,然后很快就保存离开;而有的用户可能需要修改多些内容,然后在前一个保存离开之后再存档离开。这种情景,如果不做任何处理,第二个离开的用户的存档会覆盖掉第一个用户的修改,我想这是第一个用户不愿意看到的。

方法

那么我想这里有两种解决办法:

  1. 合并两次修改。但是如果有冲突怎么办?像 Git 那样修改 conflicts?这个办法还可能需要多个用户沟通解决,实现起来也比较复杂。
  2. 锁定。只让一人修改,后来者只能读,并被告知某人正在编辑,稍后再来。这个办法简单,容易实现。

实现

我决定使用 socket.io 来实现第二个方案。websocket 的好处是实时,不用轮询来得知状态。代码也简洁。虽然这里使用 Ajax 方式也可以。

具体方法是(只考虑较少用户量):

  • 客户端(浏览器):每次试图从服务器读取一个文档时,发送

      socket.emit("toEdit", {userEmail: $scope.userEmail, docId: $scope.docId}); 
    

    等下会介绍,服务器端会有一个对象保存所有正在使用的相关文档信息,这个 docId 用来判断这份文档是否正在使用,userEmail 判断谁在占用该文档,可以方便内部用户之间协商使用时间,例如企业内部用户。

    如果发现这份文档正在被其他人使用,服务器端会发送 ‘isEditing’ 消息告知客户端正在使用

      socket.on('isEditing', function (email) {
          alert("该文档正在被 " + email + " 编辑。”);
          $scope.disableSaveButton = true; //灰化保存按钮,防止用户保存更新
      });
    
  • 服务器端(Node.js):在 server 端使用一个对象保存已在编辑的相关信息。如下:

    //结构:{socket.id: {userEmail: email, docId: id}}
      //socket.id:  每个 socket 链接自动产生的 id,用于识别每个登录的页面,同个用户可能使用多个页面登录;
      //doc id:     文档的唯一ID, 用于识别用户准备和正在编辑的文档;
      //user email: 用于通知用户谁正占用该文档。如果是内部用户,这可以让他们自己协调使用时间。
      var editingUsersInfo = {}; 
    

editingUsersInfo 保存了所有正在编辑文档的相关信息;如果一个页面查找的 docId 没人在使用的话,这个对象就会产生一个对应的属性。见下:

    io.on('connection', function(socket){
		socket.on('toEdit', function(c_ei){
			//s_ei: server {user email, doc id}; 服务器端每个 socket 链接对应的正在使用的信息
			//c_ei: client {user email, doc id}; 客户端试图编辑某个文档的信息
			//found_c_ei: 发现正处于编辑状态的客户和文档信息; 
			//ssid: server socket id
			var found_c_ei = _.find( editingUsersInfo, function( s_ei, ssid ){ 
                //只有文档 id 一样并且同时 socket id 不一样时才算该文档正在被编辑;
                //如果文档 id 和 socket id 都各自一样,那么说明是正在使用的用户刷新了或者在重新读取了该文档
				if( s_ei.babyID === c_ei.babyID && (ssid != socket.id)){
					socket.emit( "isEditing", s_ei.email );		
                    if( editingUsersInfo.hasOwnProperty(socket.id) ){
                    delete editingUsersInfo[socket.id]; }//这里是去掉已有用户搜索其他文档时遇到已在使用的情况
                    return true;
				}
			});
			if( !found_c_ei ){ 
				editingUsersInfo[socket.id] = c_ei; //不在使用,保存起来
            }
		});
		socket.on("disconnect", function(){
			delete editingUsersInfo[socket.id]; //离线切断链接,删除保存的信息
		});
	});

辅助

为了防止有人打开文档之后忘记关闭或者退出,一直占用文档的情况发生,可以通过监测用户是否使用来设计倒计时,例如半小时没有任何鼠标键盘动作,那么就自动把用户登出。我使用的是这个 angular 包:http://hackedbychinese.github.io/ng-idle/

总结

很简单的实现。因为 Node.js 的单线程特性,我们也不用考虑多个用户同时访问数据库的情况。写过多线程程序的人应该知道这种 race condition 问题有多麻烦。

10 回复

单线程 跟 多个用户同时访问数据库 有什么关系?

@hezedu 在多线程的系统,每个用户可能是一个线程,如果都去check editingUsersInfo,虽然都是一个 docId,但是有可能都没有发现对方在使用,然后把自己保存在 editingUsersInfo 里。单线程的话,肯定有一先一后的顺序。

@russj node仿问数据库不都是异步吗?

设置资源锁是对的,我比较倾向于如果资源占用editing(user1),其他的users对于资源都是不可编辑的,直到user1释放资源。

我也是加了个锁定的旗子

只有一个进程,但是一旦要做大呢? 自豪地采用 CNodeJS ionic

@hezedu 异步的话返回也是有先后的,只要是一条线就没有问题。关键只要不是在做了判断后值被其他线程改了,不然就被坑。

@fengmk2 还没有做大。你有没有经验怎么做?比如有千万用户之类的

不错的悲观锁,的确多进程之后协调貌似还挺头疼的?

可以考虑用memcached或redis实现多进程间的资源竞争。

回到顶部