游戏服务器在设计和实现上的安全性问题主要可以分为以下几个大类:
协议安全
流程安全
数据存储安全
需要注意的是,安全性和效率以及流程的复杂程度往往是对立的,越是安全的系统,流程越是复杂,效率也越低。我们在游戏的开发过程中需要做出折中的选择,达到安全性和效率的平衡。
协议安全
协议安全所指的是网络数据包的安全,通常意义上讲就是指网络数据包的加密和解密,由于网络数据包传输频繁,如果使用复杂的加解密技术将会带来巨大的CPU开销,另外由于客户端程序内存有数据包加密和解密的完整代码,因此无论多复杂的加解密算法都不能阻止破解数据包事件的发生。建议对于游戏中的一般网络包不需要进行复杂的加解密过程,只对数据包进行简单编码或者不加密也是可以接受的。
使用不对称的加密算法,客户端的代码中不能包含解密的算法和密钥。
保证客户端每次加密后的数据是动态的,以防止网络数据包被它人录制以后可以再次成功使用。例如,客户端有很复杂的加密过程,并且没有解密算法,但是如果加密过程没有动态性,由于每次输入相同的用户名加密码生成的数据包是完全相同的,那么如果被别人录制了登录的数据包,就可以伪造成功的登录。在不考虑使用密保等具有动态特性的组件以外,我们也可以考虑使用验证码的机制,新建立连接以后,服务器会发送当前有效的一段特征码,客户端加密数据需要使用到这一段特征码。这样即使登录数据被别人录制以后也无法直接使用,因为新建立的连接特征码是不同的。
另外提一下关于服务器加入部分反外挂特性的功能。一般游戏服务器内部主要能够集成的反外挂方式是防止加速外挂。目前常用的反加速外挂主要有两种:
a. 一种方式是在客户端连接服务器后,服务器会把当前的时间发送到客户端,以后客户端发送的每个数据包都需要包含自己计算出的服务器当前时间信息,服务器如果检测到数据中描述的服务器时间超过了实际的时间,就认为玩家有可能使用了加速外挂。这种方式相对服务器负担不大,也不容易出错,但是只能防止变速齿轮等通用外挂,对于能够自己构造协议的外挂没有效果。
b. 另外一种方式是服务器(一般是网关)上根据玩家发送的数据包类型进行分类,同类的数据包相互之间间隔必须满足一定条件,如果玩家连续多次出现同类数据包间隔过短的情况,就会被判定为使用加速器的玩家。这种模式虽然相对比较耗费服务器资源且需要关注到游戏逻辑,但是判断比较准确,基本上能防止各种加速方式。
在开发协议函数时,需要对所有的协议参数都进行合法性检查,服务器能够通过上下文获取的数据都不应该由客户端来提供。
流程安全
玩家游戏中的流程安全是相当重要的一个部分。对于一般的游戏,建议最好的方式是尽量维持使用同一条经过验证的Socket(Tcp)连接进行网络通信,因为相对来说想替换别人的网络数据是一件比较困难的事,除非玩家自己的设备中了木马。说简单一点就是尽量使用同一条TCP连接,先做登录验证,然后进行游戏,中间尽量不做切换连接的流程。但是对于实际的游戏而言并不一定能完全实现游戏过程中不切换连接。有些简单的游戏就没有网关的设计,如果切换线路服务器,必然会重新进行连接。还有部分游戏由于游戏过程是基于UDP或者Http短连接的通信模型,这些模式都需要重新验证玩家的有效性。如果需要有多次验证的系统请尽量满足下面几个原则:
同一个SessionID(或者称为accessToken)最好只能通过一次验证。如果游戏类型是切换游戏服务器、线路服务器或者是进入副本服务器时需要重新验证的类型,建议每次验证的SessionID都是重新生成的。如果条件允许的话,SessionID最好还包含有效期限制,但是这个需要针对不同类型的游戏有所不同。
遇到重复登录时候,一定要先确定新用户是否合法,然后再处理老的用户。否则就会出现没有验证新用户的有效性,直接就把老用户踢掉了的设计,这样会造成整个系统的玩家都可以被别人随便踢下线。
数据存储安全
游戏最重要的部分是玩家游戏数据的安全性,必须要保证用户数据能够实时安全的保存。
玩家切换服务器时,必须保证切换前数据已经被成功保存之后,才能被新的服务器读取。有部分游戏设计中,在切换服务器时,不进行实际存盘动作,只是将数据缓存在DBServer上,这个模式也可以接受,但是需要注意的是如果过程中发生切换服务器失败,玩家掉线等错误状态,DBServer要能够合理的将数据保存。
出现重复登录的情况,在踢出老用户的同时需要注意保存用户数据。建议重复登录的同时,最好也拒绝掉本次新的登录,确保用户数据的成功保存、以及老用户临时数据的清理。
玩家交易等过程一定要有事务的特性,两个玩家一定要完成物品的交换之后才可能发生写入数据库的操作,这个对于单线程逻辑程序一般很少有问题,但是对于多线程逻辑的程序必须注意。
如果处理数据库的读取和写入操作采用连接池的操作方式,需要注意单个用户数据的事务性和顺序性。可以通过一个用户数据只会在一个数据库连接上处理的方式来进行限制。
如果数据库发生故障或者玩家数据异常时,需要通过玩家的操作日志让玩家的数据能回滚到一个正常的值。
提供合理的数据库备份机制,目前一般的做法是利用数据库的主从备份机制,每天凌晨对从库进行全量备份,在数据库需要回滚到某个时间点t1的时候,可以每天的全量备份数据为基础,再执行凌晨到t1的binlog。
游戏开发中,我们经常会遇到一些技术难题,而其引发的bug则会影响整个游戏的品质。
过载保护、集群、服务器通信、并发选型等方面的问题,是中小团队常常的技术难题,本文分享了一些专家在坐诊过程中提到的解决方法,希望对大家有所帮助。
问题一:玩家登录时拉取好友信息,但好友服务繁忙导致登录失败。
解决方法:
1、分离关键路径上非关键调用,缩短事务流程,避免周边服务异常阻塞登录。
2、服务熔断机制,超出处理能力快速失败,防止雪崩。
3、按用户隔离事务,避免单个用户请求阻塞影响到其他用户。
问题二:压测并发登录对redis产生很大压力。
解决方法:redis数据表数量多,一次事务会产生多个 redis请求,小表合并为大表。
Wade:服务器进程的管理一般比较简单,有很多还是用配置文件静态组织的。同时往往进程间通信的手段比较缺乏,没有使用消息队列中间件,甚至还有用 Redis 来做通信组件使用的。为了提高集群管理的自动化水平,使用 ZooKeeper 是一个比较常见的方法。
Zc:redis一般作为内存缓存来使用,不宜将关键数据存放在redis中.其数据安全性并不如一般的DB。在使用过程中也需要参考性能基线,控制访问频率和流量。
问题三:外部服务有延迟,调用到的业务流程中产生卡顿。
解决方法:业务侧增加缓存:同玩好友msdk+最近角色id+角色信息。
Wade:很多团队对于过载保护不够重视,往往只在最外层接入客户端一侧有最大连接数或者最大会话数的限制。而对于内部的多个进程,比如访问数据库的进程,就没有太多的负载保护。由于游戏是带状态的进程比较多,所以负载均衡往往也做的不多,基本上是按状态所在进程去转发处理请求。
Zc:注意缓存和降级处理。外部平台数据,尽量缓存,提高访问体验。当发现外部服务出现故障,或本身出现负载风险时,应降级服务。
Jovi:msdk midas平台特权等api接入工作,游戏业务可以建立一个隔离层专门处理这块需求,避免过分侵入游戏逻辑,更容易控制。
问题四:运营和客服接口修改玩家数据,会与正常游戏的数据回写产生竞争。
解决方法:使用类似邮件机制去修改数据。
Zc:多线程开发中,经常会有线程池用尽或线程死锁导致服务质量下降。建议将线程池根据业务需求合理分类,不同业务间有合理的负载配比,不会相互影响。非关键流程需要延后或者异步化处理,避免卡死关键流程。
同时,合理的线程模型可以有效减少线程间竞争。对确实需要竞争的资源在流程入口处统一有序加锁,避免在逻辑过程中,随意嵌套取锁竞争。并且,给锁加个超时时间,避免业务中断。
Jovi:确保同一时刻只有单个数据修改点,有助于避免数据竞争。建议设计师采用CQRS方式,采用独立的数据表和服务记录事件,汇总到单一修改服务上执行。
Wade:并发编程是服务器端最常见的问题,一般会用多线程或者非阻塞两种方法之一解决。对于天然支持多线程的语言,如JAVA,很多开发者倾向多线程,好处是代码编写起来比较方便,但是这就要很清醒的对各种对象进行锁的操作,或者熟练使用类似 java.util.concurrent 这种多线程工具库。而如果使用非阻塞,好处是不会有锁的问题,但代码被分割到各个回调函数中,可读性非常糟糕,所以有的团队会使用“协程”或者 Promise 之类的工具来缓解这个问题,但这也引入了更多的复杂性。
下面详细介绍一下游戏服务器端架构中的调度架构,方便大家理解。
a) 单进程游戏服务器
最简单的游戏服务器只有一个进程,是一个单点。这个进程如果退出,则整个游戏世界消失。在此进程中,由于需要处理并发的客户端的数据包,因此产生了多种选择方法:
同步-动态多线程
每接收一个用户会话,就建立一个线程。这个用户会话往往就是由客户端的TCP连接来代表,这样每次从socket中调用读取或写出数据包的时候,都可以使用阻塞模式,编码直观而简单。有多少个游戏客户端的连接,就有多少个线程。但是这个方案也有很明显的缺点,就是服务器容易产生大量的线程,这对于内存占用不好控制,同时线程切换也会造成CPU的性能损失。更重要的多线程下对同一块数据的读写,需要处理锁的问题,这可能让代码变得非常复杂,造成各种死锁的BUG,影响服务器的稳定性。
同步-多线程池
为了节约线程的建立和释放,建立了一个线程池。每个用户会话建立的时候,向线程池申请处理线程的使用。在用户会话结束的时候,线程不退出,而是向线程池“释放”对此线程的使用。线程池能很好的控制线程数量,可以防止用户暴涨下对服务器造成的连接冲击,形成一种排队进入的机制。但是线程池本身的实现比较复杂,而“申请”、“释放”线程的调用规则需要严格遵守,否则会出现线程泄露,耗尽线程池。
异步-单线程/协程
在游戏行业中,采用Linux的epoll作为网络API,以期得到高性能,是一个常见的选择。游戏服务器进程中最常见的阻塞调用就是网络IO,因此在采用epoll之后,整个服务器进程就可能变得完全没有阻塞调用,这样只需要一个线程即可。这彻底解决了多线程的锁问题,而且也简化了对于并发编程的难度。但是,“所有调用都不得阻塞”的约束,并不是那么容易遵守的,比如有些数据库的API就是阻塞的;另外单进程单线程只能使用一个CPU,在现在多核多CPU的服务器情况下,不能充分利用CPU资源。异步编程由于是基于“回调”的方式,会导致要定义很多回调函数,并且把一个流程里面的逻辑,分别写在多个不同的回调函数里面,对于代码阅读非常不利。——针对这种编码问题,协程(Coroutine)能较好的帮忙,所以现在比较流行使用异步+协程的组合。不管怎样,异步-单线程模型由于性能好,无需并发思维,依然是现在很多团队的首选。
异步-固定多线程
这是基于异步-单线程模型进化出来的一种模型。这种模型一般有三类线程:主线程、IO线程、逻辑线程。这些线程都在内部以全异步的方式运行,而他们之间通过无锁消息队列通信。
b) 多进程游戏服务器
多进程的游戏服务器系统,最早起源于对于性能问题需求。由于单进程架构下,总会存在承载量的极限,越是复杂的游戏,其单进程承载量就越低,因此开发者们一定要突破进程的限制,才能支撑更复杂的游戏。
一旦走上多进程之路,开发者们还发现了多进程系统的其他一些好处:能够利用上多核CPU能力;利用操作系统的工具能更仔细的监控到运行状态、更容易进行容灾处理。多进程系统比较经典的模型是“三层架构”:
在多进程架构下,开发者一般倾向于把每个模块的功能,都单独开发成一个进程,然后以使用进程间通信来协调处理完整的逻辑。这种思想是典型的“管道与过滤器”架构模式思想——把每个进程看成是一个过滤器,用户发来的数据包,流经多个过滤器衔接而成的管道,最后被完整的处理完。由于使用了多进程,所以首先使用单进程单线程来构造其中的每个进程。这样对于游戏程序开发来说,结构清晰简单很多,也能获得更高的性能。
尽管有很多好处,但是多进程系统还有一个需要特别注意的问题——数据存储。由于要保证数据的一致性,所以存储进程一般都难以切分成多个进程。就算对关系型数据做分库分表处理,也是非常复杂的,对业务类型有依赖的。而且如果单个逻辑处理进程承载不了,由于其内存中的数据难以分割和同步,开发者很难去平行的扩展某个特定业务逻辑。他们可能会选择把业务逻辑进程做成无状态的,但是这更加加重了存储进程的性能压力,因为每次业务处理都要去存储进程处拉取或写入数据。
除了数据的问题,多进程架构也带来了一系列运维和开发上的问题:首先就是整个系统的部署更为复杂了,因为需要对多个不同类型进程进行连接配置,造成大量的配置文件需要管理;其次是由于进程间通讯很多,所以需要定义的协议也数量庞大,在单进程下一个函数调用解决的问题,在多进程下就要定义一套请求、应答的协议,这造成整个源代码规模的数量级的增大;最后是整个系统被肢解为很多个功能短小的代码片段,如果不了解整体结构,是很难理解一个完整的业务流程是如何被处理的,这让代码的阅读和交接成本巨高无比,特别是在游戏领域,由于业务流程变化非常快,几经修改后的系统,几乎没有人能完全掌握其内容。