最近,我们的合作方 陌陌 带了他们的一个 CP 到我们公司咨询一下 skynet 做 mmo 游戏项目中遇到的一些问题。因为他们即将上线一款 MMO ,在压力测试环节暴露了许多问题。虽然经过我们的分析,有很多问题出在他们的压力测试程序本身编写的 bug ,但同时也暴露出服务器的设计问题。
核心问题是,他们在实现 mmo 服务器时,虽然使用了 skynet 框架,但却把所有的业务逻辑都放在了同一个 lua 服务中,也就是一切都运行在一个 lua states 里。这样,几乎就没能利用上 skynet 原本想提供的东西。压力是一定存在的。
我花了一下午探讨了应该如何设计一个 MMO 的服务器。下面记录一下:
业务的拆分:
我认为业务拆分的原则是数据的交互频率。如果数据相互独立,那么就应该用独立的服务去处理;如果交互密切,就不要拆分它们。尽量不让一个服务承担太多热数据的处理,而冷数据或不需要高频响应的数据处理则可以堆积在一起。
例如:在 MMO 游戏中,玩家身上的背包管理,基本上只和玩家相关,那么处理背包的逻辑就可以放在玩家的 agent 中处理。即使背包中的物品的操作会影响到其他人或场景,也应该由 agent 和外部交互。
对于拍卖行、玩家摆摊等这些,提取出来放在一个或几个交易服务中去完成是没有问题的,但应该避免和场景服务合在一起。虽然拍卖行和摆摊的入口看起来放在场景里,但那仅仅是外在表现,玩家从客户端发出的交易指令,由 agent 转发即可。
聊天绝对应该被独立出来。虽然 MMO 中聊天很可能也是种玩法,发言同样需要消耗玩家的数值,表面上看起来并非独立系统,但切分出来不会太难。
而队伍系统可以被看成是一个特殊的聊天频道,这样划分可以把组队的行为和场景分离开,方便把场景独立成单独的服务。
任务和活动是绝对要独立的系统。按现在国产游戏的秉性,这些是游戏上线后维护的大头。我们必须把变化做多的部分独立开来做,不是吗?而且从数据上讲,这部分的数据日后会占到玩家的个人数据的很大一部分,只要我们把这块数据分离,也可以极大减轻单个服务对数据的维持负担。
关于游戏场景以及副本,通常大家都是把每单个场景分开的。(可惜,这次来咨询的同学没有这么做,他们游戏所有的场景都在一个服务中。)我的建议是,玩家可能和其他人交互的数据都应该放在场景服务中,比如他们的位置、HP 、buff 等等。在做战斗计算时,不必去做服务间的消息传递就可以计算出结果。但个人的物品、任务、活动信息则绝对不要加载到场景中。
最容易被忽视却非常重要的是登录认证。从实现角度看,比较简单的方法是做成一个认证模块,由每个 agent 加载去认证新连接。但实践表明,这样做有一定风险,很可能无法满足开服初期的大量玩家的同时拥入。目前的建议方案是:单独编写认证服务,把认证服务做成无状态的,这样可以启动多份以充分利用 cpu 。当新连接认证通过后,再给它分配 agent 服务对接。通常还需要创建一个 agent 池,预分配几百或几千 agent 备用,而不是临时创建。
关于状态同步:
对于回合制游戏,周围玩家的状态同步并不需要特别的实时。我们并不需要将周围所有的人都同步给他。尤其是在场景拥挤时,只需要看个热闹就够了,应该设置一个上限。而且周围能看到的人数也不必和每次需要同步的人数相同。简单说就是,你周围可能有 100 个人,但你在屏幕上只看到 50 个就够了,而这 50 人中,可能只需要将 20 人的状态变化会实时同步给你。
尤其是在移动网络中,针对客户端的接收和处理能力去调节同步信息的频率和数量是非常重要的。
最简单的处理方法是:由客户端主动来索取当前场景中周围玩家的状态变化。如果周围状态没有发生改变,服务器就不要回应,直到有新的状态改变信息需要推送。而客户端在没有收到回应前,禁止提出下一轮的请求。也就是网络上传输的状态传输信息永远只有一组(根据需要可以将限制调节成多组)。这样,当客户端网络缓慢,或同步信息量大时,自然就降低了同步频率。反之,则可以高频同步。
数据服务一般都会设计成独立的,负责和外部数据库打交道。
但最好不要按玩家为单位划分数据,当需要一个玩家的数据时,一古脑把玩家的装备物品,属性值,身上的活动任务全部扔给业务处理者。应该一开始就设计成几个部分,每个不同的业务处理服务去索取他们关心的部分即可。
这样,可以极大降低业务服务和数据服务的数据交换的负担。
就最后一个问题,这几天我做了一些思考。对于不同的 lua state 间交换数据,目前通用的做法是对 lua table 做序列化,然后传递序列化串,接收方再做反序列化。
在同一进程内,可以对序列化串的传输这点上做一点优化,减少进出 lua state 的复制。但有没有方法可以直接读写其它 lua state 中 table 呢?
如果能满足几个条件,那么看起来是可以的。如果有一张 table 希望被其它 lua state 读取时,在读取过程中保证不去修改它,那么读取过程就是安全的。而这一点,在 skynet 中很容易保证。
我花了一点时间写了一个用于 lua 5.3 的库。它用到了 lua 内部的 api 来实现 table 的遍历。稍加修改,应该可以用于 lua 5.1/5.2 。
https://github.com/cloudwu/tablepointer
你可以用 tablepointer.topointer 函数取到 table 的 C 对象指针(其实就是调用的 lua_topointer
),然后可以把这个指针传递给读取方。
读取方可以用 tablepointer.pairs 来遍历这个指针。和 pairs 遍历表不同,tablepointer.pairs 返回三个值而不是两个。这是因为跨越了 lua vm 后,用 next key 的方式索取下个 key-value 对是非常低效的;所以 tablepointer.pairs 迭代出的第一个值是一个数字索引号,正值表示 table 中的 array part 部分,负值表示 hash part 部分。一般在使用的时候忽略第一个索引号即可。
tablepointer.pairs 不支持 __pairs
元方法,也不支持 key value 中有 function 或 userdata 类型(这些是很难跨 lua vm 使用的)。当源 key value 为 table 类型时,迭代出来会返回一个 lightuserdata 指针,可以继续用 tablepointer.pairs 迭代它。
tablepointer.createtable 可以帮助你(通过 lua_createtable
) 创建一个和源 table 指针相同大小的 table ,这可以避免在复制表的期间做无谓的 rehash 。
如果在 skynet 中使用,我能想到的最直接的用法是在场景服务中定期将玩家数据传输给数据服务。这样可以减少这个过程中序列化带来的延迟。
你需要做一点简单的封装。正如 sharemap 做的那样,你可以给玩家数据 table 加一个元表,把 index 和 newindex 都定向到另一个 table 。必须主动调用一个 commit 方法才将两张表合并。然后,在打算把数据发送走时,先用 skynet.fork 产生一个新的协程,用 tablepointer.topointer 转换成指针发送。等数据服务接收到,遍历复制完毕后再回应它。接下来,再用 commit 方法把这段时间对数据的修改合并到新的表中。