一种基于长连接的社交游戏服务器程序构架
这里讨论社区游戏服务器程序的设计与实现思路。
情景
在社区游戏开发中,有一类游戏具有实时互动的特点,这样就需要有实时同步功能的服务器,我们这里叫它实时web游戏服务器。这类程序通常需要完成用户登录,为用户分配逻辑服务器,处理用户游戏逻辑,后台数据处理等业务。这里要讨论的服务程序与经典的网游服务器程序类似,但是又有不同于网游服务器的地方。首先它需要处理业务逻辑要简单的多,实时web游戏的客户端通常是以flash方式运行的用户的浏览器中的,这类其游戏逻辑通常比较简单,与服务器之间的交互命令种类也比较少,频率也比较低。其次,由于社区游戏的用户量很大,通常比网络游戏要大很多,所以对服务器抗负载、稳定性等要求比较高。第三,由于社区游戏的付费比例比较低,所以需要尽可能的节约计算资源以控制成本。 基于上面对于这类服务器特点的描述,我们在经典网络游戏服务器的基础之上进行适当的修改,从而满足现实的需求。
用户登录
在用户登录之前需要将用户连接到一个网关服务器上(gateway server),对于大规模的服务器集群,gateway往往有很多台,被分配了不同的IP地址。那么如何将诸多的客户端程序均匀的分布在这些gateway上呢?这里可以使用一个负载均衡服务器,所有的客户端在登录之前都统一访问该服务器,该服务器仅提供一个echo服务,返回内容是一个gateway server的IP地址。当然具体选择哪个gateway server则是根据各个gateway server的负载情况尽量实现负载均匀分配。具体的负载均衡算法有很多,比如可以采用DNS轮询的方式,或者根据当前gateway的负载量分配到连接数比较少的gateway上。这里我们采用后者,而具体gateway上的连接数则是通过Master Server提供的,关于这个服务器我们将在后文介绍。 之后,用户就可以连接到一个gateway server上了。连接上之后,按照约定,client应该首先向gateway发送登录命令,gateway解析该数据包,再封装成供内网使用的数据包发送给验证服务器(Account Server)。登录数据包中的信息包括用户ID(uid)和session key,Account接收到来自gateway的数据包后,首先是检查该uid是否已经登录(Account中将维护一个登录用户的列表),如果是重登录则果断返回登录失败。然后在本地的memcache中寻找该Uid,并判断session key是否成功匹配。如果成功则向该gateway返回用户登录成功信息,如果没有成功匹配则返回用户登录失败信息。本地Memcache中的uid-session key数据信息是用户登录社区页面是从相应的平台获取来的(如用户通过facebook访问游戏时,该信息就会被写入),进行这项验证是为了防止用户绕过社区平台而直接访问游戏,同时也可以避免用户的重登录。虽然这对于防止外挂程序来说有一定的意义,这道屏障还是显得太脆弱了……关于Account服务器存在的必要性我这里不想做多讨论,这类问题的争论总是发生。 聪明的你看到这里也许会问,对于整个服务集群来说,Account是一个单点,那它会不会成为系统的瓶颈呢?我的答案是理论上存在这种可能,但实际上不会。因为相对于集群内其它服务器来说Account仅做了很少的工作,它的理论上限值应该是一个很大的值,而实际的用户数量很难达到这个值。这里我无法给出精确的实验数据,所以上面的说法看似并没有什么说服力。当然,如果你想彻底的消除这个集群中的单点的话,我在后文会提供相应的解决方案。
进入游戏
用户成功登录之后就可以进入游戏了。Client首先发送enter命令给gateway,该命令中包含一个场景id(sceneid)表示用户希望进入哪个场景开始游戏,gateway将该命令发送给Proxy群组的Master服务器(这里我们加它Proxy Master)并保存该信息(uid-proxymasterid),Proxy按照负载均衡的原则将该数据包再发送给proxy并将该用户的信息(uid-proxyid)保存在一个hashmap当中,proxy接收到该数据包之后,解析出该用户的uid然后同样以负载均衡为原则为该用户分配gamelogic服务器并将enter数据包发送给该gamelogic server。之后,proxy需要将用户的信息(uid-gamelogicid)存入hashmap当中。gamelogic server接收到数据包之后,检查该用户是否已经进入游戏(gamelogic server中会维护一个进入游戏的用户信息表uid-string),如果用户已经在游戏中则不对该数据包进行处理。如果用户尚未进入游戏,则gamelogic将该数据包传递给python脚本程序。python将对该用户进行一系列的初始化操作,之后,将进入游戏结果数据包返回给gamelogic server。gamelogic server将此数据包返回给proxy,proxy再根据uid-gatewayid数据表找到该用户的gatewayid,将数据包直接转发给gateway。之后,如果是进入游戏成功proxy还需要将以uid为key,以gamelogicid,gatewayid,sceneid等为value的信息写入memcache。gateway接收到数据包之后判断用户进入游戏是否成功,如果成功则将该数据包组成外网通信协议,然后发送出去。如果没有成功则果断切断连接。 这里分别在proxy master, proxy, gamelogic上设计hashmap保存用户被分配服务器的路径信息是为了保证进入同一个场景的用户被分配到同一个proxy上,每个用户在一个session中只能被分配到唯一的一个gamelogic server上。
开始游戏
用户在成功进入游戏之后,就可以开始发送游戏数据包与gamelogic server进行通信了。具体流程仍然是Client-Gateway-Proxy-Gamelogic-Python。Python返回的数据包包括两种,一种是单播数据包,即把该数据包回发给该Client;另外一种是广播数据包,即把该数据包回发给Proxy,Proxy上会存储一个Sceneid-uid的set,Proxy将根据该set对同一个场景中的所有用户进行广播服务即将广播数据包改装成单播数据包,然后分别发送给不同user所在的不同gateway。 在游戏的过程中,用户可能会存在切换场景的操作,执行该动作的过程如下:用户首先发送进入场景数据包(即选择要切换的目标场景),然后将按照进入游戏的过程为该用户分配新的Proxy以及Gamelogic,当新的proxy接收到gamelogic的返回的消息之后,如果进入场景成功,则proxy会先从memcache查找该用户之前是否已经进入了别的场景(在进入成功后,用户的信息会被proxy写入memcache)。在切换场景的情况下,显然用户已经进入了其它场景,此时proxy会通知gateway这次进入新场景成功了,你需要告诉之前的proxy退出该用户。然后旧的proxy会自动的删除用户的信息,并通知原来的gamelogic删除这些信息。如果用户执行的是切换proxy群组的操作,则还需要通知proxymaster更新信息。 在整个集群中,memcache是一个全局的数据区,所以proxy对其进行操作特别要注意进程同步的问题。
结束游戏
当用户退出游戏时,gateway会发送给logout数据包给proxymaster,proxymaster将其发送给proxy并删除相关数据,proxy将给数据包再转发给gamelogic并删除相关数据,最后gamelogic将数据包发送给python,python会执行一些和游戏相关的用户退出操作(如flush操作等),至此用户退出结束。
集群监控
我们设计了一个master server对于整个集群进行监控。master在这个集群中是唯一的,它主要负责对于整个集群的管理工作。在gateway, proxy, proxymaster启动时,首要的任务是连接到master server,如果不能连接到master则该服务将不会被视为集群的一部分。例如,一个proxy启动它需要首先同master进行连接,然后成功注册之后再试图连接gateway,proxymaster。proxy连接到proxymaster之后,gateway会首先询问master该proxy是否已经注册,如果没有则果断发送kill指令给该proxy并关闭连接。 master将在全网广播心跳数据包,收到该数据包的服务器需要进行回响,回响数据包中将包括每个服务器进程当前的状态信息。如果master发现某一服务器在发送心跳服务一段时间之后没有任何反应则认为该进程已经沦落为僵尸,写log日志,并果断发送kill命令杀掉该僵尸。 管理员可以通过master client与master服务器相连,负责监测各进程的状态。
实现问题
服务器模块设计 上面说完了整体集群的工作原理,下面来分别说说一些实现上的问题。服务器可以采用经典的服务策略,逻辑处理与网络模块分开,由一个线程负责监听,当事件发生后建立新连接,然后由另外一个线程处理连接服务(包括异步读写socket)。当读数据结束后,网络模块将该数据递交逻辑模块处理,该逻辑模块由一个单独的线程运行,保证时序的正确性和代码的简洁。
网络连接需要设计具有断开重连功能的网络连接,这样进程的启动顺序将不会受到影响。关于gateway端网络连接的设置可以采用leader-follower模式,用多线程轮转提供异步读写服务。
心跳模块
心跳模块是在整个服务器应用中一个常用的模块。在gateway上,每个服务器进程会负载上万个连接,如果出现某个client与server建立连接但却不发送任何数据,那么这个连接就会白白的浪费计算资源,这时就需要心跳模块把那些长时间不发送数据的client找出来杀掉。又如前面介绍的master需要定期的向集群内的各个服务器进程发送心跳数据包,并将没有应答的进程杀掉,同样也可以采用心跳的方式实现。再有就是在gamelogic服务器进程运行的过程中,可能会出现一些用户的数据长时间没有回响的情况,这可能是由于python的某些同步操作(连接数据库等)发生了阻塞,从而导致后续数据包的堆积,这可能会导致一批用户无法继续进行游戏。对于这种情况,也可以对用户响应进行心跳操作,找到阻塞的进程并将其杀掉。 一个直观的想法是,为每一个要进行心跳服务的节点对象建立一个计时器,当计时器超时则进行相应的处理动作。但是,为上万个节点建立上万个计时器绝对是不可能的事情,这将是对于资源的巨大浪费!另一想法是为整个进程建立一个计时器,在每个节点记录下上次该节点活跃的时间,当时间点到达后,就遍历整个节点列表对超时的节点一一进行处理。这种策略对于小规模,超时时间较长的节点列表的确可以使用(例如master上的心跳模块就可以这样实现),但是对于像gateway这样的情况这种策略还是存在问题。首先,gateway对于每个连接的超时时间要求比较短,这样轮询的频率就会很高,而轮询操作又会大量的占有CPU资源,所以这种策略不可取。我们可以在此基础上改进这个算法,即设计一个临界节点指针,每当一个节点发生动作时就更新到该临界节点之前,那么等到一段时间(通常设为超时时间)后该临界节点之后所有节点就被视为超时节点,进行统一杀掉。然后一个新的时间段又开始了,将临界指针指向队列的队首再次重复上述操作。伪代码如下:
struct node { int update_time; }; // 该函数将会被绑定在一个定时器上,定时被唤醒调用 void heartBeat(list &nodelist, int segtime) { // 获取当前时间 time_t now = time(); // 检查临界节点之后是否还有节点 // 若有节点则为超时节点,并对其进行超时处理 list::iterator it; for( it = nodelist.rbegin(); it != nodelist.rend(); it++ ) { // 判断节点是否为临界点,将临界点的更新时间值设置为-1 if( it.second.update_time == -1 ) { // 为临界点 // 将临界点插入到队首 changeNodePos(nodelist, it); break; } else if(it.second.update_time - now > segtime ) { // 该节点为超时节点 eventHandler(it.second); // 移除该节点 nodelist.erase(it); } } } void callingFun(list &nodelist) { // 插入临界节点 node *new_node = new node; new_node.update_time = -1; nodelist.push_front(new_node); while(1) { // 每隔10s唤醒一次 sleep(10); // 10s之内没有活动的节点将会被删除 heartBeat(new_node, 10); } }
通过这种改进可以保证规定时间之内检测到没有活动的节点,同时也能保证不过于频繁的轮询所有节点。
另外一种登录验证的策略
Account server在整个集群中显得有点特殊,我们可以采用另外一种策略来替代Account server。当用户与gateway建立连接后,并不是由Account来进行验证,而是将该数据包通过proxymaster和proxy交给gamelogic处理,所有的gamelogic都将与存储sessionkey的服务器相连接。这个分配过程是随机的,并不是真的为用户分配一个gamelogic,而是仅仅处理这一个登录命令。当用户登录成功之后,发送进入游戏命令则会重新分配gamelogic服务器。
动态增减服务器
该功能是指我们可以随意随时在集群中添加新的机器,运行新的进程,用户不会感觉到服务器的增减,更新操作也不需要停服。这样既能保证可以随时根据负载量调整服务器的数量,也可以保证集群不会因为某些进程的crash而无法正常提供服务。实现服务器的动态增减主要需考虑如下问题:
集群中正在提供服务的服务器需要知道与之相关联的任何服务器的增减状况,并采取相应的措施保证新加入的服务器会被分配新的任务,已经删除的服务器不会被分配任务
对于系统中某些异常状况引发的进程无法继续提供服务,需要保持系统中数据的一致性(如某个proxy突然死掉,proxy master需要删除),避免在系统中留下死尸。
系统中的其它服务器不会因为新加入了某些服务而导致数据不一致。
日志系统
对于多进程异步服务器,时序上的问题是比较令人头疼的。一般都是采用日志的方式进行调试。另外,要监控服务器的状态也需要建立日志系统。日志系统需要提供适当的格式信息,以利于进行搜索。我们采取的策略是为每一个服务进程专门提供一个线程来输出日志信息,全部的日志信息将集中在一个指定的中心日志服务器上进行处理。
说了挺多,设计服务器的确需要考虑挺多问题,今天先说到这里,更多的内容请期待后续文章。
建议继续学习:
- 长连接(KeepAlive)在 http 连接中的性能影响 (阅读:7052)
- 关于Memcache长连接自动重连的问题 (阅读:3671)
- Mysql长连接 (阅读:3091)
- Jetty 8长连接上的又一个坑 (阅读:1305)
扫一扫订阅我的微信号:IT技术博客大学习
- 作者:nebula 来源: Nebula's Cyberspace
- 标签: 长连接
- 发布时间:2011-06-02 13:41:52
- [56] Oracle MTS模式下 进程地址与会话信
- [56] IOS安全–浅谈关于IOS加固的几种方法
- [55] 如何拿下简短的域名
- [54] 图书馆的世界纪录
- [52] Go Reflect 性能
- [52] android 开发入门
- [50] 读书笔记-壹百度:百度十年千倍的29条法则
- [49] 【社会化设计】自我(self)部分――欢迎区
- [38] 程序员技术练级攻略
- [33] 视觉调整-设计师 vs. 逻辑