服务器的运行稳定性的产品展现当然就是7*24小时,甚至更久的不间断运行的产品质量。通俗一点说,就是服务器在7*24小时运行时间内服务器不出现卡顿,不出现宕机。
对于C++来说,宕机的原因99.9%是因为内存问题,内存的直接操作是让C++程序员又爱又恨的东西。其他的可能还包括物理故障、除数为0、系统问题(虚拟机问题)等。而卡顿的原因主要体现在CPU的使用率上。服务器CPU使用率过高了会因为CPU性能瓶颈导致卡顿,这个问题比较直观,但是CPU使用率低了也有可能导致卡顿。现在我把宕机和卡顿问题归结到如下几点,每点都会详细分析细节并提出已经在使用的解决方案。
1. 可能导致CPU使用率过高的问题
2. 可能导致CPU使用率不足的问题
3. 内存使用稳定与安全问题
4. 除数为0
CPU使用率过高的时候,CPU性能就可能不能满足服务器当前计算要求而导致卡顿。一般来说我们希望我们的服务器在一班情况下能够保持在70%左右的负载压力下运行,而性能爆发的时候能够保持在90%以内。这样的服务器负载是一个运营情况下,比较理想的服务器物理配置方案和服务器部署方式。
如下几种情况可能导致CPU使用率的突发式波峰,在开发过程中如果存在这些问题需要格外注意。一定要在设计的时候把好关。因为这些算法,一般的常规测试并不能测试出问题,只有在特定的时间或者特定的输入下才会产生问题,具有比较强的隐蔽性。
1. 非O(N)复杂度算法或者高频调用算法
非O(N) 时间复杂度的算法,由于会随着输入参数数量,时间成本非线性提升。所以在一般情况下可以稳定运行的服务器,在输入参数出现一个波峰的时候,很有可能产生一个过度的CPU消耗。这个问题可以通过查看函数的单次调用CPU耗时排查出来。而高频调用算法则可能因为累积而表现出高CPU消耗。这种问题可以通过查看函数的累积CPU耗时排序排查出来。
对于游戏开发来说,解决算法问题,无论是稳定性还是性能;最简单的也是最有效的其实是跟技术无关,而与策划相关。
跟策划讨论设计目标真正的剖析真正需求的计算过程,这样可以采用真正合适的高效算法;对于实现算法确实过于复杂的可以提出简化的算法,比如非最优解,非必然可解等方案,探讨是否可以满足策划的需求达到原有的设计目标。因为策划对算法、性能是缺少概念的,其策划案描述的过程并非是算法的实现过程,而是实现其策划效果的一种直观流程而已。对于程序来说,其实可以提出很多的流程和方案也能达到该效果,而性能消耗要小很多。如果根据需求,算法的复杂度确实已经无法优化了,却仍然存在瓶颈,那么我们就需要给算法设定一个最大消耗上限,保证算法的最差情况。
案例1:登录排队功能
登录排队功能就是在实际在线玩家数量达到了服务器最大负载,正在登录的玩家必须进入等待队列,只有服务器上的在线玩家下线时,排在队首的玩家进行登录进服务器的这么一个功能。功能的一个需求就是能够提示玩家当前的排队序号。
这个功能的策划案一般是要求在玩家排队序号变化的时候:比如队首玩家进入游戏,或者排在玩家前面的其他玩家退出等待队列的时候,通知玩家其当前的排队序号。
那一般的做法就是在每个玩家进入游戏的时候,或者玩家离开队列的时候,就要对等待队列进行一次遍历,计算每个人的排队序号,并对队列中的所有人或者队列中靠后的人进行一次排队序列通知。当然通过每个人身上记录自己的序号,可以避免全员遍历从新计算序号;但是每次都需要对序号变动的人进行一次序号重新赋值。
这里通过跟策划的确认,其实有两个可以优化的点。第一,就是每个人的序号其实不需要觉得正确的序号。第二,更新其实并不需要一有改动就进行通知。实际的算法逻辑可以这样:每个玩家进入队列,根据前面的成员的排队序号+1来对该队员的排队序号进行赋值;每次玩家进行心跳消息的时候同时顺带传递当前排队序号信息。排队序号直接通过玩家序号减去队首玩家序号得出,而不用进行精确的遍历来计算。这样的优化就避免的排队需要同时广播通知的调用次数波动问题,同时也解决了每次通知的时候需要进行遍历计算当前排队序号或者需要对记录的排队序号进行重新调整的问题。
案例2:海战世界匹配算法
在结算算法之前,先做一下海战世界的广告,官网是hz.changyou.com。是一款以真实再现二战时期各国舰船,让玩家进行操作对战的慢速射击多人PVP对战游戏。游戏中有SS、CV、BB、CL、CA、DD一共6种船型,每种船都有1-10 10个等级,每一艘船都有自己的一个匹配权重值。游戏有1-12个等级的房间。每个等级的房间允许进入的每种类型舰船的等级段通过配置控制。玩家可以以组队的形式进入匹配队列,组队的成员必定在同一边的队伍。最终要求匹配出16对16的两个队伍。两个队伍之前的每种类型舰船数的差值不能大于配置的值,两个队伍之间队伍最高等级的舰船的权重值之和的差值不能大于配置的值,两个队伍之间队伍所有舰船的权重值之和的差值不能大于配置的值。
如果等待匹配队列里面有N个队伍,那最直接的算法复杂度就是组合C32 N*C16 32/2。即组合出32人,然后再32人里面分成2个队伍,然后对组合出来的队伍进行两两校验是否成员重复,计算策划案需求的数值差值。最终取出差值最小的组合进行战斗。那么这个算法只要等待队列里面到了32个对象(可能是玩家、也可能是队伍),那么计算次数就是601080390次,就已经非常吓人了。所以这个算法肯定是不能接受的。
这里很明显一个可以弱化的匹配规则就是最优。跟策划交流下来在16VS16的PVP竞技过程中即使数值差在一个范围内其实就是一个比较合适的对手了,即使数值完全相等,也不代表实力最相近。所以非最优对于策划需求没有影响。通过这个优化,那么我们的算法就不需要对16人的队伍产生进行检查,只需要配对检查到两个符合条件的队伍就完成了匹配了。但是这个方法不能解决最差情况,算法依旧不容乐观。
为了优化算法必须避免组合的高阶级乘的性能问题,我们给策划提供了一个只有2阶的组合算法。计算的过程从组合出队伍,然后进行2个队伍之间的匹配检查修改为每次从等待队列中取出人数相同的两个小组(没有组队即人数是1,如果存在一个组队,那么另外一个小组通过组合几个小组队的方式生成)分别加入两个匹配队伍;并对当前两个匹配队伍直接进行匹配检查。如果检查通过就加入当前的两个小组,否则放弃当前两个小组。继续选择小组,直到两个队伍人数都达到最终16人,算法结束。该算法的复杂度受到组队人数的影响,因为一个组队如果是M人,选择第二个组队的时候的算法就是CM N,算法的复杂度变成了C2 N~CM N之间,最差性能是CM N。该算法在6种舰船类型和舰船等级都是均匀分布的时候,等待队列达到42玩家的时候,成功率到达99.9%。42玩家匹配失败的算法消耗:组队上限5人,计算次数850668次,耗时82ms;组队上限3人,计算次数是11480次,耗时1ms。
2. 调用频率存在巨大波动
在游戏中,一个算法单次执行的性能消耗可能并不引人注意,但是如果该算法是一个功能函数,该功能的触发频率相当高;或者是一个工具函数,会被很多的地方调用,并且可能存在频繁使用的可能;或者是一个功能,机制上是定时执行的,那么这个函数或者功能调用爆发性而引起的性能性能瓶颈就需要被慎重考虑。
这种情况在游戏中还是比较多的。比如统计数据刷新、日(周)刷新任务这些都是在功能设计上按照刷新时间统一刷新的功能。比如排行榜更新、玩家移动,这些都是根据输入数量被频繁调用的功能。比如射线检测、字符串匹配这些函数这些功能更是作为基础应用有可能被很多地方调用,可能从多个功能入口产生大量的调用。
对于定时刷新的功能,一般来说在功能开发的时候都会针对性的进行刷新功能测试,在测试阶段一般都能发现并优化。不过这里有一点需要注意的,就是多个刷新功能在同时触发时,处理数据量叠加是否可能导致服务器的卡顿问题。因为游戏中日刷新、周刷新、月刷新可能统一设置了3个时间。因此相同刷新机制功能是在同一帧服务器Tick被促发,从而导致服务器的顿卡问题。所以在开发过程中,对于这种设定目前我考虑到的有3种解决办法。
a. 通过各个刷新功能独立配置刷新时间,要求策划把时间点配置成不同时间点,从而把定时触发的机制在时间轴上分散开来来解决问题。不过这种处理方式也带来了人为配置的风险点。在游戏开发过程中,所有人为控制的,又没有好的流程校验的问题都是可能发生错误的风险点。所以使用该方法控制的时候,最好在配置文件读取的时候对这些值进行校验,以保证功能不会因为配置原因出错。
b. 在定时刷新触发的时候在逻辑上保障第一个功能完成了才会进行下一个功能的刷新。不同的刷新功能之间设置刷新Tick间隔。日刷新伪代码如下:
OnDayRefresh()
Static intiProcessed = 0
If(iProcessed)
iProcessed= (++iProcessed) % PROCESS_INTERVAL_TICK
Return
If(DayRefreshProcess1is Needed)
iProcessed++
.
.
.
return
If(DayRefreshProcess2is Needed)
iProcessed++
.
.
.
return
c. 第三个办法是把玩家的数据刷新分成刷新时间点在线的玩家数据和不在线的玩家数据。对于在线的玩家数据在玩家自己的定时检测上进行触发,检测频率可以设置的稍微大一点。这样在线玩家数据就会分散到一个时间段里面进行更新。对于不在线玩家,其数据会再登录的时候进行是否需要刷新的判断,在登录时进行刷新。这样不在线玩家的数据的刷新会在更大的时间段上分散;另外对于时间段内没有登录的玩家就会避免进行刷新的资源消耗。
对于会被频繁调用的功能性函数可能因为频繁调用而产生的性能瓶颈问题呢?我觉得功能设计的时候都进行一下调用频度和处理数据量预估,对于调用频度超过千次/秒而功能函数处理数据量超过万的在完成后进行一次性能测试是一个比较保险的方法。如果CPU消耗率(单位时间内需要多少CPU时间进行该功能的处理)在0.1%以内,就是一个可以不用费心考虑性能问题的功能;如果CPU消耗率在10%以上,则是一个需要重点关注的服务器性能瓶颈。
案例1:排行榜的更新
预计同时在线人数50000人,其中60%的人在进行战斗,每次战斗持续10s,每次战斗更新会触发排行榜更新,那么排行榜的更新频率就是50000*60%/10=3000次/秒。排行榜长度50000,每次插入排序耗时Xs,那么该功能的耗时就是3Xs。如果时间消耗能够控制在1ms之内(相对于时间消耗小于0.1%,基本不会成为服务器性能瓶颈),那么该性能就比较好的满足服务器的性能需求了。
案例2:玩家移动
玩家移动根据操作模式有两种同步频率,这一种鼠标模式(手机上对应的是点击模式),另一种键盘模式(手机上对应的是摇杆模式)。键盘模式相对鼠标模式同步的频率会更高一点。键盘模式考虑到同步的精确性一般是0.1s同步一次,当然有些动作游戏会有更高的精度,不过由于网络的时延,更高的精确度在互联网联网的游戏上没有什么作用。一款网游如果不能承受0.1s的网络延时必然导致大量玩家无法很好的体验这款游戏。玩家移动在服务器端需要进行的操作包括速度检测,碰撞检测,触发事件,更新可见列表,更新被可见列表,同步玩家数据。单物理机支持玩家数1000人,地图分块每块最多对象数100(包括NPC和系统对象),玩家最大视野半径是相连的1个方块(分块不考虑高度),90%的玩家在移动。那么单物理机的移动调用频率就是1000*10*90%=9000次/秒。每次调用不能超过0.1ms。考虑到其他的功能,时间最好压缩在0.4ms以内(40%的CPU性能消耗)。所以对于移动产生的事件触发、列表更新、数据同步需要进行非常慎重的设计和测试。
3. 在突发性性能瓶颈后可能引发的恶性循环
在非时间敏感服务器上,无需关注突发的CPU使用率过高的性能问题,因为该服务器会尽量处理突发的请求,而玩家对于该功能的时延有比较大的容忍。不过需要注意这种突发性的性能问题产生之后,大量的过期数据导致服务器处理缓慢,在处理过期数据的过程中不断的有有效请求变成了过期请求,进而进入恶性循环。服务器处理的永远都是过期数据,从而导致服务器无法进行有效的功能处理(这个情况有可能是服务器本身的性能问题达到的也可能是硬件或者其他外部平台接口性能问题导致的)。
案例1:登录时间超时导致恶性循环
客户端设置了30秒登录超时,如果30秒登录请求服务器没有返回就会进入重登流程。登录过程客户端需要进行连接、加密解密对话、账号密码验证3个过程。每秒处理55个登录,同时请求数量超过1650,那么存在处理延时大概30秒。而客户端30秒登录超时,而玩家的登录请求(CMD1)、断开连接请求(CMD2)仍然处于服务器主线程的消息队列里面没有执行。之后服务器还是会对这个过期的登录请求进行处理,消耗服务器CPU时间。大量的这种已经无效的请求被服务器执行,导致了玩家的重登陆请求仍然是在客户端发起请求超时了之后才被执行。服务器就进入了一个恶性循环,客户端再也无法正常登录了。
有两个解决方案。
方案1:根据同时登录玩家的处理能力,设置同时登录玩家上限。超过上限的玩家连接请求直接在IO线程处理掉,不生成请求消息进入主线程。比如上述案例,可以设置同时登录玩家数为1650*0.8=1320人。目前我的服务器使用的是这种解决方案。
方案2:提升玩家掉线消息的优先级,优先处理玩家掉线指令。并在掉线处理逻辑中清空该玩家再消息队列中的消息请求。这个方案最好每个玩家有自己的消息队列,这样可以最高性能的删除整个消息队列,而避免相互索引甚至遍历。如果不能每个玩家都有自己的消息队列产生的消息处理优先级与玩家队列前后相关而不是时间相关,那么可以考虑掉线指令不清空消息队列中的玩家消息请求;而改成每个消息请求执行需要优先进行玩家是否正常在线判断和时间戳判断,优化超时消息处理性能。