版权声明:本文由韩伟原创文章,转载请注明出处:
文章原文连接:https://www.qcloud.com/community/article/253程序员
来源:腾云阁 https://www.qcloud.com/communitydocker
做者介绍:韩伟,1999年大学实习期加入初创期的网易,成为第30号员工,8年间从程序员开始,历任项目经理、产品总监。2007年后创业4年,开发过视频直播社区,及多款页游产品。2011年后就任于腾讯游戏研发部公共技术中心架构规划组,专一于通用游戏技术底层的研发。数据库
现代电子游戏,基本上都会使用必定的网络功能。从验证正版,到多人交互等等,都须要架设一些专用的服务器,以及编写在服务器上的程序。所以,游戏服务器端软件的架构,本质上也是游戏服务器这个特定领域的软件架构。编程
软件架构的分析,能够经过不一样的层面入手。比较经典的软件架构描述,包含了如下几种架构:缓存
运行时架构——这种架构关心如何解决运行效率问题,一般以程序进程图、数据流图为表达方式。在大多数开发团队的架构设计文档中,都会包含运行时架构,说明这是一种很是重要的设计方面。这种架构也会显著的影响软件代码的开发效率和部署效率。本文主要讨论的是这种架构。服务器
逻辑架构——这种架构关心软件代码之间的关系,主要目的是为了提升软件应对需求变动的便利性。人们每每会以类图、模块图来表达这种架构。这种架构设计在须要长期运营和重用性高的项目中,有相当重要的做用。由于软件的可扩展性和可重用度基本是由这个方面的设计决定的。特别是在游戏领域,需求变动的频繁程度,在多个互联网产业领域里能够说是最高的。本文会涉及一部分这种架构的内容,但不是本文的讨论重点。网络
物理架构——关心软件如何部署,以机房、服务器、网络设备为主要描述对象。数据结构
数据架构——关心软件涉及的数据结构的设计,对于数据分析挖掘,多系统协做有较大的意义。多线程
开发架构——关心软件开发库之间的关系,以及版本管理、开发工具、编译构建的设计,主要为了提升多人协做开发,以及复杂软件库引用的开发效率。如今流行的集成构建系统就是一种开发架构的理论。架构
服务器端软件的本质,是一个会长期运行的程序,而且它还要服务于多个不定时,不定地点的网络请求。因此这类软件的特色是要很是关注稳定性和性能。这类程序若是须要多个协做来提升承载能力,则还要关注部署和扩容的便利性;同时,还须要考虑如何实现某种程度容灾需求。因为多进程协同工做,也带来了开发的复杂度,这也是须要关注的问题。
功能约束,是架构设计决定性因素。一个万能的架构,一定是无能的架构。一个优秀的架构,则是正好把握了对应业务领域的核心功能产生的。游戏领域的功能特征,于服务器端系统来讲,很是明显的表现为几个功能的需求:
对于游戏数据和玩家数据的存储
对玩家客户端进行数据广播
把一部分游戏逻辑在服务器上运算,便于游戏更新内容,以及防止外挂。
针对以上的需求特征,在服务器端软件开发上,咱们每每会关注软件对电脑内存和CPU的使用,以求在特定业务代码下,能尽可能知足承载量和响应延迟的需求。最基本的作法就是“时空转换”,用各类缓存的方式来开发程序,以求在CPU时间和内存空间上取得合适的平衡。在CPU和内存之上,是另一个约束因素:网卡。网络带宽直接限制了服务器的处理能力,因此游戏服务器架构也一定要考虑这个因素。
对于游戏服务器架构设计来讲,最重要的是利用游戏产品的需求约束,从而优化出对此特定功能最合适的“时-空”架构。而且最小化对网络带宽的占用。
[图-游戏服务器的分析模型]
基于上述的分析模型,对于游戏服务端架构,最重要的三个部分就是,如何使用CPU、内存、网卡的设计:
内存架构:主要决定服务器如何使用内存,以保证尽可能少的内存泄漏的可能,以及最大化利用服务器端内存来提升承载量,下降服务延迟。
调度架构:设计如何使用进程、线程、协程这些对于CPU调度的方案。选择同步、异步等不一样的编程模型,以提升服务器的稳定性和承载量。同时也要考虑对于开发带来的复杂度问题。如今出现的虚拟化技术,如虚拟机、docker、云服务器等,都为调度架构提供了更多的选择。
通讯模式:决定使用何种方式通信。网络通信包含有传输层的选择,如TCP/UDP;据表达层的选择,如定义协议;以及应用层的接口设计,如消息队列、事件分发、远程调用等。
本文的讨论,也主要是集中于对以上三个架构的分析。
最先的游戏服务器是比较简单的,如UO《网络创世纪》的服务端一张3.5寸软盘就能存下。基本上只是一个广播和存储文件的服务器程序。后来因为国内的外挂、盗版流行,各游戏厂商开始以MUD为模型,创建主要运行逻辑在服务器端的架构。这种架构在MMORPG类产品的不断更新中发扬光大,从而出现了以地图、视野等分布要素设计的分布式游戏服务器。而在另一个领域,休闲游戏,自然的须要集中超高的在线用户,因此全区型架构开始出现。现代的游戏服务器架构,基本上都但愿能结合承载量和扩展性的有点来设计,从而造成了更加丰富多样的形态。
本文的讨论主要是选取这些比较典型的游戏服务器模型,分析其底层各类选择的优势和缺点,但愿能探讨出更具普遍性,更高开发效率的服务器模型。
分服模型是游戏服务器中最典型,也是历久最悠久的模型。其特征是游戏服务器是一个个单独的世界。每一个服务器的账号是独立的,并且只用同一服务器的账号才能产生线上交互。在早期服务器的承载量达到上限的时候,游戏开发者就经过架设更多的服务器来解决。这样提供了不少个游戏的“平行世界”,让游戏中的人人之间的比较,产生了更多的空间。因此后来以服务器的开放、合并造成了一套成熟的运营手段。一个技术上的选择最后致使了游戏运营方式的模式,是一个很是有趣的现象。
[图-分服模型]
单进程游戏服务器
最简单的游戏服务器只有一个进程,是一个单点。这个进程若是退出,则整个游戏世界消失。在此进程中,因为须要处理并发的客户端的数据包,所以产生了多种选择方法:
[图-单进程调度模型]
同步-动态多线程:每接收一个用户会话,就创建一个线程。这个用户会话每每就是由客户端的TCP链接来表明,这样每次从socket中调用读取或写出数据包的时候,均可以使用阻塞模式,编码直观而简单。有多少个游戏客户端的链接,就有多少个线程。可是这个方案也有很明显的缺点,就是服务器容易产生大量的线程,这对于内存占用很差控制,同时线程切换也会形成CPU的性能损失。更重要的多线程下对同一块数据的读写,须要处理锁的问题,这可能让代码变的很是复杂,形成各类死锁的BUG,影响服务器的稳定性。
同步-多线程池:为了节约线程的创建和释放,创建了一个线程池。每一个用户会话创建的时候,向线程池申请处理线程的使用。在用户会话结束的时候,线程不退出,而是向线程池“释放”对此线程的使用。线程池能很好的控制线程数量,能够防止用户暴涨下对服务器形成的链接冲击,造成一种排队进入的机制。可是线程池自己的实现比较复杂,而“申请”、“施放”线程的调用规则须要严格遵照,不然会出现线程泄露,耗尽线程池。
异步-单线程/协程:在游戏行业中,采用Linux的epoll做为网络API,以期获得高性能,是一个常见的选择。游戏服务器进程中最多见的阻塞调用就是网路IO,所以在采用epoll以后,整个服务器进程就可能变得彻底没有阻塞调用,这样只须要一个线程便可。这完全解决了多线程的锁问题,并且也简化了对于并发编程的难度。可是,“全部调用都不得阻塞”的约束,并非那么容易遵照的,好比有些数据库的API就是阻塞的;另外单进程单线程只能使用一个CPU,在如今多核多CPU的服务器状况下,不能充分利用CPU资源。异步编程因为是基于“回调”的方式,会致使要定义不少回调函数,而且把一个流程里面的逻辑,分别写在多个不一样的回调函数里面,对于代码阅读很是不理。——针对这种编码问题,协程(Coroutine)能较好的帮忙,因此如今比较流行使用异步+协程的组合。无论怎样,异步-单线程模型因为性能好,无需并发思惟,依然是如今不少团队的首选。
异步-固定多线程:这是基于异步-单线程模型进化出来的一种模型。这种模型通常有三类线程:主线程、IO线程、逻辑线程。这些线程都在内部以全异步的方式运行,而他们之间经过无锁消息队列通讯。
多进程游戏服务器
多进程的游戏服务器系统,最先起源于对于性能问题需求。因为单进程架构下,总会存在承载量的极限,越是复杂的游戏,其单进程承载量就越低,所以开发者们必定要突破进程的限制,才能支撑更复杂的游戏。
一旦走上多进程之路,开发者们还发现了多进程系统的其余一些好处:可以利用上多核CPU能力;利用操做系统的工具能更仔细的监控到运行状态、更容易进行容灾处理。多进程系统比较经典的模型是“三层架构”:
在多进程架构下,开发者通常倾向于把每一个模块的功能,都单独开发成一个进程,而后以使用进程间通讯来协调处理完整的逻辑。这种思想是典型的“管道与过滤器”架构模式思想——把每一个进程当作是一个过滤器,用户发来的数据包,流经多个过滤器衔接而成的管道,最后被完整的处理完。因为使用了多进程,因此首选使用单进程单线程来构造其中的每一个进程。这样对于程序开发来讲,结构清晰简单不少,也能得到更高的性能。
[图-经典的三层模型]
尽管有不少好处,可是多进程系统还有一个须要特别注意的问题——数据存储。因为要保证数据的一致性,因此存储进程通常都难以切分红多个进程。就算对关系型数据作分库分表处理,也是很是复杂的,对业务类型有依赖的。并且若是单个逻辑处理进程承载不了,因为其内存中的数据难以分割和同步,开发者很难去平行的扩展某个特定业务逻辑。他们可能会选择把业务逻辑进程作成无状态的,可是这更加加剧了存储进程的性能压力,由于每次业务处理都要去存储进程处拉取或写入数据。
除了数据的问题,多进程也架构也带来了一系列运维和开发上的问题:首先就是整个系统的部署更为复杂了,由于须要对多个不一样类型进程进行链接配置,形成大量的配置文件须要管理;其次是因为进程间通信不少,因此须要定义的协议也数量庞大,在单进程下一个函数调用解决的问题,在多进程下就要定义一套请求、应答的协议,这形成整个源代码规模的数量级的增大;最后是整个系统被肢解为不少个功能短小的代码片断,若是不了解总体结构,是很难理解一个完整的业务流程是如何被处理的,这让代码的阅读和交接成本巨高无比,特别是在游戏领域,因为业务流程变化很是快,几经修改后的系统,几乎没有人能彻底掌握其内容。
因为服务器进程须要长期自动化运行,因此内存使用的稳定是首要大事。在服务器进程中,就算一个触发概率很小的内存泄露,都会积累起来变成严重的运营事故。须要注意的是,无论你的线程和进程结构如何,内存架构都是须要的,除非是Erlang这种不使用堆的函数式语言。
动态内存
在须要的时候申请内存来处理问题,是每一个程序员入门的时候必然要学会的技能。可是,如何控制内存释放倒是一个大问题。在C/C++语言中,对于堆的控制相当重要。有一些开发者会以树状来规划内存使用,就是通常只new/delete一个主要的类型的对象,其余对象都是此对象的成员(或者指针成员),只要这棵树上全部的对象都管理好本身的成员,就不会出现内存漏洞,整个结构也比较清晰简单。
[图-对象树架构]
在Objective C语言中,有所谓autorealse的特性,这种特性其实是一种引用计数的技术。因为能配合在某个调度模型下,因此使用起来会比较简单。一样的思想,有些开发者会使用一些智能指针,配合本身写的框架,在完整的业务逻辑调用后一次性清理相关内存。
[图-根据业务处理调度管理内存池]
在带虚拟机的语言中,最多见的是JAVA,这个问题通常会简单一些,由于有自动垃圾回收机制。可是,JAVA中的容器类型、以及static变量依然是可能形成内存泄露的缘由。加上无规划的使用线程,也有可能形成内存的泄露——有些线程不会退出,并且在不断增长,最后耗尽内存。因此这些问题都要求开发者专门针对static变量以及线程结构作统一设计、严格规范。
预分配内存
动态分配内存在当心谨慎的程序员手上,是能发挥很好的效果的。可是游戏业务每每须要用到的数据结构很是多,变化很是大,这致使了内存管理的风险很高。为了比较完全的解决内存漏洞的问题,不少团队采用了预先分配内存的结构。在服务器启动的时候分配全部的变量,在运行过程当中不调用任何new关键字的代码。
这样作的好处除了能够有效减小内存漏洞的出现几率,也能下降动态分配内存所消耗的性能。同时因为启动时分配内存,若是硬件资源不够的话,进程就会在启动时失败,而不是像动态分配内存的程序同样,可能在任何一个分配内存的时候崩溃。然而,要得到这些好处,在编码上首先仍是要遵循“动态分配架构”中对象树的原则,把一类对象构造为“根”对象,而后用一个内存池来管理这些根对象。而这个内存池能存放的根对象的数目,就是此服务进程的最大承载能力。一切都是在启动的时候决定,很是的稳妥可靠。
[图-预分配内存池]
不过这样作,一样有一些缺点:首先是不太好部署,好比你想在某个资源较小的虚拟机上部署一套用来测试,可能一位内没改内存池的大小,致使启动不成功。每次更换环境都须要修改这个配置。其次,是全部的用到的类对象,都要在根节点对象那里有个指针或者引用,不然就可能泄漏内存。因为对于非基本类型的对象,咱们通常不喜欢用拷贝的方式来做为函数的参数和返回值,而指针和应用所指向的内存,若是不能new的话,只能是现成的某个对象的成员属性。这回致使程序越复杂,这类的成员属性就越多,这些属性在代码维护是一个不小的负担。
要解决以上的缺点,能够修改内存池的实现,为动态增加,可是具有上限的模型,每次从内存池中“获取”对象的时候才new。这样就能避免在小内存机器上启动不了的问题。对于对象属性复杂的问题,通常上须要好好的按面向对象的原则规划代码,作到尽可能少用仅仅表示函数参数和返回值的属性,而是主要是记录对象的“业务状态”属性为主,多花点功夫在构建游戏的数据模型上。
在多进程的系统中,进程间如何通信是一个相当重要的问题,其性能和使用便利性,直接决定了多进程系统的技术效能。
Socket通信
TCP/IP协议是一种通用的、跨语言、跨操做系统、跨机器的通信方案。这也是开发者首先想到的一种手段。在使用上,有使用TCP和UDP两个选择。通常咱们倾向在游戏系统中使用TCP,由于游戏数据的逻辑相关性比较强,UDP因为可能存在的丢包和重发处理,在游戏逻辑上的处理通常比较复杂。因为多进程系统的进程间网络通常状况较好,UDP的性能优点不会特别明显。
要使用TCP作跨进程通信,首先就是要写一个TCP Server,作端口监听和链接管理;其次须要对可能用到的通讯内容作协议定制;最后是要编写编解码和业务逻辑转发的逻辑。这些都完成了以后,才能真正的开始用来做为进程间通讯手段。
使用Socket编程的好处是通用性广,你能够用来实现任何的功能,和任何的进程进行协做。可是其缺点也异常明显,就是开发量很大。虽然如今有一些开源组件,能够帮你简化Socket Server的编写工做,简化链接管理和消息分发的处理,可是选择目标创建链接、定制协议编解码这两个工做每每仍是要本身去作。游戏的特色是业务逻辑变化不少,致使协议修改的工做量很是大。所以咱们除了直接使用TCP/IP socket之外,还有不少其余的方案能够尝试。
[图-TCP通信]
消息队列
在多进程系统中,若是进程的种类比较多,并且变化比较快,大量编写和配置进程之间的链接是一件很是繁琐的工做,因此开发者就发明了一种简易的通信方法——消息队列。这种方法的底层仍是Socket通信实现,可是使用者只须要好像投递信件同样,把消息包投递到某个“信箱”,也就是队列里,目标进程则自动不断去“收取”属于本身的“信件”,而后触发业务处理。
这种模型的好处是很是简单易懂,使用者只须要处理“投递”和“收取”两个操做便可,对于消息也只须要处理“编码”和“解码”两个部分。在J2EE规范中,就有定义一套消息队列的规范,叫JMS,Apache ActiveMQ就是一个应用普遍的实现者。在Linux环境下,咱们还能够利用共享内存,来承担消息队列的存储器,这样不但性能很高,并且还不怕进程崩溃致使未处理消息丢失。
[图-消息队列]
须要注意的是,有些开发者缺少经验,使用了数据库,如MySQL,或者是NFS这类运行效率比较低的媒介做为队列的存储者。这在功能上虽然能够行得通,可是操做一频繁,就难以发挥做用了。如之前有一些手机短信应用系统,就用MySQL来存储“待发送”的短信。
消息队列虽然很是好用,可是咱们仍是要本身对消息进行编解码,而且分发给所须要的处理程序。在消息处处理程序之间,存在着一个转换和对应的工做。因为游戏逻辑的繁多,这种对应工做彻底靠手工编码,是比较容易出错的。因此这里还有进一步的改进空间。
远程调用
有一些开发者会但愿,在编码的时候彻底屏蔽是否跨进程在进行调用,彻底能够好像调用本地函数或者本地对象的方法同样。因而诞生了不少远程调用的方案,最经典的有Corba方案,它试图实现能在不一样语言的代码直接,实现远程调用。JAVA虚拟机自带了RMI方案的支持,在JAVA进程之间远程调用是比较方便的。在互联网的环境下,还有各类Web Service方案,以HTTP协议做为承载,WSDL做为接口描述。
使用远程调用的方案,最大好处是开发的便捷,你只须要写一个函数,就能在任何一个其余进程上对此函数进行调用。这对游戏开发来讲,就解决了多进程方案最大的一个开发效率问题。可是这种便捷是有成本的:通常来讲,远程调用的性能会稍微差一点,由于须要用一套统一的编解码方案。若是你使用的是C/C++这类静态语言,还须要使用一种IDL语言来先描述这种远程函数的接口。可是这些困难带来的好处,在游戏开发领域仍是很是值得的。
[图-远程调用]
在多进程模型中,因为能够采用多台物理服务器来部署服务进程,因此为容灾和扩容提供了基础条件。
在单进程模型下,容灾经常使用的热备服务器,依然能够在多进程模型中使用,可是开着一台什么都不作的服务器彻底是为了作容灾,多少有点浪费。因此在多进程环境下,咱们会启动多个相同功能的服务器进程,在请求的时候,根据某种规则来肯定对哪一个服务进程发起请求。若是这种规则能规避访问那些“失效”了的服务进程,就自动实现了容灾,若是这个规则还包括了“更新新增服务进程”的逻辑,就能够作到很方便的扩容了。而这两个规则,统一块儿来就是一条:对服务进程状态的集中保存和更新。
为了实现上面的方案,经常会架设一个“目录”服务器进程。这个进程专门负责搜集服务器进程的状态,而且提供查询。ZooKeeper就是实现这种目录服务器的一个优秀工具。
[图-服务器状态管理]
尽管用简单的目录服务器能够实现大部分容灾和扩容的需求,可是若是被访问进程的内存中有数据存在,那么问题就比较复杂了。对于容灾来讲,新的进程必需要有办法重建那个“失效”了的进程内存中的数据,才可能完成容灾功能;对于扩容功能来讲,新加入的进程,也必须能把须要的数据载入到本身的内存中才行,而这些数据,可能已经存在于其余平行的进程中,如何把这部分数据转移过来,是一个比较耗费性能和须要编写至关多代码的工做。——因此通常咱们喜欢对“无状态”的进程来作扩容和容灾。