游戏的存储方案

1.存储要求

  在游戏中,玩家、帮会等数据需要存到数据库中,这些数据要能快速存储,且存储数据不影响游戏进程GameServer的响应速度,同时要保证服务器重启时加载数据需要尽可能的快,服务器宕机时数据不丢。

2.存储结构

  为了让服务器重启时加载数据尽可能的快,且服务器宕机时数据不丢,本文采用共享内存的方案。进程异常退出时,共享内存仍然存在,进程再次启动时可以直接读取上次写的数据。GameServer主线程MainThread将数据写到共享内存,GameServer存储线程StorageThread将共享内存的数据写到数据库中。服务器重启时,MainThread从共享内存读取数据,共享内存没有的数据StorageThread从数据库读取。

  本文将游戏中的数据分为动态数据和静态数据。动态数据为玩家数据,这部分数据在玩家上线时,StoargeThread从数据库加载到共享内存,供MainThread使用。玩家下线时,StoargeThread将数据存储到数据库,MainThread清理共享内存。静态数据为帮会等公共数据,这部分数据为多个玩家共享,需要常驻共享内存,在单进程架构中静态数据被GameServer加载使用,在多进程架构中,静态数据被公共服务器WorldServer加载使用。

  对数据的存储结构有两种方案:一,每个动态数据为共享内存和数据库中的一条记录,对静态数据(如帮会等),k个帮会组成一个数据块为共享内存和数据库中一条记录;二,每个动态数据为共享内存和数据库中一条记录,对静态数据(如帮会等),每个帮会为共享内存和数据库中一条记录。两种方案区别在于静态数据的存储结构,因为玩家上线加载动态数据,下线释放动态数据,频繁的加载、释放决定每个玩家数据为共享内存和数据库中一条记录,便于管理每个玩家的数据。然而静态数据常驻共享内存,因此只有加载没有释放,两种方案均可。静态数据两种方案优劣点如下:一,多个帮会存为一条记录,例如10个帮会存为一条,100个帮会只需要加载10条记录,服务器启动时加载速度快;而每个帮会存为一条记录,需要加载100条记录,服务器启动时加载速度慢;二,多个帮会为一条记录,如果某个帮会解散,记录里会存在碎片,合服时,A服的一条记录里可能只有5个帮会,B服的一条记录里可能只有1个记录,如果不合并这两条记录,会导致空间浪费,因此需要人为合并这两条记录,但人为干预合服过程容易出错;如果每个帮会存为一条记录,合服时只需要将两个数据表合并即可;三,若每个动态数据、静态数据均为一条记录,可为所有动态、静态数据定制统一的模板,用于管理这些数据的共享内存。

3.状态机

  共享内存中每条记录的状态共有七种:FREE表示该记录空闲,可以分配数据;LOADING表示该记录正在加载数据;NORMAL表示该记录有数据且没有被修改;MODIFIED表示该记录的数据已经被修改;SAVINGDB表示该记录的数据正在往数据库存储,并且存储后需要清理共享内存中该记录;DELINGDB表示该记录的数据正在从数据库中删除,并且删除后需要清理共享内存中该记录;DELINGMEM表示清理共享内存中该记录,然后将记录状态标记为FREE。状态之间切换如下图所示。共享内存的每条记录初始状态为FREE,表示可被分配数据,在加载数据时,变为LOADING状态,加载完数据并将数据复制在共享内存上后,记录状态变为NORMAL状态,此时数据可被MainThread使用,NORMAL状态的数据被修改后,变为MODIFIED状态,动态数据由于属性变化非常频繁,因此不在每个属性变化时都尝试修改为MODIFIED状态,而是定时置为MODIFIED状态,静态数据,变化不频繁的可以在数据变化时置为MODIFIED状态,变化频繁的可定时置为MODIFIED状态。状态为MODIFIED的静态、动态数据,都会定时存到数据库中,存完后共享内存中记录状态置为NORMAL。动态数据在玩家下线时,需要将玩家数据存到数据库并清理共享内存中的相应记录,因此先将玩家数据置为SAVINGDB,表示正在存数据库,存完数据库后将记录置为DELINGMEM状态。在少数情况下,需要删除数据库相应记录(例如删除邮件等),此时将共享内存置为DELINGDB状态,删完数据库的记录后,将共享内存置为DELINGMEM状态。将共享内存状态置为DELINGMEM状态后,或者StorageThread通知MainThread回收状态为DELINGMEM的共享内存记录,或者MainThread定时垃圾回收。

4.线程通信

  MainThread的存取请求写进一写一读的无锁循环队列,然后StoargeThread从队列中读取请求,在StorageThread内再开十个工作线程WorkThread负责处理这些请求,每个WorkThread均有一个无锁循环队列,StorageThread将存取请求随机写进一个WorkThread的无锁循环队列,WorkThread读取请求并处理。StorageThread为了避免处理重复的请求,维护一个全局的set,set中元素为存取请求对应的数据库键值,StorageThread每次读取MainThread的请求时,均将请求对应的键值加入set。每个WorkThread发现队列有请求,取出请求并处理,请求处理完后,将请求从全局set中删除,StoargeThread和每个WorkThread每次操作set时,均需要加锁。因为本文是多进程架构,需要对数据加上版本号,每次存完数据,数据库中的版本号加一,如果两个进程同时存一个玩家的数据,会因为版本号冲突导致某个进程存数据失败,便于校验两个进程同时写一份数据。在对数据加上版本号后,若MainThread连续对某个玩家发出两次存数据的请求,假设某玩家数据键值为user_3_11111,当前版本号为2,两个请求A、B均为将该玩家数据存到数据库。WorkThread1处理请求A时,发现共享内存中记录的版本号为2,此时线程WorkThread2处理请求B时,共享内存中记录的版本号也为2。请求A处理完,数据库中该记录版本号变为3,此时请求B会失败,因为请求B的版本号为2低于数据库该记录版本号3,因此认为共享内存的记录比数据库的记录老,在请求B失败时,会认为是两个进程同时写一条记录,造成干扰;若请求在处理完后才从set删除,在请求B尝试进入set时,发现B请求的键值user_3_11111已经set中,则忽略B请求。