网络游戏中玩家数据的处理
背景
网络游戏中最重要的数据莫过于玩家在游戏的过程中产生的数据。
可以简单的分成两类:
- 存档数据
- 过程记录数据
第一类数据主要是类似角色『基础』信息,背包、技能、任务,以及所有(或者部分)玩家共有的王国、地图、联盟等信息。
第二类主要是类似『日志』信息一样的,比如「某个地方某角色使用了某道具」这样的操作记录。
这篇文章中我们主要讨论第一种数据的处理,关于后一种用作记录和分析的数据,可能会在后面写一个专门的Blog介绍。
存储结构
首先我们需要决定数据的形态,和描述的方法。
这里有一个选择:以『条目格式』为核心,还是以『文档格式』为核心。
『条目格式』的优点:看起来较为平坦,方便实时落地,和各种数据库存储模式搭配方便,外部工具修改方便,便于批量处理。
『文档格式』的有点:通常是树形结构,和大部分脑中的角色模型更加匹配,在内存中处理的时候,通常来说效率更高。
采用不同的数据结构通常会影响到项目使用的技术,甚至直接影响到整体架构和处理模型。如果使用条目,可能就会用memcache/redis存储,使用更多的无状态业务逻辑处理方式。
我们团队习惯的,是以 struct
为核心,配合 bson
/protobuf
实现接口和序列化,主要操作都在内存中处理的方式。
使用的时候,内存中的数据结构为 Go 的结构体;落地时使用MongoDB
数据库的保存的bson
格式的文档(Document);在和移动端交互以及某些内部RPC接口调用的时候,使用protobuf
作为传输格式;配置方面,使用etcd
同步的csv条目数据。
不论是bson
、protobuf
还是 csv 条目数据,都可以使用的是 Go 里面的扩展性极强的 struct tag
来完成自动转换。
数据兼容和升级
在长时间的线上业务维护和开发过程中,玩家的数据结构总是会修改、更新的。
如果每次都停止系统,把几千万甚至上亿的存放在数据库中的数据都升级准备好肯定是不可能的。通常情况下会在数据载入的过程中进行数据兼容检查和更新。
此时,在go
程序里面使用bson
作为数据的最终存储方式会给我们带来非常多的便利。bson unmarshal
的自动兼容处理可以把大部分『新增』、『删除』操作都完美处理好,对于那些需要修改的字段或者复合结构,我们完全可以使用新增操作来替代,升级之后删除旧数据即可。
使用二进制数据的那种痛苦 v0.01 -> v0.02 -> v0.0N 升级过程再也不需要见到了。而我们也不需要向使用条目存储数据的项目一样,等待一个巨大的库执行 alter table 的操作。
序列化的效率
有很多人都做过测试,有不同的结果。我们实际测试的结果是:
gob 最好,bson,protobuf 和 json 较差。
LRU 的选择
上面提到,需要操作的数据的,都是以 struct 的形式存放在内存中的。自然不能将所有的数据都载入,通常我们需要维护一个数据结构,将那些很久没有使用过的数据逐步淘汰出内存。这里使用一个LRU表就可以恰当且方便的完成。
Go的一个LRU实现
获取到需要淘汰的数据,通常会将其持久化到数据库中,线上我们遇到过这样的一个场景:
- 更新启动服务器之后,停服期间玩家完成的大量操作在启动之后统一处理(建筑、战斗、科研等),导致大量玩家载入内存。同时触发联盟操作,导致大量联盟数据也载入到相应服务进城的内存中。
- 由于大部分需要持久化的数据都是使用的一个接口,里面定义了相同的存盘间隔(或者LRU阈值)。
- 半小时后,巨量在启动的时候载入的玩家和联盟数据超过存盘阈值,被LRU算法筛选出来。
- 并发的序列化以及db操作导致MongoDB卡死……
所以,做了LRU,最好配合限流或者hash操作,防止同时间大量数据一起淘汰。
内存数据的丢失
数据放在内存中,总是给人一种『不靠谱』的感觉,至少是有不少人给我提起过的。
然而游戏的业务中最重要的是什么?我个人认为是响应速度和可用性。
实际上,每个半年左右总会有一些云主机无辜挂掉(该死的阿里云),实践证明,通过一些常规的手段,可以在上层避免掉软硬件crash带来的灾难,至于内存中较新的未持久化的数据,丢了就丢了吧:
- 使用适当的保底存盘策略(每个玩家半小时至少存盘一次),加上登出或者某些特殊操作(例如充值)的即时存盘,保证数据绝大部分数据的正确。
- 敏感操作全部记录track日志(通过kafka一类的方式保存),必要的时候从日志中恢复丢失的操作。
- 使用云服务器自带的备份功能,将所有数据库做1小时间隔的备份。
- 多处引用的数据,只在一个地方保存(或者仲裁),防止部分节点数据损坏带来的一致性问题(那怕出错也不要冲突)。
动态读写内存
Go的动态特性(其实就是Reflect啦),给我们带来了一些运行时调试的便利。起码有两种方式可以实现不停止服务器修改数据:
- 自己实现一套get/set/del的操作,读写内存中的struct object。
- 引入lua,动态的载入自定义lua script读写内存中的数据。
由于Go到目前为止(1.11)仍然没有靠谱的动态更新方案,我们开始考虑,在某些运营业务为主的服务中,尝试用lua来编写业务逻辑,这样可以达到不停机修复bug甚至更新程序。