游戏db服务器设计的相关问题

存档的简单历史

单机游戏都会把存档以文件的形式保存在本地,于是最早的一片网游也是这么干的,他们把存档以二进制的形式存储为本地的文件。到了21世纪,mysql等开源数据库引擎的性能和安全性逐渐获得认可,于是主流的网游开始以数据库作为媒介存储玩家存档。但当时的用法和用文件存储实际并没有本质的区别,所有的数据会打包存成blob的形式,然后丢给mysql。

随着网络游戏的复杂化,这种结构并不能适应新的需求,比如gm想查看玩家的部分数据怎么办,又或者排行榜需要对某个数据做top100的排行。通过blob解开再去处理,效果非常不理想。于是,就出现了blob和字段混合使用的存储方式。字段是冗余与blob存档的,作用就是提供给外围服务使用。而blob才是真正游戏使用的,之前怎么用,以后还是怎么用。

于是,我们今天的存档一般是存在数据库中,并且包括若干个blob字段以及若干个int,char字段。

db服务器干什么

保存/读取客户端的存档的媒介

就像前一节说的,它以数据作为媒介,实现了数据存储的过程,就好像用户把存档传到了云端。

裁剪存取API

说白了就是限制访问者的权限,把数据库多加一层保护。对外也更加友好,写db的程序员和写逻辑的程序员可以更好的配合。

实现缓存逻辑

可以再数据库前套一层缓存逻辑,用内存来换时间。因为db服务器了解业务的模式,所以可以写出性能很好的缓存服务。

隐藏实现细节

比如,最终落地是走数据库,还是走文件的,或者某个api是对应于哪种缓存或是延迟还是立刻写,对外层调用者而言仅仅是一个黑盒。对内,如何需要做引擎升级或是切换,对外层更容易做到完全无感。

模块分层

为了实现上面的目的,我们把模块分成缓冲层,存储逻辑层和落地层

db服务器数据流图​​​​​

每一个请求,都可以通过某一条路径直到完成落地。

RPC层完成了协议的裁剪(当然也可以不用RPC)

负载层完成指令的缓存,任务的分配(多线程协同处理)

逻辑层按模块完成了落地方式的适配(可能有的模块是存db,有的是存file)

落地层给逻辑层支持,提供统一的api方法,来完成存储和读取的任务

线程池和mysql连接池

这里的线程池是对应上图的TaskThread,多个处理线程可以最大化数据的处理,因为一些解包,序列化等操作还是非常消耗cpu的。另外,区分系统线程和用户线程,我们可以保证系统线程响应最大化。而为了保证用户数据的缓存不会来回覆盖等问题,一个用户一般会绑定到一个确定的TaskThread。

mysql连接池也就是连接mysql的连接句柄的对象池。连接池的对象数需要保证>=线程池的数据,否则容易造成线程池对mysql连接的竞争。一般情况下,一个线程不会hold住1个以上的连接句柄。多个mysql连接的自动化管理,可以最大化mysql的数据吞吐量。

字节流/对象互转

许多类库现在都可以支持这个需求,比如json,protocolbuf。我这边推荐的是https://www.codeproject.com/Articles/15375/AltSerializer-An-Alternate-Binary-Serializer这个库,优点是比较简单,很容易实现二次开发。

有了这样的工具,我们就可以把一个用户存档PlayerProfile转成一个byte[](反之亦然)了。但是,我们需要特别注意几个问题。

1.支持自定义类型的序列化

2.支持存档升级,也就是byte[]可能是比较古老的版本,新版本同一个类型可能增加了一个成员。这时候同样要保证反序列化之后的对象成员数据是正确的。

还好,一般的序列化库都支持这两点,只是在配置时需要留意下。

Db服务器的缓存模式

我们一般在ThreadTask中实现一套缓存模式,也就是当RPC层给出需要存储的数据,ThreadTask可以先存在内存中,不急着落地。当然,该如何来判定是否需要开启缓存开关就要看具体的业务逻辑需求了。

不开启缓存

相当于ThreadTask直接落地,好处是数据库可以比较及时的完成落地,增加安全性。坏处就是性能比较差,因为落地的性能对比内存操作还是差很多的。此外,缓存系统实现的各种优化方式都没有作用了。

开启缓存

开启后也可以根据任务类型的不同,一部分走缓存,一部分走实时回写。

缓存需要考虑内存的开销,如果缓存对象数量过多时,应该考虑通过LRU等策略移除老对象。

开启缓存的优势

直接内存返回数据:如果用户读数据时,命中缓存,就可以不通过访问db来完成一次数据读取。

可以合并写回:延时写回,如果在延时期间又收到了存档请求,就可以直接覆盖上一次的请求内容。

可以做优先级调度:一段时间延时后,就可以来对任务的优先级进行调度。

何时存档

对于一个网络游戏而言,最安全的方式当然是数据一改变就立刻落地,放到安全的存储介质中。但这种做法无疑会带来很多的IO开销,于是在这里,我们讨论下通常几个重要的存储时机。

下线时以及定时存档(5分钟)

下线时存档是必然的。定时存档是一种兜底的方案,也就是如果保障了如果出现意外情况,玩家最多只会有5分钟的回档。(当然,服务器硬盘故障等不在这个意外范围内)

获取重要道具或者充值时存档

获得重要道具(稀有装备,SSR卡牌)或者充值,对玩家的游戏体验是非常重要的。所以,如果出现回档,我们不希望会破坏玩家这种良好的体验。

玩家间操作时存档

玩家间的操作,比如道具交易,如果不同时存档,可能会造成时间视图上,某一个时间点,某一个道具是存在2份的情况,也可能有复制道具的危险。所以,这种情况下的存档,可以更好保证数据的一致性。

关闭服务器操作

关闭服务器时,db服务器最主要需要考虑的问题是,整个服务器组的数据是不是都已经写回到落地介质(文件,mysql等)了。

所以关闭时,应该需要分为2个阶段。

第一阶段,收集来自其他服务器的数据,并得到已经不再产出更新的数据的消息。如何保障不在产生更新的消息是和各个服务器的业务逻辑息息相关的,比如游戏服务器GameServer,如果已经保证没有玩家在线并且关闭了客户端连接通道,可以认为GameServer就不再会产生更新的存储消息了。

第二阶段,保证db服务器内的缓存都处理完成,并完成数据落地。这个步奏可控性相对比较强,主要是在缓存模式,保证所有数据flush完成就可以了。

唯一存储id

游戏服务器很多物件(object)都会有一个id,用于互斥管理,比如玩家id,道具id,装备id。这些id因为会出现在玩家的交互中(聊天需要互相知道玩家id),甚至进行数据交换(交易会把一个道具id的数据给到另一个玩家)。我们就要求这些id至少是全服唯一的。

但是,有一个麻烦的事情,就是当需要合服时(几个服的玩家合并到一个服参与游戏)。我们就需要保证几个服务器的数据,在合并后应该也是互不重复的。

有一种办法就是合服时,做一次数据id的转换,把重复的id转一次,保证再次唯一。但这种操作之后,原来的道具交易log等要么就废了,要么也要跟着转换(转换log的代价可比存档大多了)。

另一办法,就是生成id时就保证n个服务器生成的id不重复(n可能是几百)。

  public ulong GenId(IdSegTypePersistence type)
  {
            ulong genId = (ulong)type * 1000000000000000000 + index * 1000 + (ulong)severId;
            index++;
            return genId;
 }

type指的是类型(区分道具,玩家,宠物等等)。index就是该类型内的偏移值。severId就是服务器id(1服还是3服)。这种方法在永久性id生成时就保证id是(999个服之间)唯一的。那么,在合服时,我们只需要简单的把数据放到一起就可以了。

猜你喜欢

转载自blog.csdn.net/narlon/article/details/83384473