如何只基于请求回应模式实现 MMO 级别的场景服务
在上一篇 blog 里,我谈到游戏服务器其实只需要使用 req/resp 模式就够了。有同学表示不太理解,认为服务器主动推送,或者说 pub/sub 的消息模式必不可少。
在聊天中我解释了很多,是时候记录一下了。
从本质上来说,如果你只是想把一系列消息发送到客户端,req/resp 请求回应模式和 pub/sub 发布订阅模式并没有什么不同。你可以把 req/resp 理解成一次性的,只订阅一条(或有限条)消息;如果需要维持这类消息的推送,需要客户端在收到消息后再次发起订阅。
这和 timer 定时器系统一样,订阅一个定期触发的定时器这种功能并非必要。如果你需要一个每分钟触发一次的定时器,完全可以在触发一次定时操作后,立刻发起下一次定时任务请求。它在功能上是等价的。
潜在的实时性影响是:如果规定必须在收到回应消息后,才允许发起下一次请求;那么一个事件发生后推送到客户端的延迟可能增加了最多 2 倍。即,如果你刚刚回应了一条消息,这个时候又发生了新的事件;客户端需要在收到前一个事件后再提起新请求,然后你才可以把后发生的事件通过新请求的回应包发过去。
降低这个实时性影响的方法也很简单,协议上允许在客户端发起请求后未收到回应之前再次发起请求即可。对应同类消息许多少并发的请求,要根据这类消息的实时性要求有多高来决定。无限提高允许的并发量没有太大意义,毕竟物理上的延迟不可能完全消除。
从客户端来看,MMO 这样的游戏,客户端仅仅是一个呈现设备,它不断的接收到服务器发过来的游戏世界的状态变化,而做出表现。客户端主要靠回应包来分发业务逻辑,使用请求回应模式,无非是根据回应包里的 session 号匹配之前请求包(通常包的类型在请求包中,不必放在回应包数据里),将两者(请求/回应)合并起来调用消息分发函数而已。
既然效果一样,请求回应模式有更大的数据冗余(一些看起来没有意义的请求包,在订阅模式中,你可能只需要订阅一次)那么请求回应模式带来的好处是什么呢?
它天然可以解决过载问题。如果服务器不考虑客户端的带宽/硬件处理能力而推送数据,客户端很可能无法及时处理,而无谓的浪费了双方的带宽。而请求/回应模式中,如果我还没处理完,就不会要更多,很自然的回避了问题。
有选择性的发送消息这件事,对服务器来说,要么是一个时间复杂度为 O(n2) 的操作:你需要对每个用户遍历对比其他用户来决定这条消息对方是否关心;要么,会把数据结构的复杂度提高一个数量级:你需要把对象进行各种分类、索引,以提高检索的效率。而如果把选择我关心的消息的权利放在客户端,那么时间复杂度就只有 O(n) 了。客户端总能更了解自己的情况。我是 iphone6s ,所以我可以关心我周围 50 个人的变化;你用个两年前的红米,屏蔽了周围所有人,服务器就不要把我旁边的人跳了个舞的事件发给我了。
消息优先级更好处理。我正在战斗,那些在世界聊天频道对撕的消息晚点给我,不要耽误了我的对手正在搓火球这种我更关心的事件。
无论是客户端还是服务器,只要想清楚,业务逻辑可以写得更清晰。怎么做呢,下面展开讲。
在 MMO 的场景服务中,只使用请求回应模式该怎么做?
首先把场景中的玩家、NPC 等都抽象成一样的对象。同时将场景打上合适大小网格(比如一格 100 米为边长,这取决于你允许的玩家视野),将每个格子也看成一个对象。
每个对象都带有一个(或多个)事件队列。
对于格子对象来说,事件只记录有别的对象来到这个格子,以及一个对象离开格子。
玩家对象的事件队列则记录这个对象自己做了什么动作,收到了什么伤害,增加或减少了什么 buf 等等。
你的场景应该是以某个心跳周期(通常不会超过 10Hz)来工作的,也就是游戏服务器上,所有对象的变化都是离散的。
所有的攻击、移动、技能都会以事件的方式,加上时间戳放到所属对象的事件队列中。
客户端的查询协议中的来说只有两种:
查询一个对象的当前状态以及该对象最新事件的时间戳。
查询一个对象从某个时间戳开始,以后发生的所有事件列表。
如果客户端还不了解一个对象时,它发起查询 1 ;如果已经了解了这个对象,发起查询 2 。或者根据需要发起查询 1 (例如曾经查询过,但很久都不关心了)
在查询没有回应前,限制再次提起查询的次数。
这样总能保持对某个对象最新状态的了解。
当一个玩家进入场景,他要做的:
查询进入点。
根据进入点坐标,查询所在格和周围格的发起查询。
根据周围格的查询,获知了周围存在的对象。然后分别对这些对象做进一步查询。作为 MMO 可能存在大量的对象,通常筛选一部分就够了。
一旦感兴趣的对象走远,就不再监视它;这点不必依赖格子对象的状态变化。所以格子的状态反馈只需要有新增对象即可,而不必有对象离开的消息。所谓监视一个对象,指在每次查询回应后,再次提起下一次查询。有些对象,比如自己,需要响应更及时。应该同时向服务器发起多个查询请求,这样,服务器一旦判定你被攻击或被某些事件改变,总能有可用的回应 session 供发送回应包。同理,如果你在和人/ NPC 战斗,那么对手也是重点关注对象。而周围人跑来跑去、做了个动作,可能你就不那么关心了。这个可以由客户端来控制哪些该保持追踪、哪些暂时不必理会。
最后关注自己(在场景中那个对象)。这样如果有人攻击你,你就能知道这个事件的发生。在攻击发生后,还可以立刻去关注打你的这个人(如果之前没有关注的话)。
可以做,且容易的做的优化有:
事件合并。当用户请求某个时间戳之后的所有事件时,有些事件是可以合并的,比如多次移动可以合并成一个;格子里一个对象进进出出,可以合并掉,并只回应新增事件。
回应包缓存。把事件列表序列化为网络包有一定的开销。有些信息肯定会有很多玩家来索要,你可以只做一次序列化工作,把数据缓存起来,等多个人来请求时,直接回应给他,省去了将信息反复序列化的流程。
多查询合并。查询多个格子的时候,可以合并在同一个请求中,这样,对象在临近格子间的移动消息可能就被合并掉了。
从协议层,可以允许一次提起对很多对象的查询请求,它们的查询 session 一定是连续的,所以不必逐个编码而只需要给个区间就够了;同时也不妨碍单独回应。在上面的查询约定中,如果发现时间戳之后到当下,对象事件列表中若没有事件,服务器是不回应的。
例如,如果你来到一个无人区,对所在格做了一次查询,除了你自己外,没有其它对象;你若再次查询,结果不会有变化,所以服务器会挂起你的请求,直到附近有新对象出现。
由于请求和回应是一一对应的,所以不用担心比单纯的数据推送(订阅模式)多出太多不必要的流量。
Comments
Posted by: 任正非 | (17) May 16, 2017 04:33 PM
Posted by: haiping | (16) March 31, 2017 10:57 AM
Posted by: 雷军 | (15) August 23, 2016 01:53 PM
Posted by: 传说 | (14) July 20, 2016 08:02 AM
Posted by: 黄金 | (13) July 12, 2016 11:08 AM
Posted by: rj | (12) July 11, 2016 01:05 AM
Posted by: rj | (11) July 11, 2016 01:01 AM
Posted by: 誠私 | (10) July 8, 2016 09:39 PM
Posted by: liyonghelpme | (9) July 7, 2016 09:28 PM
Posted by: dalton | (8) July 7, 2016 02:06 PM
Posted by: 嘿嘿嘿嘿 | (7) July 6, 2016 11:47 AM
Posted by: lichking | (6) July 5, 2016 04:31 PM
Posted by: cloud | (5) July 3, 2016 11:21 AM
Posted by: 杨博 | (4) July 3, 2016 01:12 AM
Posted by: magicsea | (3) July 1, 2016 08:24 PM
Posted by: ljy2010a | (2) July 1, 2016 08:19 PM
Posted by: jones | (1) July 1, 2016 06:01 PM