后端小记录

 

1. 所有的代码都要能热更

首先目前配置是可以支持热更新的,大部分代码也是可以支持热更新的。

 

所有的程序员都写过bug,bug是难免的,但是我们可以尽量降低bug对玩家的影响。
而当代码上线后,若发现bug,为了不影响玩家体验,不能通过重启只能通过热更去修复。
 
2, 详细的日志
 
完善的日志非常重要,为处理线上问题定位线上bug提供基础,也为运营查问题提供支持。此外,也可以将日志用于下文要说的监控和报警。

日志这东西,不用时没感觉,用的时候就后悔:为啥不在这里加一条日志,为啥不把这个信息打印出来。

如何记录日志,没有一个统一的标准,我这里说一下我的经验和思考,这里主要介绍非战斗逻辑的日志。

哪些地方写日志:

玩家一个行为对玩家状态产生改变,这个逻辑期间至少一条日志。
玩家某些行为投放物品,一般投放前。
一些重要的状态切换,比如一个战斗房间由准备进入开战状态。
任何错误和异常,都需要加一条错误日志,防御性编程检测到错误也需要。

日志信息,日志需要记录什么:

日志的基本业务描述,一般几个关键字。比如投放武器
逻辑涉及的上下文关键信息。比如投放武器的 武器信息,比如武器所属的玩家ID
其他信息,比如服务端进程信息、时间戳、日志所属entity等。

日志等级常见的又debug、info、error、fatal。一般表示的含义是:

debug:开发环境打印,线上环境不打印。一般用于程序开发。(这里有个坑,debug日志一般的实现是线上进行过滤,但这个函数的参数还是会执行,所以如果是超级长的字符串操作要注意性能问题)
info:线上环境打印,我们说的日志大部分都是这个级别。
error:和log类似,只是是错误信息。一般是业务预期内的错误,不会影响系统正常运行。
fatal:业务预期外的错误,表示这类错误信息需要开发人员去关注和排查。

对于战斗,最好的方案是回放录像,日志一般是次选项。
我的理解是,会错误的地方都应该有一条日志, 对于玩家的操作的,都必要一条日志。我这边info也是测试服的日志,只有warn/error才是线上的日志.
 
3.监控和报警
 
通过监控,我们要对线上服务器的情况尽量的了解,并且能提前发现问题,防止问题扩大化后才知道。报警和监控相辅相成,监控到异常后,马上报警,我们就能立刻对线上问题进行处理。

我们用到的监控/报警有:

慢响应:监控客户端请求的平均响应速度,一般能整体评估游戏服务器是否有卡顿。
FATAL日志:有些特殊情况需要马上通知开发人员,比如服务器开服失败等,服务端会打印FATAL日志。这时候就需要报警通知。
线上traceback:一般用脚本写的服务端逻辑,都会有一些线上traceback,这个是需要重点关注的问题。发现有trace就需要去确认traceback的影响,若影响玩家功能需要热更修复。
投放监控:我们游戏对物品投放也有监控,防止玩家因为某些bug可以刷某类物品。由策划对每类物品设置一个报警上限,玩家当日获取此类物品超过阈值会通知到我们。我们会去检查玩家行为看是否符合预期。
玩家行为监控:除了物品获取,也有些玩家行为需要监控。比如我们游戏中每天打悬赏令的次数,按照策划的设计每天不会太多。超过某个数量说明可能有问题,需要人工审查。
服务端性能监控:我们会监控单点服务cpu使用率,skynet中一个服务只使用一个线程,单点服务cpu占用上限是一个核,所以比较容易出现问题。我们若发现单点服务的cpu使用率超过阈值,就会报警,可以提前进行性能优化。
服务端机器监控:监控机器、数据库的硬件信息,比如cpu、内存等。超过阈值进行报警。
阿里云费用监控:对按量付费的项目,我们会对其进行监控,若某一天的消费超过某一阈值,则进行报警。

总之,通过监控以及与之对应的报警,能提前发现线上问题,降低影响。大事化小,小事化无。
 
 
 
4.容错:保底逻辑:
大型分布式集群必须要考虑容错问题,容错分为几个层面:

架构容错(机器宕机、进程crash):主要通过消除单点等方式,后文会介绍。
DB等Saas服务卡顿/闪断:做好断线重连、重试等容错逻辑。
逻辑容错:有些逻辑系统中难免出现异常、bug或者超出预期的情况,在服务端中,对于这些问题尽量写一些保底逻辑,当出现问题时,能降低问题造成的影响。

下面主要介绍一下逻辑容错的相关情况:

系统异常:某些非核心服务出现异常(卡死、crash、网络中断)可以之关闭异常服务,保证系统整体正常运行。
bug:比如某款游戏中的反外挂逻辑特别复杂,有时候会因为改动了某些战斗机制造成外挂的误判,把正常玩家判为外挂玩家,这种bug比较难完全避免并且会造成大规模玩家封号等较大的影响。因此,可以增加了一个保底逻辑,每小时因外挂封号的玩家数量超过一个阈值以后,就不再直接封号,而是报警并且把这些玩家记录下来,去人工检查后确认是否有问题,有问题再人工操作封号。这个保底逻辑一方面可以报警让我们快速发现外挂检测bug,另一方面即使出现了bug也能降低影响人数。
玩家行为超出预期:比如,有些游戏会记录玩家的历史好友(删掉的),这个记录会随着玩家行为变得越来越长。若完全没有限制,当客户端请求或者逻辑遍历历史好友列表时,会造成卡顿。因此,这种情况最好设一个上限,历史好友数量超过一个阈值就删掉之前的。
功能开关:开发新功能一定要做好开关,可以随时线上关闭功能。若出了问题,可以关闭功能后慢慢修复,不影响玩家玩其他功能。
我的理解:
1, 功能开关,这个很好,有时候某些功能是实验性的, 需要和主体功能分开的,做一个开关可以保证,如果这个功能不要了,那么设计相关的代码还会一直跑,这样就问题很大了,明明不需要了,还要去请求redis。容易出错。
2. 防御性编程。
3.系统异常,某个服务器宕机了,这时候也要保证系统能正常跑。
4. 预期的错误(预期有一个阀值),如果好友列表过长导致的问题。
 
5、异步提交

玩家有些操作不需要等待等待逻辑执行完成返回响应,这种操作可以将任务提交到队列中然后异步执行。这种方案的好处是即使任务处理能力不足,不会影响到玩家造成玩家卡顿。

我们曾经开发过一个副本成绩排行榜,排行榜的上榜规则比较复杂,当玩家打完副本后会将战斗成绩提交到排行榜,排行榜通过一系列的逻辑将成绩插入到排行榜中。当我们开服后,玩家大量涌入这个新玩法,此外,由于排行榜是空的,大量成绩都会进入排行榜中,造成排行榜卡顿,导致玩家完成战斗后提交成绩时卡死。

后来,我们将其改为玩家打完副本后将成绩提交到排行榜中,但不等待排行榜的响应。这样,当排行榜逻辑卡顿的时候,只是有可能成绩上榜会延迟,但不影响玩家体验。

以skynet为例,尽量用skynet.send替代skynet.call。若发现skynet.call没有返回值时,就去判断一下call的逻辑和下文逻辑是否有顺序依赖关系,若没有依赖关系就可以改为send。

这其实就是消息队列的思想,通过异步处理提高系统性能和削峰、降低系统耦合性,大家可以去百度“消息队列”详细了解。
我的理解:项目中,如何有rpc,rpc的服务端,调用函数时,永远不返回,那么这个时候发起端,应该有超时反应,比如我请求一个rpc去获取玩家属性,超过30s没有返回的话,就直接返回错误给客户端,这样可以防止卡死。
 
6、消除单点和水平扩展

一个游戏服务器集群的承载上限,就是集群中的逻辑单点的承载上限。所以,在游戏服务器架构设计中,要尽量的消除单点,改为支持水平扩展。

服务器集群中存在单点的常见原因是因为数据需要统一管理,比如玩家管理器、家族管理器等,需要管理所有玩家或者所有家族。

这种情况有两种解决方案:

加一层分发逻辑:比如我们之前家族管理器管理所有家族的信息以及相关的逻辑,后来就扛不住了。然后我们抽象出来了familymaster和familynode,每个familynode管理部分家族,familynode可以无限的水平扩展。familymaster依然是单点,但是他只是记录每个家族在哪个familynode上面,所以承载上限很高。
使用无状态:将数据和逻辑分离,数据放在redis/db中,逻辑执行都去读写db。这种方案理论上是可以无限扩展的,因为db是支持无限扩展的,但是要求状态(数据)相对比较简单,容易存在db中并且可以高效读写。比如上文提到的familymaster依然是单点,但只管理familyid到familynode的信息,这个信息我们就可以存在redis中,然后每次读写都去操作redis,这样就达到了理论上的无限扩展。

一般来说,游戏服务器并不要求完全的消除单点,因为需要做很多额外的事情,要么增加开发成本,要么增加运维成本。所以,只要我们的单点承载上限超出游戏玩家量的需求,就可以了。不要过度优化。

消除单点一方面可以带来承载量的提升(高并发),另一方面可以提高可用性(高可用)。通过消除单点,一个功能可能分布在多个进程/机器上,即使某个进程挂了,其他进程也可以使用,仍然可以提供服务。当然,写代码时需要处理这种异常才可以获得高可用性。

我们游戏的服务端简化版架构如下图所示,我们的玩家逻辑、战斗逻辑和家族逻辑都是可以水平扩展的。而只有一些管理全服信息的逻辑(比如维护玩家再哪个进程上)才会放在管理器里,管理器是服务器的单点,也是服务器承载量的瓶颈。
 
服务器架构(简化版)

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设为永不失效,只有在副本结束时才去统一清理。

具体策略根据具体需求可以使用各种花式方案。

 

posted on 2021-12-03 10:37  gongzhuiau  阅读(54)  评论(0编辑  收藏  举报