by Daly
网游服务器程序优化要解决的最主要矛盾无非就是在保证流畅游戏体验(响应时间在可接受范围)的前提下,容纳更多的玩家,当然还要保证开发的便捷性。一个靠谱的MMOG游戏服务器基本上都是多线程或多进程的架构, 利用多个CPU核把串行处理变成并行处理,以容纳更大的并发玩家规模。
然而并行处理程序会使开发的复杂度增加,一不小心很容易出一些诡异bug。为什么这样说呢?实际环境的大部分程序,函数的执行结果与状态数据相关(外部状态,全局数据),并且函数执行可能会改变这些状态。如果把处理模块拆成多进程,进程间的这些状态数据的一致性和处理时序,会影响到结果的正确性。多进程状态数据的管理,读写和同步更新机制,便是本文要探讨的主要问题。
如果函数能变成无状态的(结果只与输入参数相关),则分拆成多进程毫无压力。于是业界开始探讨erlang这种函数式编程语言,并有已有实际游戏项目(参看:http://www.qingliangcn.com/) 。不过笔者觉得,erlang的无状态,本质上是把状态数据通过函数参数传递,这样意味着频繁而大量的数据复制和传递,是否更适合于MMORPG开发很难说,本文不予讨论,可见文章末尾参考资料。下面探讨一下状态数据在多进程之间的问题。
为了容易描述,整个架构如下图
G
client <--->║ <------> A
║ <------> B
其中G表示接入网关,负责把client协议分发到内网对应处理进程,A,B是负责不同功能的处理进程,client表示客户端,玩家状态数据只有个v和w两个。用reqA,reqB分别表示client对A, B的处理请求,respA, respB表示A,B返回给client的处理结果。
游戏逻辑大部分情况下需要保证状态数据的强一致性,基于过期的数据进行处理会得到错误的结果(分布式数据一致性的工程问题见文末的参考资料)。举个有点蹩脚的例子,假设client先后发出reqA, reqB两个请求,reqA是换武器,reqB是发起攻击,变量v是攻击输出量(dps)。reqB在reqA之后发出,攻击理应是按穿上武器后的dps数值来计算的。但多进程情况下,却有可能reqB先于reqA处理(比如A进程很忙),这时reqB的逻辑会基于还没穿上装备时的变量v来计算结果。下面分别讨论几种解决数据一致性问题的方案。
模式一:共享内存
适合于单机多进程或多线程的模式。
优点:数据只有一份,可以保证强一致性。
缺点:进程无法扩展到多台服务器;
需要加锁,加锁相当于把处理串行化,还是有可能被某一个较忙的进程卡住。如果精心设计和划分数据,减少锁的粒度可以提高性能,但细粒度的锁(设计成类似MySQL的行级锁),在涉及多个玩家数据的交互逻辑时,稍有不慎又容易导致死锁。随手写一个:
假设进程A和B同样执行以下类似的逻辑
foreach( user in mapA) {
lock(user);
lock(user‘s friend);
do_something();
unlock(user's friend);
unlock(user_id);
}
由于遍历的是map, 进程A和B中的user顺序有可能交叉, 假设交叉的两个user互为friend,就可能死锁了。
参考资料[4]采用了这种模式的方案。
模式二:状态数据只由一个进程管理
把状态数据根据游戏逻辑进行划分,比如变量v只由A读写, 变量w只由B读写。假如A逻辑需要用到w,则通过异步请求B获取w。
优点:保证强一致性;数据只有一份,无需进程间复制更新。
缺点:异步请求增加了响应时间(嗯,又从并行变成了串行); 异步写起来的代码有点ugly,到处是callback, 回来要检查上下文,不然又是诡异bug.
适用范围:如果状态数据能比较好的划分(即绝大多数情况下,某个数据只会在某个进程的逻辑中用到),用这种方案比较适合,因为简单。比如玩家位置只由AOI进程管理,玩家好友由聊天进程管理。
模式三:多个writer, 类似MVCC方案
这是完全的分布式设计。每个进程有自己版本的状态数据,进程间可互相同步更新, 状态数据v分别在A,B都有一份。互相update时,根据版本信息进行merge。
这种方案不能保证强一致性,而且merge时会有可能发生冲突,需要逻辑开发者仲裁这种冲突(比如按时间先后)。不同于互联网应用,游戏需要较强的数据一致性和实时性,这种方案比较复杂且不太可控。
模式四:Master-Slave模式
这个是对模式二的一个扩展,某个状态数据还是只由一个进程进行写操作,但其他进程会维持一份cache进行读操作,比如变量v由进程A管理,v的更新会同步到进程B,进程B逻辑如果要用到v,直接读自己的cache就可以了。对于变量v
特点:这种方式也是不能保证强一致性,只能保证最终一致性。作为模式二的补充,有些数据不需要保证更新时序,根据过期数据进行处理也可以接受(这个是代价,需要权衡玩家体验),可以采取这种方式。而对于不能接受的,走模式二。某些需求reqA,reqB虽然先后发出,如果respA还没反馈回来的话,即使逻辑上reqB先于reqA处理,在玩家体验上也是可以接受的。比如reqA穿装备, 然后reqB攻击,但是respA还没返回,客户端还是看作是没穿上装备,这时候按照老的属性计算攻击值是可接受的。广域网几百毫秒的延迟,reqB要晚于reqA + respA这种概率很小了,如果真的发生,服务器已经很卡了。
又比如聊天进程,reqA离开场景,然后reqB发聊天消息往当前场景频道,需要知道当前场景的玩家列表(假设场景玩家列表在AOI进程管理),如果reqB先到达聊天进程,拿到旧的场景玩家列表, 那么这个广播就不准确了。这种不一致性的代价可以忍受的话就没问题(在这个聊天栏例子,在跳场景的瞬间发错人了也可以忍),实际情况,进程间通信几个毫秒,发生这种处理时序反转的几率其实非常小了。
综上,如果要设计多进程结构,个人比较推崇模式四。这时又引申出几个问题:状态数据如何合理划分?何时更新?同步给谁?
如何划分?
有些功能很好划分。比如聊天进程,状态数据只与好友列表有关,这个需求可以忍受过期数据,好友关系由主进程修改,同步到聊天进程。玩家position, 由AOI进程管理,修改同步到主进程,主进程几乎没有需要用到position的逻辑。
但有些数据就可能很纠结,比如背包数据。玩家交易,在线奖励,战斗都需要修改背包物品数据,而且必须保证强一致性,否则就可能出现丢失或物品复制,该由谁做这个数据的管理者呢?如果AOI进程管理,物品使用效果可以马上生效,但是交易和在线奖励也需要验证背包物品,这些逻辑也放到AOI进程么,如果放,则又牵扯出更多的变量,如果不放,则需要退化成模式2的异步请求。如果放主进程,则使用物品后产生的效果不能立刻同步到AOI进程。可以经过仔细对比,AOI与背包数据交互的频率远高于主进程,于是背包数据可由AOI进程管理。
何时更新?
两种选择:一有修改立马发送更新给其他进程;队列buffer住所有更新,定时送出去(比如每2秒同步一次);既然是无法保证强一致性,后者性能容易优化些。比如AOI进程中的位置信息变化很频繁,但主进程对位置实时性不敏感(比如只用于持久化,掉线重上后的位置恢复),则更新间隔可以长一些,否则会有频繁而大量的位置数据更新;定时更新也利于同步间隔内数据修改的合并,减少同步量。
同步给谁?
某类数据有修改时,需要通知哪些进程,意味着要维持一个映射表。可以在编码阶段,在数据定义时静态写死某类数据要通知哪一类功能进程; 也可以在运行期设计成pub-sub模式(或者叫observer模式), 动态增删订阅者。笔者觉得前者可控一点,因为进程要用到哪些数据,在编码阶段是可以清楚规划的,根据这个原则把数据划分成一个个模块,比如玩家数据分为基本角色属性,avatar, 位置/朝向, 好友数据.... 然后决定归属。
多进程可以提升系统并发规模,但同时有各种异步调用和数据一致性问题,带来的代价就是bug的风险增加(尤其团队水平不能保证个个都很高的情况下,一个菜鸟程序员就够受了,还很难跟踪),开发难度增大。这个需要仔细profile和实验确定瓶颈在哪,真的跑满CPU或者卡IO才有必要分出去,想当然的把模块拆分很多进程,设计看上去很优雅也很牛逼,往往是麻烦的开始 ——> 开发效率降低,出bug意味着啥?加班,加班,深夜运维的夺命追魂call... ...
参考资料