游戏开发中如何进行高效稳定的数据存取,一直是非常重要的问题。本篇中会尝试分析一下常见的存储方案。
数据库选择
如果是早几年的话,这基本上是个不需要讨论的问题,因为 MySQL 是当时绝对的主流选择,但是最近几年这种情况发生了一些变化,让数据库选择有了更多的方案。
直接读写文件
使用直接读写文件这种方式来保存游戏内数据的公司应该很少很少了,可能只有一些还抱着祖传代码不想撒手的公司还在用。
不过大部分公司在保存日志或者是战斗录像这种单个文件内容比较大,并且不会修改的数据的时候都还会使用这种方法,尤其是对时间久的一些日志,基本上不会查询,只做留档使用了。
采用这种方式的实现,一般是将每种类型数据中每一个 key 作为文件名,数据作为文件内容来存储。每种数据可以使用一到多个文件夹来管理。创建和更新数据的时候一般使用某种序列化方式将内存中需要保存的数据打包,然后写入到适当的目录下。读取数据的时候通过数据类型和 key 找到对应目录下的文件,通过文件 I/O 将其读入内存中,再反序列化得到数据。
MySQL
凭借开源且免费的特点,MySQL 逐渐变成了关系型数据库的代表。在 NoSQL 数据库兴起之前,MySQL 基本上是有统治级别的使用率。
MySQL 虽强,但是这几年它增加了一批非常有竞争力的对手,也令他的统治地位有点一些动摇。不过 MySQL 目前也依然活跃在相当大比例的游戏公司中,毕竟大部分公司对 MySQL 都有丰富的使用经验,很多坑也都踩过填过了,选用一个成熟可靠的数据库也是很正常的事情。
使用 MySQL 存储数据的话,有两种用法,第一是完全按照关系型数据库那种每张表分字段的方法。还有一种是当作 key - value 数据库来用,每张表只有两个字段,key 和 value,会把本张表中要保存的所有字段都使用一种方式序列化成一个值,存入一个 blob 类型的 value 中,不过这种方法在数据更新的时候没办法单独更新字段,只能再次将 value 完整序列化替换掉旧值。
Redis
Redis 应该是后起之秀里面攻势最猛的了,因为它的性能优势,使它深受对实时性要求很高的游戏行业青睐。
它同样开源免费,而且因为全部数据都在内存中,所以它无论是读取还是写入,都对传统的磁盘数据库有碾压的表现。当然缺点也很明显,毕竟相同单位大小的内存比硬盘可是贵的多了,而且系统和硬件支持内存的容量上限也远远小于硬盘。同时数据在内存里也并不安全,如果需要开持久化的话,它的性能会有一些下降。
在 Redis 中保存数据一般采用 “数据类型 + uid” 作为 key,每个数据是一个哈希表,其中保存了它的所有字段。同时 Redis 也可以非常方便的保存一些全局数据。
MongoDB
如果说 Redis 是 NoSQL 数据库中发展最好的内存数据库,那么 MongoDB 就是 NoSQL 数据库中发展最好的磁盘数据库了,在游戏公司中使用的更多。
模式自由(Schema-free)的数据库对游戏公司的业务是非常合适的。不再需要做业务的时候经常改表的字段了。最重要的是,游戏公司的业务基本上是不需要使用关系型数据库提供的复杂特性,也不需要使用 SQL 进行复杂的查询,这使得关系型数据库的优势难以发挥。
不过它也有很多为人诟病的地方,比如相对一些成熟的老牌数据库来说稳定性,性能,内存占用等方面都有一些劣势,但是这些年随着版本不断迭代,各方面进步都很大,已经长期霸占 DB Engine 的第五名了。
MongdoDB 也是 key - value 数据库,不过它在外层有一个集合的概念,基本上相当于关系型数据库中的表了,使用的时候将不同功能的数据放在不同的集合中即可。
存储方案
一个完整开发的较大规模的游戏项目,基本上不会只是用一种存储数据的方式。因为游戏中对各种数据的使用和保存方式都有很大区别,这导致了很难有一种完全的方法可以搞定所有数据的存储。所以一般都是采用多种方式混合的数据存储方案。
加载数据
数据被加载大概有两种处理方式,启服时全量加载不释放,按需加载使用完释放。
适合全量加载不释放的数据必须规模不是太大,且要使用非常频繁,这样才有常驻内存中的价值。一般都是用来保存进程的全局数据。
按需加载使用完释放,一般用来保存跟个体有关的数据,这部分数据当个体没有操作的时候,大部分情况下是完全用不到的。这种形式可以再加上一层缓存层来加快热点数据加载,这个缓存层如果需求比较简单可以自己实现,如果需要一些高级功能,可以使用 Redis 来做。
写入数据
写入数据的方式大概有四种,每次修改直接落地,定时全量落地队列,定时脏数据落地队列,数据离线全量落地。
每次修改直接落地的代价比较大,要严格控制这类操作的使用范围,一般只有高优先级的操作引起的数据变更会考虑使用每次修改直接落地的操作。
定时全量落地队列,是指在某个对象的数据发生变更以后,将其加入到一个变更对象列表中,然后每隔一段时间,将列表中对象的所有数据进行一次落地。
定时脏数据落地队列,是指在对象的某个数据发生变更以后,将对象和修改的字段加入到变更对象列表中,然后每隔一段时间,将列表中的变更对象被修改字段的数据落地。
数据离线全量落地,是某个对象要从内存中删除这种情况,在删除之前会对这个对象进行一次全量的保存,一般作为别的方案的保底。
数据分类
选择存储方案需要根据实际需求来考量,数据的不同使用场景和范围,都对应了不同的存储方案。
玩家数据
游戏中玩家数据的总量是很大的,即使是日活月活都不太高的游戏,它的注册用户可能也是很惊人的一个量。这就导致了玩家数据在起服时全部加载到内存中变成不现实的事情。
玩家的数据大概有两类组成,第一类是别的系统可能会在玩家离线的时候读取的部分,可能有的是玩家的名字,uid,签名,等级,所属工会等基本信息。另一类是别的系统不会读取,只有玩家自己登录以后才会使用的数据,比如背包中的物品数量,经验值,体力值这些。在开发中有时也会称第一类为对外数据,第二类为对内数据。
对内数据和对外数据因为使用场景不同,一般会分表来保存,不放在一起,方便读取,加载策略也有一些区别。对内数据只有在玩家登录的时候才会从硬盘上加载进游戏服的内存中,玩家下线以后就可以释放掉这部分内存了,一般不会缓存这部分数据,而是可能会做一个延迟删除对象的设定,来应对玩家可能的立马再次上线。对外数据经常要被其它系统读取,比如好友之类的系统,一些高等级玩家的数据可能会经常被访问,所以一般会使用带缓存的按需加载。
玩家数据的变更是非常频繁的,一个在线玩家即使是完全不做任何操作,可能也会有一些定时器会修改他的数据。如果每次的修改都落地到硬盘显然是不现实的。一般采用定时脏数据落地队列的方案进行数据更新,有时也会再加上数据离线全量落地作为保底。
全局数据
游戏中有一些全局数据,这些数据一般单个服务器只有一份,数据量不会太大,但是访问频率很高。像是当前开放的活动中的数据,以及一些功能的全服状态。
这种全局数据直接在启动时全部加载进内存即可,整个功能的开放周期中都存在于内存里,数据保存方式可以按需选择,一般也用定时脏数据落地队列即可。
功能数据
很多功能性系统的数据是会脱离玩家数据单独保存的,比如像公会,聊天这种,这部分数据相对比较独立,一般只有自己系统会用。
这些系统的总数据量可能会非常大,尤其是像需要保存全部记录的聊天系统,会越来越大。这些数据如果也是启动时全部加载是不太现实的,一般使用按需加载使用完释放,并且要加上缓存系统,因为这些数据会有明显的优先级,比如高排名的公会信息可能经常被查看,同样在一个会话中最新的聊天记录也会被经常查看。
日志数据
日志类数据不仅包含了日志,还有类似战斗录像这种数据。如果说其它数据只是比较大的话,那日志类数据就真的是巨大了,远超其它数据很多个数量级,这就导致了它需要一些特殊的手段来处理。
一般处理日志类数据有两种方法,存数据库,或者是把每小时的日志存进一个独立文件里。存数据库可以方便直接进行查询,不过用这种方法的公司比较少,因为日志数据实在是太多了。存成文件的好处是,可以直接通过目录查看,清晰直观,也不需要生产环境的数据库权限就可以查看这些文件,如果需要进行分析,可以使用一些日志分析工具进行后期分析。
有一些项目要求只要保存一定时间以内的日志即可,比如 30 天,这种就比较简单,每天把最后一天的日志删除即可,总量一直在一个可以接受的范围内。但是有一些项目是需要永久保存日志的,这种需求就需要将非临近日期的日志进行二次处理,一般是将其打包压缩,然后可以选择一种更加便宜的存储介质来保存,比如说磁带。