服务器架构(简化版)
7、功能解耦和隔离
根据KISS(Keep It Stupid Simple)原则,应该将功能尽量的拆分成小的代码模块。这个原则对应到游戏服务器就是要将功能尽量的拆分成一个个服务,每个服务都只负责一小块功能。
Skynet提供了比较好的模块解耦模式:service模式,skynet中每个service就可以对应一个物理意义上的服务,而每个service就是一个线程,同进程service之间具有一定的隔离。而不同service可以放在一个进程,也可以放在不同的进程,提供了不同的隔离级别。
KISS原则我是基本赞成的,但是我认为游戏的玩家个人逻辑应该放在一个服务中,若拆为多个服务会造成服务间耦合严重。比如玩家升级,往往涉及到背包、属性、代币等不同模块。这个地方更合适用代码模块来区分开,但运行时属于一个服务。
除了玩家个人逻辑,其他功能可以适当的拆分,比如好友服务、聊天服务、排行榜服务等。
将功能拆为一个个服务以后,就需要考虑如何隔离。隔离方式skynet支持线程隔离和进程隔离,有的单线程服务器可能只支持进程隔离。
线程隔离的优点在于不同服务运行在同一进程,调用是函数调用,不存在失败的概念,缺点是一定程度上违反了KISS原则,并且服务之间隔离度低某些情况仍会互相影响
进程隔离优点在于进程功能更单一明确,隔离度高不会互相影响,但服务间通信变为网络通信更复杂,此外,每个服务一个进程,会造成进程数量庞大,管理和维护成本高。
以前我曾基于python写过游戏微服务,因为python只支持单线程,所以每个进程只能承载一个服务。这种模式主要存在两个问题,一、服务间的调用请求都是网络rpc,都存在失败的可能,给业务开发造成了很大的成本。二、进程数量很多,因为一类服务往往又多个实例,每个实例都是一个进程,进程数量为N*M,进程数量多造成治理困难。
skynet这种模式就比较好,一个进程可以承载很多服务实例,每个服务实例一个线程,服务之间基于线程进行隔离。不同的服务可以放在一个进程中,一个进程也可以承载多个相同或者不同类型的服务实例。
那么,在skynet模式中,什么情况使用线程隔离,什么情况使用进程隔离呢?
首先根据物理含义,将服务进行分组,同组服务放在同一进程。比如玩家服务、家族服务、登陆服务等。这个主要是将不同的核心服务进行隔离,也考虑容易管理。
对于性能消耗高的服务,进行隔离。防止打满CPU影响其他服务。
对于不稳定的服务,进行隔离。比如某服务使用了没有被广泛验证的C扩展,crash概率就会高很多。
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、压测
一款游戏上线前应该经过比较详细的压测,并且在后续的开发新功能和架构迭代过程中需要持续的进行压测。
压测主要是为了评估三个内容:
验证在大规模并发请求的环境下逻辑执行的正确性。
查找在大规模并发请求的环境下功能的性能瓶颈和性能热点。
评估游戏或功能的承载能力和需求,规划机器部署需求。
压测中需要关注的功能点(常出现性能问题的场景):
开服:关注登陆和创建账号,这两块逻辑一般都比较复杂。可以增加排队系统处理这个问题。
广播:比如全服聊天。可以分频道,也可以服务降级。
MMO游戏中玩家聚集:比如国战类游戏中的同屏大量玩家聚集。可以优化同步策略,也可以逻辑分线。
定时(同时)功能:比如某个活动会同时拉大量玩家进入某个场景。
单点服务:最多只能跑满一个CPU的服务。
数据上限:比如某游戏曾经因为大量玩家申请某头部主播好友,导致主播好友申请列表增加了近10w,导致机器直接卡死。
数据库相关:考虑数据库的承载。
全服玩家操作:比如通过命令给全服玩家发邮件。
为了方便压测,我们做了一套压测
工具,可以支持在容器中快速部署压测集群、执行压测任务并汇总压测结果。
12、动态扩容和缩容
对于大部分游戏,都会有玩家在线人数的波动,比如某些活动期间人数很多,但每日凌晨都人数比较少。
我们游戏周末晚上会搞一些活动,周末晚上活动期间和平时相同时间段相比同时在线上升一倍。如果我们按照最大同时在线部署机器,会造成较大的浪费。
比如下图,常驻机器承载可以满足平时的需求,但是到了某些活动期间,就无法满足需求。这时候,如果支持动态扩容,就可以将机器在活动前增加,活动后回收,既节省了成本,又给玩家更流畅的游戏体验。
动态扩容缩容
我们游戏可以将玩家个人逻辑和战斗逻辑进程做到了动态扩容缩容,这类进程占比最大性价比最高,其他进程没有支持。
动态扩容缩容需要注意一些点:
对于我们这种大DAU游戏,阿里云在某些可用区的备用库存不够,导致无法启动动态机器。所以需要考虑跨可用区的支持。
动态扩容比较容易,动态缩容需要做一些逻辑处理,需要达到优雅退出的效果。战斗服比较容易,战斗结束后关闭进程即可,对于我们这种玩家个人逻辑进程需要处理的事情多一些。我们关进程时会分步执行,先将此进程标记为新玩家不可进入,过段时间后再将非战斗状态的玩家踢下线(此步骤玩家无感知),最后强制踢下线所有玩家(此时玩家已经极少),基本做到了玩家无感知。
需要有较好的运维流程支持自动化,手动做的话人力成本太高而且容易出错。
最佳的方案是根据线上的情况(比如在线玩家)自动化扩容缩容。
这个方法不适合用于自建机房,对于阿里云/AWS这种按量付费机器支持的较好的云提供商比较适合。
13、cache
大部分性能问题都可以通过cache来解决,空间换时间,多买点内存,让玩家玩的爽一点,很值。
增加cache,需要考虑两个点:cache存放位置和cache更新策略。
13.1 cache存放位置
常见的存放cache的位置有:
贴近读取数据的实体(消费者)
贴近生产数据的实体(生产者)
生产者和消费者之间
第三方,比如redis
假设一个场景:玩家需要去拉取全服的一个排行榜,而这个排行榜的计算可能是很重度的计算,所以每次拉取都重新计算不可取。
服务端架构如下图所示,全服排行榜负责计算生成排行榜,每个玩家进程中管理很多个玩家entity,每个玩家都会去全服排行榜中请求排行榜信息。
上面说的四种位置,在这个场景下的对应关系如下:
贴近消费者:存在玩家entity中,每个玩家都有自己的cache。
贴近生产者:存在全服排行榜,cache全服有效,所有的玩家共享cache。
消费者和生产者之间:存在玩家进程中,每个玩家进程中的所有玩家共享cache。不同玩家进程之间的玩家不共享。
redis:将生成的排行榜数据存在redis,全服玩家共享。
说一下四种存放位置的优缺点和应用场景:
贴近消费者:若消费者消费频率特别高,且不同消费者数据不同,可以存在消费者这边。这种情况其实比较少。
贴近生产者:这种情况比较多,一般是为了通过空间换事件,是常见的方案。
消费者和生产者之间:这种情况一般是全部消费者的整体消费频率特别高,为了防止给单点压力太大,所以存在中间,降低压力。
redis:这个和贴近生产者差不多,最大的区别在于,redis可以与服务器解耦,服务器重启,redis的数据也存在。常见的情况比如存玩家的简要信息(供其他玩家查看)。
当然,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设为永不失效,只有在副本结束时才去统一清理。
具体策略根据具体需求可以使用各种花式方案。