游戏后台三两经验谈与方法论

发表于2017-08-07
评论0 1.7k浏览

游戏品类众多,规模与适用场景千差万别。所以对于后台架构开发而言,个人认为并没有放之四海而皆准的规则用来严格遵循。遇具体情况,择合适而为之。有三两经验,绕行遇到过的“坑”,留一家之言。

首先,选择架构设计当中一部分原则来谈:大道至简、分而治之、分久必合、保持一致、柔性可用

我们常常提到,要大系统小做,其优点在于降低了设计和实现的复杂度,便于扩展和组装,理解和维护的人力成本较低,出了问题能够快速的定位。

在实际应用过程中也遇到过这样的情况,一种设计方案非常完备,运用了很多的设计原理,代码实现分了很多层次,每一层进行了很多包装;而另外一种方案,摒弃了多层的继承,简化了架构,可以快速完成迭代并且比较容易增减模块和被他人维护。于是两种观点开始了pk。个人认为,万事无绝对,遵循一个基本准则,在具体的设计过程中适当取舍、灵活选择,目标:易于扩展、安全、稳定高效

架构设计最初,应该尽可能的考虑的服务的扩展性,既要“分”得彻底,又要“合”得容易。算法的分治法,把复杂度从o(n)降到了o(logn)。架构设计中的分而治之,体现在很多方面。

1、服务部署拆分。根据server的适用范围分为大区,大区内公共服与分区,分区内公共服与逻辑分区;内部服务与外部服务。这样大区间互不影响,服务间互不影响,第三方变更、波动不会对游戏本身产生影响。

2、业务拆分。很多游戏的主逻辑采用了单线程,线程内处理玩家请求,服务之间的调用,以及各种心跳。这就导致单个server能tps较低,并发上不去。这样可以把与游戏主逻辑相关不大的功能例如pk逻辑拆分出去,提高单个服务的处理能力。另外,主逻辑的gs配合共享内存使用时,会在单一的gs上加载玩家的数据,这样gs其实是有状态的,而战斗本身是没状态的,拆分出去后可以平行扩展。

3、数据拆分。根据数据的量级、访问频次、需要的响应时间、安全持久行等方面考量,按照内存、共享内存、cache,db几个层级划分,读写速度降低,但容量和安全性逐步增加。服务重启时通过挂载到相同的共享内存,报纸数据不丢失,当然机器重启就无法保证了,所以使用共享内存时,必须选择合适的落地策略,如定时回存、重要数据实时回存等。另一方面的拆分,体现在不同分区间数据拆分、同一分区内所有玩家分库分表落地。不同粒度的数据划分能够保证数据相互隔离,一旦出问题需要回档,影响范围较小,且操作快速。

4、线程拆分。这种拆分主要体现在读写分离上。游戏内大部分的数据读的频率远高于写,这样读线程读数据,写线程写数据,读线程作为无状态的多线程,写线程单点修改数据。这种唯一性的保证,可以通过几种方式实现:1)单个写线程;2)调用方根据id做hash;3)代码层面强制制定被调;4)锁。

5、协议拆分。以卡牌类型的游戏为例,很多展示内容是都是不涉及玩家交互的,这样,就可以把相关的逻辑交给客户端去做。所以可以根据游戏里数据的类型做一次动静分离,静态数据内置为dat,改动变化很少,可以通过cdn下载更新;动态数据是玩家数据变量较多的部分,而这一类型数据在协议传递时又可以进一步细分:列表类型的全量传还是增量传;同一条协议按照字段分,置脏或满足条件的才传;心跳里合包后统一传;客户端上行请求版本号与服务器存储版本号不一致才传等多个方面。

分而治之,可以有效的降低问题的复杂度,减少性能的消耗,提高安全性。但是另一方面,随着游戏运营时间的增加,逐步暴露了一些问题:1)分区数量越来越多,但是单个分区的活跃用户逐步减少;2)每次开新服需要部署的服务和db非常多;3)已有的db使用率降低;4)裁撤了很多备机导致很多服务都是单点在跑。

所以,就需要做一些类似合并区服;合并服务:原来分区内的服务为多个分区共用;合并存储:不同的分区以不同的key作区分。

总体看来,需要在架构设计初期就做好能分能合的准备,具体包括:

1、架构设计灵活,切换代价小,甚至可以无壁垒的切换分区分服的服务到全服;

2、可以平行扩容缩容公共服务而玩家无感知;

3、可以无限制的扩充单个有状态的服务。做到只要有足够的机器资源,单个分区就可以无限扩下去。当然策划一般不会希望永远只开一个区,而且每个游戏都会经历蓬勃到衰减的过程。所以除了扩,还要能灵活的缩。

4、大部分滚服的游戏运营到中后期都会上合服、跨服、全服的功能,所以每个服务、功能在设计之初都充分考虑扩展性。

所说的保持一致,大多数场景并非完全实时且一致,而是依据具体的需求和应用场景,接近于最终一致。例如以下场景:

1)A查看B的信息,结果返回之前B又做了修改;

2)A去修改B的背包,B也在修改自己的背包;

3)A和B同时去砍C;

4)A和B同时去砍C的最后一滴血;

对于1),现实生活中也可能出现,你扭头看后边一个人一眼,他带着帽子,转身回去的时候他把帽子摘掉了,你看到的确实也没错,所以游戏里我们也可以不处理这种情况。对于2)如果A和B都是往背包放东西,那么不会有冲突,游戏里采用离线邮件增量发放的方式解决,但是如果是去扣除,那就类似4)了。而3)与4)的区别在于,是否介意先后顺序,如果不介意,那么可以用队列,无论是自定义队列,或者调同一个线程去排队;如果介意,多线程去修改同一个数据的的时候,就要上锁了。

柔性可用,目标是尽量尽快的响应。具体而言,有以下几种:

1、多部署几套server,一个扛不住了另外一个上;

2、每个server按照服务的重要层级做划分,无法完全跑起来的时候适当舍弃非核心功能;

3、接入排队,无法服务所有人的时候,只服务部分,且保证已服务的玩家不受影响;

4、客户端的展示做分层,无法全部展示的时候,优先展示核心内容;

5、开关是个好东西,提示一个“功能暂未开放”远比转圈还把人踢下线优雅得多。 

--------------------------------------- 来个分割线 -------------------------------------------

架构设计整体出发整理了一些思路,接下来则是一些实际的项目经验。

1.内存预分配。预先分配内存可以减少内存碎片,提高效率,对于服务稳定有贡献。游戏里有很多player pool,msg queue,table和各式的handler。可以加上热加载方式提高reload table的灵活性,另外需要注意复用各式pool的时候的init与cleanup,不要出现复用了别人的obj,socket等情况。

2.兼容性。Jce编码的方式处理db兼容,可以灵活的增加和删除tag字段。protobuff处理协议兼容,注意转给客户端的结构用向量而不是数组,否则就等着面对因为需求扩充宏定义增加了大小而导致的老包不兼容吧~

3.共享内存。使用共享内存可以保证某个服务core了之后数据不丢失,但随之而来的是数据需要定期回存,数据结构扩修改后需要全部回存并清理。之后如果遇到需要查看他人的信息,还要在清理之后重新load回来。

4.时间空间的相互转换。可以舍弃一部分内存或者db空间来存储一些双向索引,特别复杂的计算结果,非常复杂的状态机;也可以舍弃一部分时间来根据一些数据重建当前的各种状态。关键在于对整个复杂度和成本的综合考量,做个折中。

5.容错。长调用的中途可中断;支持外界的重复调用;任何服务重启后可恢复;极端异常不影响整体功能。

6.一些小的习惯。循环先写条件;指针先判空;迭代器先使用后释放;先delete后赋空;完善条件分支;强化下标检查。我们的目标写出来的代码,即使时间过去很久,还是可以第一时间做出定位,而且让别人也看得懂。比较推荐《clean code》。

7.工具很重要。可以让工具去做的事情,快点放手。

8.尽早制定统一的标准。不然一个项目里几种规范,大家都会很痛苦。

--------------------------------------- 分割线又来了 -----------------------------------------

再列一些项目踩过的“坑”,希望看到的同学绕行。

1.初始化:结构体、类;协议、db各种遗漏初始化的地方,导致的各种随机不可控。建议声明后习惯性加上构造函数。

2.Stl:线程不安全,多线程访问时记得加锁;不受pragma pack限制,sort的自反性。这类错误总是导致莫名的core掉,查起问题来比较耗时。

3.配置错误:成百上千张表出现时,或者不熟悉的人接手时,总是难免有些人为因素的干扰,配错表被刷,配重复id导致逻辑出错等等。作为开发能做到的,是尽量设计不易出错的表格,用代码去检查配表的格式,尽可能的检查配表的内容。

4.对次数没有做限制。我们往往强调先扣后发,但往往是发失败需要补,补成功要扣(例如有个操作要消费元宝,操作失败当然要归还,但是如果恰巧这个时候有一个消费返利的活动,那么如果没有把这个补偿扣除,bug就来了)。

5.选择性的信任第三方。开放给第三方的发放奖励的权限,要应对对方没有做次数限制的bug。使用第三方的接口,要能自容错。还是那句话,不要相信任何人。第三方的任何波动都影响不了你,才是真正的强大(有时候做人也是~)。

6.压测。理论应该被合理验证。不然一旦扛不住,不仅是自己的问题,还有可能影响公共的服,进而雪崩到影响整体。

7.时间节点引发的各种bug:跨天、跨月、跨年、跨闰年、跨千年;玩家在线、离线跨以上节点;公共逻辑与各业务自身各时间节点的交叉;linux,crontab等不同表达方式;测试环境不好验证单服跨服,单业务全业务,手机调时间等等。在开发时间相关的接口时,要慎重,充分考虑到各种情况。

8.边界值。程序世界下标[0,现实世界[1;随机函数的上下边界;取整之后小数部分被舍弃;小概率事件归一到100后永远随机不到;数值溢出,short->int, unsigned->signed;除0。这种边界值异常经常把内存写坏,自然就core了。

9.死循环。循环嵌套,互相调用,死锁,条件恒真。这种情况往往监控不到。

10.Db扩展时没有发现字段已经被其他功能复用了,原因是因为当时紧急修复一个bug,没有新增存储。这样修bug引发了新bug。 

以上列出遇到过的各种问题,其实是可以通过规范代码和流程,提高代码质量来规避的。

文末写一点杂谈

1.需求决定方案,项目前后期特点不同,灵活机动;

2.需求会变,但要以不变应万变;

3.觉得代码写得别扭的时候,设计可能有问题;

4.灰度和压测很必要;

5.解决问题尽量优雅,重启永远不是最好的办法;

6.往往都是一个很低级的错误,导致了最严重的后果;

7.出错的地方往往更容易错第二次;

8.你所不确定的“一定”是错的;

9.不要相信任何人;

10.理解别人远比让别人理解你更难;

11.能指出别人方案的问题并给出建议同理;

12.凡是预则立,不预则废。

 

以上,零零总总,一家之言

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引

标签: