问题描述: 使用node开发了一个游戏服务器,为了尽可能提高服务器的性能,服务器采用多进程的架构,前面处理玩家socket连接的是多个node进程,使用 child_process 模块,服务器启动时fork出来,而处理玩家游戏逻辑的是单独一个node进程(因为玩家之间需要交互,而且玩家都是有状态的,所以无法分成多个进程)这个作为主进程;主进程与子进程之间使用使用child_process模块内建的通讯方式进行通讯;
现在服务器性能出现瓶颈,服务器同时在线去到1500人左右,CPU占用率在40+左右,目标是3000人同时在线;通过CPU Profile分析,唯一无状态的可分离出来而且比较占用CPU的,就是玩家数据读数据库和保存数据库的时候,数据库使用的是mongodb,所以现在想把读写数据库的逻辑独立到一个新的进程中;但是问题来了,由于玩家的数据是一个大的Json结构,最大的大小能达到600+K,而node的child_process模块的内建进程间通讯是通过JSON.stringify和JSON.parse来转换成字符串来进行通讯的,所
以独立出来以后,性能的问题,又指向了child_process模块中的进程之间的通讯的函数,通过实验: JSON.strinify 一个600K+玩家的数据平均需要35ms,JSON.parse 一个600K+玩家的数据平均需要20ms,而且这两个函数都是同步的,会造成阻塞;这也就是说主进程无法把一些包含大数据处理的任务分派到其他进程去做,这也直接限制了主进程的承载量和在线玩家人数;
尝试找过一写解决的方案: 1.异步版的 JSON.stringify 和 JSON.parse ,可惜没有结果,正如这里的讨论:https://github.com/joyent/node/issues/7543 2.为node加入多线程?node有几个多线程的库,但是貌似都不支持共享进程内存,无法操作主进程的内存,只能通过字符串进行交换数据,也就是说轮回到以上进程间的通讯问题;
这个问题一直比较困扰,还没有找到好的解决方法,希望大牛来赐教
每次都需要朝数据库读写这么大的数据么?我觉得你首先要做的是拆分数据,每次读写这么大的数据消耗当然大。 你数据库更新的频率是多少?每个人600k,1500个人要900M,3000人要1.8G,还要再乘以8换算成bits,感觉好大。 能贴下你们机器的参数么?感觉这个机器好牛逼的。
参考下传奇源代码吧,传奇也是独立进程读写数据库的,进程间使用socket通信,自定义通信协议,不要使用文本格式,数据更新频率和更新范围优化下,如果还不行的话,那就是计算瓶颈,你得换语言,换机器。
@Acceptedlc 缓存? 1.如果缓存在主进程,那么就会占用本进程内存,而且玩家数据比较大,这样会加大GC的压力,同时直接拉低了同时在线人数(node的heap最大去到1.7G,容不了多少个玩家的数据) 2.如果计划缓存在其他进程,那么就又会回到我上面的的问题(进程间的通讯瓶颈)
@leapon 横向扩展我们也是做了的,不过是针对没有状态的服务器,比如处理玩家长连接的连接服务器,我们就可以任意扩展他的数量 游戏逻辑服务器中的玩家,是有交互性的,(比如一个玩家要挑战另外一个玩家,却发现这个玩家不在这个进程里,这又会涉及到进程间通讯,也会回到我上面的那个问题中去,而且这样实时性低,对玩家体验不好),而且是有状态的,不像web服务器那么简单,可以很轻松就进行横向扩展;
@joesonw 现在就这么做啊,但是由于现在需要传输很大的数据, node进程之间的通讯,是使用了json parse 和 stringify来进行序列化成字符串来传输的,但是这两个函数是同步的,所以就出现了瓶颈嘛
@joesonw 关键是在丢给redis或者其他任何第三方进程前,都要先进行序列化操作吧(序列化成字符串,或者是 buffer, 网络上只能传输二进制数据), 所以现在就是瓶颈出现在这个序列化的操作上
@coordcn 拆分数据比较难,因为本来玩家就是一个整体的对象,你把他的数据进行了拆分,那么肯定不便于后续的开发; 数据库更新的频率是:玩家上线就会读取mongodb 加载玩家的数据;玩家下线或者是意外断线就把玩家的数据写到mongodb中去; 600K是最极端的情况,就是当一个玩家拥有游戏中的所有宠物和装备的情况,但是我们来计算上限肯定就是按照最差的情况去计算; 机器配置不高啊,就是8核 8G内存 非SSD
nodejs + mongodb, 那么最自然的就是用json来进行数据存储了,所以这个改变的可能性不高
“参考下传奇源代码吧,传奇也是独立进程读写数据库的” 现在我们也是想把读写数据库独立出来啊,问题就出现在玩家数据很大,序列化操作是同步的(childprocess模块底层代码决定的),瓶颈变成了进程间的通讯
那我是不是可以理解成最极端的情况下,你们的玩家600K数据会在进程间传来传去?
玩家数据肯定可以拆分的,你们数据库设计有问题,这个必须做拆分,装备,宠物全部都整到一起,还要在进程之间传来传去,这想想也醉了。难道我增加了一件装备,就要将整个人物数据都传一遍?这个也太奢侈了。
在数据设计上下点功夫吧,拆分是唯一的办法,不然就换语言,3000个人内存都不够。
“那我是不是可以理解成最极端的情况下,你们的玩家600K数据会在进程间传来传去?” 可以这么理解,整个流程是: 玩家上线就会读取mongodb 加载玩家的数据;玩家下线或者是意外断线就把玩家的数据写到mongodb中去;所以在这个过程中,玩家做的任何操作都是操作内存,如果进行拆分,那么玩家登陆要加载多次数据库,同时玩家下线,保存也要保存多次数据库,这样的话,当游戏逻辑服务器把玩家数据丢给数据库代理进程的时候,虽然每次传输的数据是小了,但是频率高了(因为被拆分了,需要传输多次),而且总的传输数据量是没有变的
非要一起传的话,用socket都没有用,这么大的数据,即便用自己的协议,解码也消耗蛮多的。
我个人建议,可以试着拆分下,看效果,然后取得平衡。玩家肯定有相同的数据,能压缩的压缩,能合并的合并,目标3000,搞到2000,尽力了就可以了。每种数据更新频率不同,这个要好好利用,原来那种一股脑儿更新的做法会把一些死数据也更新了,如果能根据实际更新数据来做更新,肯定比原理的方法节省。
v8已经很快,但必须要承认,这是跟其他脚本语言相比的快,当然还有更快的luajit。再快的脚本跟原生的比都要差一个数量级。node的优势在IO吞吐,计算密集型的,如果设计不好,很容易出瓶颈的。
说点自己的经验吧,我们也是TCP服务器,交互内存管理使用了redis(纯缓存),例如排行榜这种东西,落地使用的是mysql(连接池),网络包使用protobufjs正/反序列化(比json好不少,对于大数字压缩,量和效率都有优化,毕竟字节流),游戏是通服大区的,没有使用cluster,服务器间交互这块做了一个中央TCP服务器,自己封的RPC,用于各服务器间的数据交换以及全服的一些定时器刷新,例如:竞技场一周重置一次啊,以及做下通服数据redis到mysql的定时落地。 游戏服务器玩家内存这块,自己写的缓存管理器,使用引用计数方式管理。跑不了,上线全加载,在线期间定时刷,断线立即存储。之前实测的吞吐还是比楼主的要高些,主要测试的是1500玩家在线,定时刷回DB,我之前定得是5秒一次,内存没爆,CPU基本80+,但是相应其他操作倒是不卡的说,正式上线根据以往的经验,我们一般都是3分钟刷一次,所以我这样测试算是变相压力型测试,增大了他的IO压力。P.S. 我们玩家数据不如楼主这么多的了,但是也有几百K吧,具体没有算过。 关于楼主的问题,感觉以下几个方面可以进行优化,纯个人观点,有不对的地方,麻烦大家指出啊… 第一:楼主一次性进行较大IO操作从db加载到内存,可以考虑一次上线操作分多次数据查询拼接玩家数据对象,毕竟这样能够更好利用node本身tick的机制,你多次小IO,从响应层面,我认为应该优于前者,至少不会造成这么久的阻塞。这就是所谓的数据分离。(有问题请指出)。 第二:感觉使用cluster对于web服务器而言这种无状态的会很好,然游戏服务器这块我认为不是特别合适啊。有大量的内存操作,无法共享对于后续的开发会带来不少问题呢。除此之外就是楼主诟病的Json问题,实质上这步操作是多余的,理论上来看,我加载好了直接使用当然是最好,然后楼主还多了一步传输,虽然从架构上分离了这个负载,实质上由于数据IO这块的影响依然没有解决,这种不必要的数据交换,如果能够优化掉当然会好很多。我是不太建议使用cluster,特别是对于游戏服务器,他本身已经耗费了资源去传输他的socket对象,实质上效率有多高,我认为跟你自己写的网络通信来比,肯定是比不过。如果要使用,共享内存这块可以多参考redis,如此你的开发也会省事很多。 以上仅是个人观点,如果有不太好的地方楼主也自行参考。
兄弟,你们的游戏是什么类型的?感觉你们的服务器架构跟我们的很不一样啊。我们的是卡牌游戏,有很多个区的,你们的是全服大区,那么你们面临的挑战性感觉大好多啊。 “3分钟刷一次”是定时保存玩家数据的意思吗?如果是保存玩家数据,这时间会不会太短了些?这样服务器压力会很大,80+的CPU占用率是很恐怖的 “第一”点,其实我们是踩过坑的,如果登陆的时候涉及到多个异步操作的话,最好是压缩成一个异步操作,否则登陆的等待时间会更长,会一直出现卡在进入游戏的进度条哪里的,要等很久才能进去,这是我们的切身体会和经验。 “第二”点,我们只有对那些没有状态的服务器才使用cluster模式的,其他有状态的都无法使用,不然就会出现跨服跨进程通讯的情况,成本非常的高,所以现在的想法就是把有状态的服务器中的一些无状态的业务尽量地分离出去,比如处理玩家的socket连接已经被分离出去,还有现在这个读取和保存数据库的业务也想要分离出去一样。这样才能把有状态的服务器的计算能力不断提高,才能容纳更多的玩家。 “传输他的socket对象”只会在第一次发生,这个性能的损耗基本可以忽略不计; redis 可以实现共享内存,但是代价也很大,他的get set都是要先转成字符串先的,所以这个方法遇到的问题,跟我上面遇到的问题是一样的,都要先通过JSON.parse 和 JSON.stringify 来进行对象与字符串之间的转换。
游戏类型是城建+RPG,仿照的一款国外游戏,因此采用了大区的设计方法,然实质上也是通过内部“分区分服”的做法制作,只是交互这块达到全服交互,因为由于运营这块有风险,所以服务器之后也要支持分区分服,所以架构进行了如此设计。 恩 我们单服架构实质上主要参考的还是TrinityCore的写法来做,这个以前做过端游的童鞋不少应该都看过,楼主可以自行看下。 “3分钟刷一次”这个是以往的开发经验定得时间,80+是我们5秒存一次的结果,只是为了测试压力才设置的时间,实质上这个时间可以根据自己之后的压力进行调整。另外CPU即使达到80+,但是对于内存操作的响应还是没有多少延迟的。例如,我的一个移动操作,仅仅需要改变玩家内存的坐标,那么这个操作响应实质上也是非常快的。 另外跨进程通信确实有不少损伤,但是我们的跨进程通信都是交互业务,但凡交互业务在设计上都有限制,例如竞技场战斗,我们是全服打得,然而对于用户,都会有战斗次数限制,所以,即使这个有不少损伤,但是凡是可以量化的,都不会有问题。 “第一点”,这个问题主要讨论的是上线操作,理论上上线操作属于游戏业务中复杂度以及IO最高的操作,我这里提得数据分离一方面是查询做多次,另一方面如果你们这块数据量实在是非常大,就把这个操作往lazy模式制作,我不知道你们业务结构的复杂度,是否导致你们业务关联度比较高,如果能够拆分,就做懒加载,用到的时候再加载,这样写起来确实蛋疼,但是能够分摊掉你们上线的卡得问题。 “第二点”,这个我比较疑惑不知道你们现在这块为什么要做如此多的分离,首先,玩家在线期间,所有数据CRUD基本都是操作内存,这一块,nodejs的响应和原生c++的响应相差并不多,具体你可以看深入浅出这书上的一些对比。唯一能够大幅度影响nodejs本身性能的就是你有大量的阻塞操作、或者代码块中有较高次数的IO循环,除此之外我暂时没有想到其他。另外,你们如果做了这样的分离,势必对之后编码的复杂度以及设计有提升,写起来我感觉会比较麻烦,无法无脑的编写,不过这个也考验你们主程的设计能力,node本身作为游戏服务器这块是起步阶段,如果设计的过于复杂,日后招人维护都是个问题,所以架构可以设计的复杂,但是在业务的书写这块,最好是无脑方式。 “socket传输”,这块我确实没有过深入研究,句柄共享应该只会有一次,但是不知道之后的数据流通信是否有损失,这个我不知道。 “redis”,他的使用在乎的不是效率代价,使用他是去解决node本身内存瓶颈,1.4G,另外引用redis官网的那句话,当你发现缓存出现效率问题的时候,永远不要质疑redis,因为我们的应用级别是无法让他出现handle不了的时候的。
1.TrinityCore才发现还有这样的游戏服务器框架,可惜C++忘却得差不多了… 2."3分钟刷一次",为什么要保存得那么频繁,我们之前定的是20分钟就保存所有在线玩家一次,但是发现其实没什么用,因为node服务器没有试过挂掉,因为我们在最后捕获了所有的异常,估计唯一出现无法保存的就是断电之类的小概率事件了,由于这个定时保存玩家数据,会给服务器带来一定的压力,所以之后我们索性直接取消了这个定时任务; 3.跨进程通信,我们准备做的跨服战也是用到这个的,还是设计到跨进程传输玩家数据就会遇到我上面那个问题,真是头痛;按你说的,虽然是每个玩家的次数可以限制,但是在线玩家数量你总没有限制吧?所以这个随着在线人数的增加,这终究会是一个问题; 4.“这个我比较疑惑不知道你们现在这块为什么要做如此多的分离”一个服务器的处理能力是有限的对吧?如何扩展这个服务器的承载能力?那么只能是把这个服务器的一些无状态的计算分离到别的进程去,比如如果这个服务器其中一个业务逻辑是计算 “斐波那契数列”,因为它是没有状态的,那么我们就可以很容易可以把这个业务逻辑分离到另外的进程去了,我们现在做的就是这样的工作,比如数据库读取和存储,都是没有状态的,你塞一个玩家的对象给他,那么他就去存储就是了;你给一个玩家ID给他,那么他就直接去数据库读取这个玩家的数据,读取完毕,然后把这个玩家的数据返回来就是了,这些都是没有状态的,这部分的工作,完全是可以分离出去,那么分离出去后,原来的服务器的工作量就轻了,那么承载就肯定是上去了;
“socket传输”,socket这个只会在connect的时候才会出现多个进程争夺同一个socket的情况,当被某一个进程拿到了这个socket之后,数据传输是完全没有损耗的。 “redis”关于这个,我们暂时没有出现1.4G瓶颈的问题,因为我们玩家下线后,就马上清掉他的缓存了,所以只要内存没有泄露,这个完全没有问题。但是关键是,如果我们使用redis,那么我们现在是在存储到redis前,进行 redisClient.set(xxxxx, playerData)操作钱,转换这个playerData为字符串时有性能问题(playerData这个对象太大了);