游戏开发跟运维哪个轻松(某百万DAU游戏的服务端优化工作)
游戏开发跟运维哪个轻松(某百万DAU游戏的服务端优化工作)我们在skynet的基础上,做了多种热更方案,基本上能保证所有的代码都能被热更。而且,可热更,是我们新开发功能时的一个必须考虑的事情。而当代码上线后,若发现bug,为了不影响玩家体验,不能通过重启只能通过热更去修复。游戏目前已经上线两年了,上线后我们做了不少服务端优化的工作。这篇文章主要介绍游戏服务端优化的一些方法,主要以介绍思想为主,为了举例更容易理解,很多实现思路并不是我们游戏的,这里也是作为例子说明。所有的程序员都写过bug,bug是难免的,但是我们可以尽量降低bug对玩家的影响。
文/水风 本文首发知乎
https://zhuanlan.zhihu.com/p/341855913
现在在一款百玩DAU的游戏项目中工作,由于怕玩家找上来聊一些非技术的工作,就不报名字了,只讨论技术问题。
我们游戏是一款基于skynet的通服游戏,开房间的游戏架构,预计单服可承载60w同时在线。
游戏目前已经上线两年了,上线后我们做了不少服务端优化的工作。
这篇文章主要介绍游戏服务端优化的一些方法,主要以介绍思想为主,为了举例更容易理解,很多实现思路并不是我们游戏的,这里也是作为例子说明。
1 所有的代码都要能热更所有的程序员都写过bug,bug是难免的,但是我们可以尽量降低bug对玩家的影响。
而当代码上线后,若发现bug,为了不影响玩家体验,不能通过重启只能通过热更去修复。
我们在skynet的基础上,做了多种热更方案,基本上能保证所有的代码都能被热更。而且,可热更,是我们新开发功能时的一个必须考虑的事情。
我们基于skynet、lua和服务端架构,做了以下热更方案:
通过以上热更方案,基本能覆盖全部情况。
一般只有我们发现只能通过inject但想修改的函数非常长或者修改非常复杂觉得不稳妥的时候,我们才会去通过重启修复线上问题(此情况在我们游戏中极少)。
2 尽量详尽的日志完善的日志非常重要,为处理线上问题定位线上bug提供基础,也为运营查问题提供支持。此外,也可以将日志用于下文要说的监控和报警。
日志这东西,不用时没感觉,用的时候就后悔:为啥不在这里加一条日志,为啥不把这个信息打印出来。
如何记录日志,没有一个统一的标准,我这里说一下我的经验和思考,这里主要介绍非战斗逻辑的日志。
哪些地方写日志:
日志信息,日志需要记录什么:
日志等级常见的又debug、info、error、fatal。一般表示的含义是:
对于战斗,最好的方案是回放录像,日志一般是次选项。
3 监控和报警通过监控,我们要对线上服务器的情况尽量的了解,并且能提前发现问题,防止问题扩大化后才知道。报警和监控相辅相成,监控到异常后,马上报警,我们就能立刻对线上问题进行处理。
我们用到的监控/报警有:
总之,通过监控以及与之对应的报警,能提前发现线上问题,降低影响。大事化小,小事化无。
4 容错:保底逻辑大型分布式集群必须要考虑容错问题,容错分为几个层面:
下面主要介绍一下逻辑容错的相关情况:
5 异步提交
玩家有些操作不需要等待等待逻辑执行完成返回响应,这种操作可以将任务提交到队列中然后异步执行。这种方案的好处是即使任务处理能力不足,不会影响到玩家造成玩家卡顿。
我们曾经开发过一个副本成绩排行榜,排行榜的上榜规则比较复杂,当玩家打完副本后会将战斗成绩提交到排行榜,排行榜通过一系列的逻辑将成绩插入到排行榜中。当我们开服后,玩家大量涌入这个新玩法,此外,由于排行榜是空的,大量成绩都会进入排行榜中,造成排行榜卡顿,导致玩家完成战斗后提交成绩时卡死。
后来,我们将其改为玩家打完副本后将成绩提交到排行榜中,但不等待排行榜的响应。这样,当排行榜逻辑卡顿的时候,只是有可能成绩上榜会延迟,但不影响玩家体验。
以skynet为例,尽量用skynet.send替代skynet.call。若发现skynet.call没有返回值时,就去判断一下call的逻辑和下文逻辑是否有顺序依赖关系,若没有依赖关系就可以改为send。
这其实就是消息队列的思想,通过异步处理提高系统性能和削峰、降低系统耦合性,大家可以去百度“消息队列”详细了解。
6 消除单点和水平扩展一个游戏服务器集群的承载上限,就是集群中的逻辑单点的承载上限。所以,在游戏服务器架构设计中,要尽量的消除单点,改为支持水平扩展。
服务器集群中存在单点的常见原因是因为数据需要统一管理,比如玩家管理器、家族管理器等,需要管理所有玩家或者所有家族。
这种情况有两种解决方案:
一般来说,游戏服务器并不要求完全的消除单点,因为需要做很多额外的事情,要么增加开发成本,要么增加运维成本。所以,只要我们的单点承载上限超出游戏玩家量的需求,就可以了。不要过度优化。
消除单点一方面可以带来承载量的提升(高并发),另一方面可以提高可用性(高可用)。通过消除单点,一个功能可能分布在多个进程/机器上,即使某个进程挂了,其他进程也可以使用,仍然可以提供服务。当然,写代码时需要处理这种异常才可以获得高可用性。
我们游戏的服务端简化版架构如下图所示,我们的玩家逻辑、战斗逻辑和家族逻辑都是可以水平扩展的。而只有一些管理全服信息的逻辑(比如维护玩家再哪个进程上)才会放在管理器里,管理器是服务器的单点,也是服务器承载量的瓶颈。
服务器架构(简化版)
7 功能解耦和隔离根据KISS(Keep It Stupid Simple)原则,应该将功能尽量的拆分成小的代码模块。这个原则对应到游戏服务器就是要将功能尽量的拆分成一个个服务,每个服务都只负责一小块功能。
Skynet提供了比较好的模块解耦模式:service模式,skynet中每个service就可以对应一个物理意义上的服务,而每个service就是一个线程,同进程service之间具有一定的隔离。而不同service可以放在一个进程,也可以放在不同的进程,提供了不同的隔离级别。
KISS原则我是基本赞成的,但是我认为游戏的玩家个人逻辑应该放在一个服务中,若拆为多个服务会造成服务间耦合严重。比如玩家升级,往往涉及到背包、属性、代币等不同模块。这个地方更合适用代码模块来区分开,但运行时属于一个服务。
除了玩家个人逻辑,其他功能可以适当的拆分,比如好友服务、聊天服务、排行榜服务等。
将功能拆为一个个服务以后,就需要考虑如何隔离。隔离方式skynet支持线程隔离和进程隔离,有的单线程服务器可能只支持进程隔离。
以前我曾基于python写过游戏微服务,因为python只支持单线程,所以每个进程只能承载一个服务。这种模式主要存在两个问题,一、服务间的调用请求都是网络rpc,都存在失败的可能,给业务开发造成了很大的成本。二、进程数量很多,因为一类服务往往又多个实例,每个实例都是一个进程,进程数量为N*M,进程数量多造成治理困难。
skynet这种模式就比较好,一个进程可以承载很多服务实例,每个服务实例一个线程,服务之间基于线程进行隔离。不同的服务可以放在一个进程中,一个进程也可以承载多个相同或者不同类型的服务实例。
那么,在skynet模式中,什么情况使用线程隔离,什么情况使用进程隔离呢?
8 引入超时
通过上文介绍的服务拆分和隔离,我们将服务端进行了拆分,拆分后我们希望对某些服务中的异常进行进一步的隔离。
skynet把集群看作一个整体,所以通过skynet.call调用其他进程函数并等待返回默认是无限等待的,没有timeout。
这样就导致若某个模块卡顿或者出现了异常,就会导致集群雪崩,影响到所有的功能。
比如我们游戏的chat模块,曾因为某些问题导致进程卡顿,而玩家登录都会去注册和拉取聊天消息,进而导致玩家无法登录,也无法正常游戏。
我们的聊天功能在前期设计的时侯设计的比较复杂,所以实现方案比较复杂,我看了一遍代码后觉得重构的成本和风险都太高。于是,我们希望即使chat卡顿或异常,也不要影响玩家的正常游戏,只是让玩家不能聊天而已。
因此,我们在skynet中增加了timeout机制,支持skynet.call超时。
引入了超时后,也需要增加超时后的逻辑处理。超时可能有三种情况,1.接收方没有收到请求。2.接收方收到了但是出trace没有返回响应。3.请求方没有收到接收方发出的响应。
业务需要处理超时问题,一般有两种方案:重试或忽略。对于有些关键逻辑,需要写重试逻辑,重试要保证幂等性。对于不重要的逻辑,可以忽略,比如发一个聊天消息。建议尽量忽略,重试逻辑写起来很麻烦,而且容易出问题。具体可以参考“分布式事务”相关信息。
在游戏的大部分的模块间耦合还是比较重的,所以skynet将集群认为是一个整体,我觉得是合理的,所以不应该过份解耦。只有一些相对独立的模块,可以通过解耦防止问题扩散和雪崩。
引入超时后,应该将游戏系统进行分割,核心业务不使用超时,不然写超时处理逻辑会非常麻烦。非核心业务加入超时,将核心业务和非核心业务进行解耦。
9 部分数据转存redis大部分游戏都把持久化数据存在mysql或者mongo中。而redis常用于cache等场景,比较少用于持久化存储。
但redis本身支持RDB和AOF持久化,其实有作为持久化存储的能力。而有些游戏数据很小,但存在mysql里面麻烦。
比如玩家的好友关系数据,一个好友关系涉及两个玩家,存在任何一个玩家身上都不合理。而如果存在mysql里面,如果设计不好,可能加载时需要访问很多次mysql。
这类数据存在redis就很方便,占用不了多少空间,而且大大提高了访问速度。我们游戏千万量级的注册玩家,玩家的好友关系数据也不过小几十G。
一般来说,业务上存mysql/Mongo觉得比较麻烦,数据量又不大,访问频率很高的,都可以存在redis中。
将Redis作为持久化存储其实是没有数据可靠性保证的,所以需要考虑异常问题对游戏系统的影响。若系统不能接受任何的异常情况,建议还是使用mysql。
此外,还需要考虑回档问题(虽然永远不希望遇到)。因为一个玩家的数据分散在了不同的地方,有的在mysql,有的在redis,所以回档的时候要想办法回档到一个点。(阿里云的企业版Redis也就是Tair,支持精准时间点恢复数据)
10 灰度测试环境对于一个线上项目,任何的修改都是有风险的,而有些底层的修改(比如数据存储相关代码)可能会涉及到所有的业务逻辑。这种情况若只是让QA测试某些情况其实是非常不稳的。
因此,我们将某些玩家逻辑进程设为灰度环境,只有指定的玩家可以进入。这样,我们就可以将某些涉及范围较大的改动,先在灰度环境中上线,选取某些玩家进入。即使出现问题,也只影响选区的测试玩家。测试一段时间后,若测试玩家没有反馈问题,就可以将改动正式上线了。
灰度环境是线上环境,和测试服具有本质区别。因为直接承载线上玩家,所以应用场景和测试服相比限制更多,比如我们只应用于玩家个人逻辑节点,也只测试底层代码逻辑,不测试业务逻辑。和测试服比起来优点是比较灵活,不需要部署测试服并且安排玩家进来测试。
我们的灰度环境可以分为多级,比如第一级灰度只能公司内部测试人员进入,新功能刚开始上线时就先放到这个环境。第二级灰度我们在线随机选取几百到几千的玩家进入,一般是经过第一级灰度验证过的功能。
灰度测试
第一级灰度环境的业务逻辑可以和线上有些许差别,但是第二级灰度因为直接面向外部玩家,所以要求业务上完全一致,一般都是底层的修改。
一级灰度因为只有内部玩家,所以理论上来说可以随时重启更新代码,所以可以随时将代码上线测试,不用等周版本,比较灵活。
一级灰度还有一些特殊用法,比如线上某个活动出了问题暂时关闭了入口,然后通过热更修复了。为了验证线上的修复结果,可以先在灰度环境打开入口,验证修复结果。
总之,有了灰度测试环境,可以相对大范围的验证一些底层修改,对于线上项目非常重要。而且,可以比较灵活的在线上做一些事情。
11 压测一款游戏上线前应该经过比较详细的压测,并且在后续的开发新功能和架构迭代过程中需要持续的进行压测。
压测主要是为了评估三个内容:
压测中需要关注的功能点(常出现性能问题的场景):
为了方便压测,我们做了一套压测工具,可以支持在容器中快速部署压测集群、执行压测任务并汇总压测结果。
12 动态扩容和缩容对于大部分游戏,都会有玩家在线人数的波动,比如某些活动期间人数很多,但每日凌晨都人数比较少。
我们游戏周末晚上会搞一些活动,周末晚上活动期间和平时相同时间段相比同时在线上升一倍。如果我们按照最大同时在线部署机器,会造成较大的浪费。
比如下图,常驻机器承载可以满足平时的需求,但是到了某些活动期间,就无法满足需求。这时候,如果支持动态扩容,就可以将机器在活动前增加,活动后回收,既节省了成本,又给玩家更流畅的游戏体验。
动态扩容缩容
我们游戏可以将玩家个人逻辑和战斗逻辑进程做到了动态扩容缩容,这类进程占比最大性价比最高,其他进程没有支持。
动态扩容缩容需要注意一些点:
13 cache
大部分性能问题都可以通过cache来解决,空间换时间,多买点内存,让玩家玩的爽一点,很值。
增加cache,需要考虑两个点:cache存放位置和cache更新策略。
13.1 cache存放位置常见的存放cache的位置有:
假设一个场景:玩家需要去拉取全服的一个排行榜,而这个排行榜的计算可能是很重度的计算,所以每次拉取都重新计算不可取。
服务端架构如下图所示,全服排行榜负责计算生成排行榜,每个玩家进程中管理很多个玩家entity,每个玩家都会去全服排行榜中请求排行榜信息。
上面说的四种位置,在这个场景下的对应关系如下:
说一下四种存放位置的优缺点和应用场景:
当然,cache也可以在不同的地方同时存在,也就是多级cache。这种情况一般可以获得更好的效率,但需要针对每一级cache定义维护和更新策略,逻辑更加复杂,bug更难查。
13.2 cache更新/失效策略cache的引入一般是为了解决性能问题,但也并不是没有成本。成本就在于需要管理cache,也就是决定cache什么时侯失效和更新,增加了编程的复杂性。
生存时间(ttl,time to live)
cache最常见的更新策略是使用生存时间ttl,即缓存超过一定的时间后自动失效,然后重新计算或者去数据源拉取。比如域名解析中就是用ttl控制DNS服务器中域名解析信息缓存失效。
这种策略最简单,建议优先使用这种策略。
主动更新cache
这种策略是cache的生产者主动去更新cache,这种更新策略思想类似写扩散。
比如游戏常见的玩家简要信息cache,这种cache一般是玩家更新自己的信息时,就去更新自己的简要信息。(当然,不一定完全实时)
这种策略一般是要求cache的实时性要求比较高,但是又不希望所有的请求都打到数据生产者中执行。
关于这类思想,大家可以去搜索“读扩散/写扩散”来了解更多的内容。
固定cache空间
某些场景下,cache可用的空间是有限的, 在有限空间的前提下,我们希望尽量的提升cache空间的利用效率。当可用空间没有用尽时,cache一直不会失效,当可用空间用尽后,以一定的策略去将某些cache失效,以获得空间给新的cache。最常见的是LRU策略。
因为硬件资源是有限的,这种策略也常见于硬件和系统层,比如虚拟内存的管理,比如mysql等数据库将部分信息缓存在内存中以提高查询效率,比如Redis内存空间用尽后内存淘汰。
这种cache的管理方式业务逻辑中用的比较少,偶尔配合其他策略一起使用,增加保底机制防止cache所占用的内存空间过大。
常见的策略有LRU和LFU。比如若redis占用内存接近内存上限时,会使用类LRU策略淘汰数据。
其他各类策略
cache也可以根据不同的业务场景设置更新和失效策略,比如可以在一个副本中将某些cache设为永不失效,只有在副本结束时才去统一清理。
具体策略根据具体需求可以使用各种花式方案。
后记
一款DAU百万级的游戏,而且是已经上线的游戏,其实优化起来非常困难,真*为一辆高速行驶的汽车换零件。
为了给玩家带来更好的游戏体验,我们做优化计划时并不保守,但非常谨慎的执行。
如临深渊,如履薄冰。
附:
公司招人,在杭州,一线薪水,不输任何其他游戏公司。
公司快速发展中,机会多多!
服务端、客户端都要,技术专家、主程都要~
投简历请发 yangpengwei@pandadastudio.com