游戏后台开发共性问题和解决方法(1)

2023-06-06 21:32:29 浏览数 (1)

1. 玩家数据、公会数据等如何保证一致性?

玩家数据比如经验、等级,公会的数据比如成员列表、活跃度、等级等,会有很多触发更改的时机,这些修改可能在不同的模块触发。

数据读取不存在一致性的问题,最多读到的新数据还是旧数据而已,即使读到旧数据,在下次刷新的时候也会读到新的数据,问题不大。

数据写的时候,如果是(1)读(2)内存更新(3)写 (4)读(5)更新(6)写的数据,没有问题;但如果是并发写不是这个顺序的话,数据大概率就乱了。

最简单的解决办法是使用版本号机制,在回写的时候如果旧的版本号对不上,就意味着在你的读和写之间有其他玩家执行了写操作。这种情况下,让写失败并进行重试,直到成功为止。

但是使用版本号机制有个问题,就是重试比较麻烦,甚至有时候执行重试逻辑是不可行的。所以单纯的版本号机制还不行,需要对请求进行按照某种key排队,这种排队可以保证对kv中特定key的读写是串行的。而按照某种key进行排队有效的前提是,对db中某个表(某个类型)的数据集中在1个特定的模块进行处理,其他模块最多只允许读,不允许写。

但是收归到一个模块之后又有一个问题,相同的模块可能会有多个实例,如果同一个key无法路由到固定的模块实例,仍然有可能出现并发读写的情况,这个时候,一致性哈希路由上场了。微服务寻址时,上游模块指定路由的key,一般使用玩家id/公会id等,保证请求发到模块的相同实例。

2. 任务、活动定时刷新的问题

游戏的策划案中,经常会有一些定时刷新的需求。比如每日任务,在领取之后,不管进度如何、是否完成,都需要在凌晨某个时间点把玩家的任务领取数据清除掉。

比如要求每日任务在凌晨4点刷新,刷新之后,如果没有完成需要重新领取,才能去继续完成任务获得奖励;如果已经完成,也要清除掉完成记录,支持再次领取后继续执行。

玩家在白天11点领取了每日任务,但是凌晨3:59才开始做任务,如果在凌晨4点不能把领取的任务清除掉,那么玩家就会可以继续做任务,完成后获得奖励,与策划要求不一致。

要在4点准时刷新,有几种方法。

第一种比较直接,就是后台模块在统一刷新的时间,遍历所有用户数据进行刷新。但是游戏的存储普遍是使用KV数据库,KV的库对于遍历key都存在严重性能问题,并且短时间内大量的db操作尖峰,可能会导致其他模块对db的读写操作报错。难以接受。

第二种方法简单些,就是在线玩家实时刷新 离线玩家登陆时刷新。在线玩家实时更新有三种触发方法,一种是大厅实例直接遍历本大厅维护的在线玩家,一种就是通过广播机制由大厅通知在线客户端,在线客户端向后台模块发刷新请求,最后一种是由客户端自己定时,在定时时间向服务端发刷新请求。三种方法由不同的利弊。由客户端配合做刷新是优选,客户端可以在一个定时范围内做随机,避免形成请求尖峰。一般刷新的时间是凌晨,在线玩家也不多,服务器可以承受。

第三种方法是推策划改需求或接收有损的刷新,将刷新操作延时到用户重新登录或者重新请求该模块,这样一来可以平滑流量,二来逻辑比较清晰。比如将任务的入口进行统一,在玩家进入任务入口的时候进行刷新。同时要求策划适当接受有损的服务,接受少量未及时刷新带来的副作用。

3. 有状态服务的容灾

最简单的,就是想办法把有状态的服务改造成无状态的服务,尽量减少有状态的服务模块。这个怎么改造呢,一般都是借助KV,避免内存中保存数据。这个其实是把容灾的活交给KV,现在的KV,牛逼点的都是百万千万读写QPS的量级,问题不大。KV是标准化组件,有问题怨基础组件团队或者云产商好了。

但是,有些模块必然会有比较大的数据,比如对局匹配的模块,必然需要在内存中缓存各种玩家的匹配请求,否则性能上、延时上无法接受。这种模块的容灾,就是比较通用的套路,单机上使用共享内存存数据支持快速拉起,拉起后利用共享内存的数据继续干活;在模块上就是热备容灾,玩家进出池子、请求同时发到所有主备实例上,但是只有主实例会实际输出应答。

这里的主备容灾又有不同的实现方法,借助各类KV、Zookeeper等都可以实现选主。

有状态的服务一般使用k8s的statefulset部署,这个statefulset有个问题,就是当机器挂掉的时候,无法在新的节点上重新拉起,原因是k8s master在node挂掉的时候,无法确认故障原因是什么,也不清楚statefulset的实例是否还在运行,如果强行拉起新的,可能出现PV重复挂载等严重问题。

所以statefulset的容灾只能业务、运维自己实现,充分的冗余 至少跨机房的容灾部署 实时监控告警,及早请开发、运维大佬介入。deployment就没有这个问题,所以尽量使用deployment部署。

4. 服务部署中的大镜像问题

游戏后台模块多,可能服务模块打包后有好几个GB的大小,特别是在开发测试阶段,没有使用编译优化,CPP编译出来的可执行文件可能有几百MB,里面有符号表和各种调试信息。

如果把服务模块打包到docker的镜像中,会导致镜像非常大,而且只要代码有更新,镜像就需要更新。

在版本发布更新的时候,成百上千台机器同时拉取镜像,镜像仓库可能扛不住直接挂了,或者被限速几个小时才完成更新也是有可能的。当然,这种操作方式有好处的,简单、便于流水线化、不易出错。

但是如果上面的问题无法解决,那么就必然要求基础镜像和服务器资源包分开,基础镜像不更新,服务器资源包通过运维工具提前分发到节点上。通过文件挂载的方式,将资源包映射到容器内部。

这个分发也可以使用其他方式完成,比如借助一些存储分发工具比如FTP,容器启动脚本主动从FTP拉取。这种方式容易操作上容易出错,旧文件残留等,这需要把操作标准化,发布更新的时候严格按照步骤来,并且及时check各个步骤结果是否符合预期。

5. 服务的任意重启

可以任意重启的服务 与 不能任意重启的服务,执行更新的难度差别很大。可以任意重启服务,意味着不丢包、不丢请求。但是如果存在网络连接,那么大概率是无法任意重启的,需要先禁用模块把流量切走,还需要把存量的连接也断掉,这会影响到玩家的体验,并且操作起来也步骤多并且麻烦。解决的方法就是网络连接维护 和 业务分离为两个独立进程,两者通过共享内存的方法进行包的消费。在业务进程需要重启的时候,首先停止消费包,然后在所有请求处理完成后执行重启即可。共享内存要求有一定的容量,能够缓存短时间内的请求。

但是共享内存也只能缓存一段时间的请求,如果服务没有快速再次拉起并处理请求,这些请求可能都会超时。这些超时由其他上游模块或者客户端执行重试。

如果服务长时间没有拉起来,会体现在为服务的路由表上,新的流量不会在转发到该连接进程上。

0 人点赞