前言
这一年左右的时间,我参与并完成了一款SLG手游的研发,我负责游戏的服务端研发。这是一款以三国为题材的游戏,除了有三国名将的卡牌养成、多种多样装备养成、PVE,玩家竞技场等常见玩法外,我们的游戏的主打特色是国战和军团战,目前我们正在进行国战部分的开发和优化,军团战部分仍在策划中。军团战预计是一种以公会形式存在的策略玩法,国战是全服玩家达到一定等级就可以参与的混战玩法,玩家的阵营由玩家创建角色时选择的国家(魏蜀吴)决定,进入国战地图之后,服务器会根据玩家选择的国家分配阵营,然后玩家就可以在世界地图中进行攻打敌国城池,在世界地图中,除了三国城池,还有一些资源城池和关隘城池,玩家占领资源城池可以获得一定的资源收益,而关隘一般是设定在两国交界处,只有打通了关隘城池,国家内的人才能通过关隘攻打帝国,这一系列的玩法都非常具有策略性,需要同国玩家之间的相互协作才能共同立足于三国之中。国战的过程中,玩家在混战的同时也能获得一部分的收益。整个国战的玩法充分体现了SLG的策略性。
前文主要讲解我们的游戏的功能和玩法,大家应该都知道,一款游戏里,处处都充满了“数据”,有关卡配置数据,有人物属性数据,有玩家基础数据等,正是这些各种各样的数据,才让我们在游戏中更加数字化的感知游戏的乐趣,本文就从游戏服务器的“数据”角度,来分享我们的游戏的数据管理方案。本文的内容只针对我们的游戏的一种解决方案,不包括其他游戏的解决方案,相信不同的游戏中,架构师应该会对数据管理有不同的解决方案,但万变不离其宗,我们只需掌握数据管理中的核心概念,就可以轻松自如的应对各种应用场景下的数据管理。
我们的游戏服务器的技术架构是用Java来做的,使用Java作为游戏服务器开发语言的成功案例已经越来越多了,Java的网络应用技术也是越来越成熟,不说太多的语言之争,总之最后,我们选用了Java作为了整个游戏服务器的技术支撑,其中网络通信采用Netty,数据与缓存分别选用了MySQL,Memcache和Redis。本文主要讲解数据管理,因此重点也就是讲解MySQL。Memcache和Redis是如何在我们的服务器架构中应用的。
数据分析
在开始讲解如何具体实施之间,我们可以先对游戏中的数据进行一个简单的分析。相信大家一定都常玩游戏,一定可以感觉到,游戏中的虽然处处是数据,但各种数据的性质又不完全一样,比如怪物的攻击力,防御力等战斗属性,一般情况是一组静态数据,这部分数据可以由策划在配置关卡时提前配置好,而玩家的战斗属性,却是玩家在游戏中一系列养成的综合因素所决定,这是一组动态的数据。按照宏观上分,游戏数据大致就可以分为静态数据和动态数据,静态数据,如签到奖励,战斗掉落,抽卡概率等内容,一般情况下均由策划配置好静态表,服务器启动时直接读取静态表,将表中内容加载到服务器内存,使用时直接从内存读取,而动态数据,是根据玩家的游戏进度所决定的,因此这部分数据就需要一个数据管理系统来统一管理,也就是我们常用的数据库,这部分的数据我又细分为热数据和冷数据,热数据指游戏中操作频繁的数据,如体力恢复时间,抽卡免费次数,国战城池状态等,这部分的数据的特点,就是读写频繁,并且其数据结构也是复杂多变的,玩家的每一步操作,都可能涉及到这部分数据的读取或更新。相反,冷数据,则泛指游戏中更新并不频繁,却仍然十分重要的数据,如君主信息,卡牌信息,装备信息等,这部分的数据可能只有特定的操作才会触发这部分数据的变化,比如卡牌的星级,只有当玩家进行了卡牌升星操作,卡牌的星级才有可能发生变化,这部分的数据,更新不频繁,且数据结构稳定,一般在一开始就已经设计好,这部分数据,非常易于管理。基于以上总结,可以得出如下分类图:
技术选型
静态数据
游戏中的静态数据,一般由策划进行配置,一般情况下是xml文件或csv文件,在我们的游戏中,我们采用了读取策划配置的csv文件来获取游戏中的静态数据,服务器启动时加载游戏中的csv静态数据,把所有的静态数据加载到Java的内存中,用Map进行管理。我们的游戏中,使用静态数据的步骤如下:
1.读取csv文件
2.按一定格式对文件内容进行解析
3.将内容封装到JavaBean
4.存放数据到Map中
5.从Map中取出数据使用(使用时)
当然读取静态数据,也有其他的形式,比如配置xml表,从xml表中读取数据,这里说的只是我们的采用的其中一种形式。
热数据
一说到频繁交互,大家第一反应一定是内存,是的,Redis就是一款基于内存的数据库,网上有很多帖子说Redis会如何如何吃内存,在大数据量下的效率如何低下,其实我认为,Redis,它只是一款内存数据库,至于它会发挥怎样的功效,还是得看我们如何去使用它,有一把好枪不代表我们就有了好枪法,还是得根据我们自己的应用场景,合理运用。我也说说我为什么选择Redis的原因吧:
1.Redis是内存数据,其操作均基于内存,可以满足我的“频繁”需求
2.虽然是内存数据库,但同时它也具有RDB和AOF两种持久化功能,可以满足我的“存储”需求
3.Redis支持String,Set,List,Sorted Set和Hash五种数据结构,丰富的数据结构,可以满足我的“数据结构复杂多变”需求
4.Redis可以很容易实现cluster以及主从等分布式扩展,可以满足我的“数据扩展性”需求
当然,Redis的优点远远不止以上四点,但从这四点来说,我觉得Redis就非常适合作为我的游戏动态数据中的热数据的存储。
冷数据
前文分析到这部分数据的特点是更新不频繁且数据结构稳定,我们采用MySQL来存储这部分数据,MySQL不仅结构清晰,更是方便数据管理。MySQL也有许多很适合我们这部分数据的特点:
1.关系型数据库,支持标准的SQL语言,适合结构化的数据
2.支持多种列类型,能存储各种数据格式
3.多线程运行,高并发环境下高效稳定
4.支持事务控制
以上我只列举了我认为比较重要的四点,MySQL已经足以满足我对冷数据的所有存储需求。
缓存数据
这部分数据是在前文在数据分析中没有提到的,因为这部分数据,只是临时的,严格的说,如果不需要考虑服务器的效率问题,没有缓存也是可以照常运行的。缓存的出现是为了减轻服务器的负载,我们可以把客户端的请求全部交给缓存去做,缓存再通过一定的策略与数据库同步,这样,我们就不必要重复的查找或插入同一条数据。这就相当于,在客户端和数据库中间,添加一个挡板,这个挡板先行对数据进行过滤处理,而缓存就是这个挡板。我认为,在服务器中,缓存是一门艺术,用好了缓存,可以让整个架构都看起来赏心悦目。在缓存技术上,我选择Memcache,Memcache作为内存数据库,在高并发及大数据下的性能都是不错的,Memcache采用一致性Hash算法,并且具有数据分布式的特点。
技术实现
Redis存储数据
对于我们游戏中需要使用Redis存储的部分热数据,我做了如下总结:
使用Redis,除了要理解Redis的内存模型原理外,首先还得了解Redis的五种基本数据类型,每一种数据类型都对应不同的Redis操作API,在Java中使用Redis可以使用官方提供的Jedis客户端,Jedis客户端中包含了各种数据类型的操作。Redis既可以作为单服务器使用,也可以做cluster或主从的集群扩展,方便日益庞大的游戏数据的扩展。
MySQL存储数据
在游戏数据中,我对游戏中的冷数据做了一个总结,如下图所示:
完成要存储的游戏数据的分析之后,我们就可以进行具体建模建表的工作,完成对数据的设计。由于在游戏服务器的数据存储中,数据库基本上只是一个玩家下线后的游戏数据临时存放的地方,所以游戏数据表中的关联性并不是特别强,不需要特别严密的数据库设计,只需简单的将玩家所有的数据按照一个userid进行关联即可。
我们使用Druid和Hibernate来管理数据库的连接以及进行增删改查的操作。在使用Hibernate的时候,我们使用了Hibernate4,我们只需将需要存储的Model写成JavaBean,并加上作为数据Model的注解,在启动时,Hibernate扫描到JavaBean会自动为我们创建或更新表结构。
Druid数据库连接池
游戏服务器运行中经常是多个玩家同时在线的,可想而知,如果同时进行某一项涉及数据库的操作时,会并发请求数据库,多个数据库请求就需要我们对多个数据库连接进行有效的管理,当然,我们可以自己写一个数据库连接池来进行数据库管理,但好在前辈们为我们做足了工作,有很多成型的开源数据库连接池可供我们选择,常见的有c3p0、dbcp、proxool和driud等,这里我们使用阿里巴巴公司的开源产品Druid,这是我个人认为最好用的数据库连接池,它不仅提供了数据库连接池应有的功能,更是提供了良好的数据库监控性能,这是我们作为开发人员在遇到性能瓶颈时最需要的东西,感兴趣的朋友可以参考下官方github,根据官方wiki配置一个Druid的数据监控系统,通过系统可以查看数据库的各种性能指标。
Druid在github中的地址是:GitHub - alibaba/druid: 阿里云计算平台DataWorks(https://help.aliyun.com/document_detail/137663.html) 团队出品,为监控而生的数据库连接池
Hibernate
使用Hibernate作为Mysql数据库的ORM框架,主要是因为其良好的封装,首先我个人认为Hibernate的性能是不足与和原生JDBC以及MyBatis这样的框架所匹敌的,封装的更好却带来了更多的性能损失,但我使用他也是看中他良好的封装性,因为我对性能的需求还没有达到很高的级别;其次,Hibernate不适用于写复杂的SQL查询,而MyBatis可以写出一些复杂的SQL。但在我的设计中,我不需要太复杂的查询,基本上我所有的SQL语句的where条件都是”where userid=?”,因此在性能需求上以及易用的对比上,我选择了Hibernate。
Memcache缓存数据
Memcache作为一种内存数据库,经常用作应用系统的缓存系统,在我们的游戏服务器中,我使用Memcache应用在三处地方。
1.MySQL的数据表主键自增ID的生成
2.MySQL的查询结果集的缓存
3.国战数据缓存
MySQL数据表ID生成器
在数据库的设计中,我们的主键ID是自增,但由于自增ID也是会消耗一定的MySQL性能的,因此我使用Memcache封装了一个QQ靓号买号平台ID生成器,其原理就是利用Memcache的incr方法实现ID的自增,Memcache的incr方法是并发安全的,能保证在多线程环境下,MySQL的数据表ID的唯一。
MySQL查询结果集的缓存
我在将Memcache引入到项目作为Mysql数据结果集的缓存系统的过程中,曾进行了多种缓存方案的尝试,具体有以下几种缓存模型:
1.无缓存
这种方式不使用Memcache缓存,游戏服务器的操作直接穿透到Mysql中,这种方式在高并发环境下容易引起Mysql服务器高负载情况。如下图所示:
2.查询使用缓存,更新穿透到数据库,数据库同步数据到缓存
这种方式在客户端表现来看可以提高一部分速度,因为查询操作都是基于缓存的,但实际上Mysql的负担反而加大了,因为每一个更新请求,都需要Mysql同步最新的查询结果集给Memcache,因为每一个更新操作都会带来一个查询操作,当然这个同步过程可以是异步的,但是就算我们感受不到这个同步的过程,在实际上也是加大了数据库的负担。如下图所示:
3.更新和查询都使用缓存,缓存按策略与数据库进行同步
这种方式是比较好的方式,因为客户端的所有操作都是被缓存给拦截下来了,所有操作均是基于缓存,不会穿透到数据库,而缓存与数据库之间可以按照一定策略进行同步,如每5分钟同步一次数据到数据库等,具体同步策略可根据情况具体调整,当然这种方式的缺陷就是一旦服务器宕机,那么在上次同步到宕机这段时间之间的数据都会丢失。如下图所示:
4.更新和查询都是用缓存,更新操作同时穿透到数据库,数据库同步缓存的查询
这种方式是我最终使用的方式,虽然更新操作穿透到数据库,但是我可以在保证查询效率的同时,也保证数据的安全稳定性,因为每一步更新操作都是要进行数据库存储的,并且所有的查询操作可以直接在缓存中进行。如下图所示:
国战数据缓存
在我们的数据管理中,前文提到的所有数据均是基于玩家的个人数据,游戏服务器与Web服务器最大的不同,就是游戏服务器中存在一个“游戏世界”,在这个游戏世界中,多个玩家均操作同一套数据,稍有经验的后端开发者就知道,在多线程环境下操作共享资源,需要处理好共享数据的安全同步问题。在我们的国战玩法中,共享资源就是世界地图上的城池,以及各个玩家的国战信息(玩家被打败后会更改玩家的占领城池信息)。
我们的国战数据是存储在Redis中的,但如果我们不做任何安全问题的保护,就会出现数据的混乱,举个例子,当玩家A和玩家B同时操作一座城池的信息时,假设有以下步骤:
1.玩家A从Redis读取城池信息
2.玩家A对城池信息进行更改
3.玩家A将城池数据入库
4.玩家B从Redis读取城池信息
5.玩家B对城池信息进行更改
6.玩家B将城池数据入库
如下图所示:
以上是一种A和B按顺序分别对城池信息操作的正常情况,在这种情况下,城池信息的数据是不会有异常的,可如果A和B的的操作顺序变成了如下这样,就会产生数据的安全问题了:
1.玩家A从Redis读取城池信息
2.玩家B从Redis读取城池信息
3.玩家A对城池信息进行更改
5.玩家A将城池数据入库
6.玩家B将城池数据入库
如下图所示:
可以假象以下,如果两个玩家按照如上步骤对城池数据进行操作,那么玩家B的操作将完全覆盖玩家A的操作,其最后的结果肯定是不允许的,这种需要将一系列操作执行完的叫做事务,在一个线程对当前的共享数据进行事务操作的过程中,其他线程是不允许操作这个共享数据的。Redis中,是提供了这样的事务操作API的,Redis的multi和exec函数,可以保证如果玩家A正在操作数据,玩家B的操作无效。而在我们的服务器中,我使用了Memcache提供的CAS原子操作来保证同步数据安全。因为国战数据的操作频繁,且大部分数据需要保证同步安全,因此我在客户端和Redis数据库的中间,用了Memcache作为缓存,既保证了国战数据的操作效率,也保证了其事务的特性。
所谓CAS(check and set),即在写操作时,先检查数据是否被别的线程修改过。其基本原理就是对每次存储的对象分配一个版本号(casUnique)。客户端每次读取数据时,调用gets,Memcache返回当前数据的casUnique,在客户端提交数据的时候,客户端将此casUnique一起提交,Memcache会判断此casUnique是否是当前版本最新的casUnique,如果不是,则本次操作失效,如果是,则操作成功。对于操作失效的情况,我的处理是让此请求重复执行一定的次数,如果执行完这个次数之后仍未成功,则返回“系统繁忙,请稍后再试”。其操作过程如下图所示:
如图所示,通过如上图的流程,A和B的操作流程如下:
1.玩家A读取城池信息,并获取到CAS-ID1;
2.玩家B读取城池信息,并获取到CAS-ID2;
3.玩家A对城池信息进行更改
4.玩家B对城池信息进行更改
5.玩家B将城池数据返回给Memcache,在写入缓存前,检查CAS-ID与缓存空间中该数据的CAS-ID是否一致。结果是“一致”,就将修改后的带有CAS-ID2的X写入到缓存。
6.玩家A将城池数据返回给Memcache,在写入缓存前,检查CAS-ID与缓存空间中该数据的CAS-ID是否一致。结果是“不一致”,则拒绝写入,返回存储失败。
通过Memcache作为Redis前面的中间层,既提高了读写效率,又保证了多线程环境下的同步数据的安全。
总结
本文讲解了我们的游戏服务器的游戏管理方案,通过使用MySQL、Redis和Memcache,使它们各司其职,各自承担不同的功能,发挥它们的最大功效,如今我们的游戏仍在紧张的新版本开发中,目前已经登录一些平台进行内测。本文仅作为参考,其中的所有内容是我在对我们当前游戏的开发中所总结出的一点经验,或许并不适用于其他的数据管理方案,但应该可以帮助一些人填坑,这些也是我曾经爬过的坑。本人学艺不精,文中难免有不严谨之处,希望发现的同学给提出来,不要误导了大家。感谢大家欣赏!