游戏db服务器设计的相关问题
存档的简单历史
单机游戏都会把存档以⽂件的形式保存在本地,于是最早的⼀⽚⽹游也是这么⼲的,他们把存档以⼆进制的形式存储为本地的⽂件。到了21世纪,mysql等开源数据库引擎的性能和安全性逐渐获得认可,于是主流的⽹游开始以数据库作为媒介存储玩家存档。但当时的⽤法和⽤⽂件存储实际并没有本质的区别,所有的数据会打包存成blob的形式,然后丢给mysql。
随着⽹络游戏的复杂化,这种结构并不能适应新的需求,⽐如gm想查看玩家的部分数据怎么办,⼜或者排⾏榜需要对某个数据做top100的排⾏。通过blob解开再去处理,效果⾮常不理想。于是,就出现了blob和字段混合使⽤的存储⽅式。字段是冗余与blob存档的,作⽤就是提供给外围服务使⽤。⽽blob才是真正游戏使⽤的,之前怎么⽤,以后还是怎么⽤。
于是,我们今天的存档⼀般是存在数据库中,并且包括若⼲个blob字段以及若⼲个int,char字段。
db服务器⼲什么
保存/读取客户端的存档的媒介
就像前⼀节说的,它以数据作为媒介,实现了数据存储的过程,就好像⽤户把存档传到了云端。
裁剪存取API
说⽩了就是限制访问者的权限,把数据库多加⼀层保护。对外也更加友好,写db的程序员和写逻辑的程序员可以更好的配合。
实现缓存逻辑
可以再数据库前套⼀层缓存逻辑,⽤内存来换时间。因为db服务器了解业务的模式,所以可以写出性能很好的缓存服务。
隐藏实现细节
⽐如,最终落地是⾛数据库,还是⾛⽂件的,或者某个api是对应于哪种缓存或是延迟还是⽴刻写,对外层调⽤者⽽⾔仅仅是⼀个⿊盒。对内,如何需要做引擎升级或是切换,对外层更容易做到完全⽆感。
模块分层
为了实现上⾯的⽬的,我们把模块分成缓冲层,存储逻辑层和落地层
db服务器数据流图
每⼀个请求,都可以通过某⼀条路径直到完成落地。
搭建ssr服务器教程RPC层完成了协议的裁剪(当然也可以不⽤RPC)
负载层完成指令的缓存,任务的分配(多线程协同处理)
逻辑层按模块完成了落地⽅式的适配(可能有的模块是存db,有的是存file)
落地层给逻辑层⽀持,提供统⼀的api⽅法,来完成存储和读取的任务
线程池和mysql连接池
这⾥的线程池是对应上图的TaskThread,多个处理线程可以最⼤化数据的处理,因为⼀些解包,序列化等操作还是⾮常消耗cpu的。另外,区分系统线程和⽤户线程,我们可以保证系统线程响应最⼤化。⽽为了保证⽤户数据的缓存不会来回覆盖等问题,⼀个⽤户⼀般会绑定到⼀个确定的TaskThread。
mysql连接池也就是连接mysql的连接句柄的对象池。连接池的对象数需要保证>=线程池的数据,否则容易造成线程池对mysql连接的竞争。⼀般情况下,⼀个线程不会hold住1个以上的连接句柄。多个mysql连接的⾃动化管理,可以最⼤化mysql的数据吞吐量。
字节流/对象互转
有了这样的⼯具,我们就可以把⼀个⽤户存档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个服之间)唯⼀的。那么,在合服时,我们只需要简单的把数据放到⼀起就可以了。