00游戏存储系统设计

0.注意事项

  1. log库和db库分开。
  2. 缓存队列:减少对硬盘的访问次数,提高效率。
  3. 异步存储:不能卡游戏主线程。
  4. 存盘策略:不要所有数据定时一起存,容易出现longtick。
  5. 避免回档:重要数据立即存盘。
  6. 大数据量:如何高效存储数据。
  7. 性能监控:在运行时出现cpu使用率高等情况,要能快速分析。
  8. 异常处理
    • 内存爆了。
    • 磁盘满了。
    • mysql宕机、redis宕机,如何重连?
    • db宕机,能否重启?
    • redis数据\mysql数据异常时,如何数据回档

1.引用的第3方库

rapidjson:http://rapidjson.org/zh-cn(在组装和解析操作中,使用上很方便。)
hiredis:C++上很好用的访问、操作redis的库。
base64:因为json没法存放bytes类型,所以将bytes用base64转换为string来传递。

2.C++编写的db_sdk

1、提供接口,支持游戏层序列化二进制数据,存储到redis命令中。例如:

const char* sqlStr = "insert t_role(_id,mgr_data,game_data) values('123456789','@','@')";

其中’@'是我实现的占位符,最终结果是将sqlStr和@对应的二进制数据,一起存入到redis的命令队列中。

// 伪代码;
// 1、填充上层游戏数据;
PssSave::Save_Copy copy_data;
copy_data.set_copy_id(101);
copy_data.set_isunlock(true);
copy_data.set_fight_cnt(5);

PssSave::Save_Forge forge_data;
forge_data.set_item_id(20200709);
forge_data.set_isbind(false);
forge_data.set_forge_lev(5);

// 2、序列化到m_buff数组中;
BYTE m_buff[65535] = {0};
int copyLen= copy_data.ByteSize();
copy_data.SerializeToArray(m_buff,copyLen);

int forgeLen = forge_data.ByteSize();
copy_data.SerializeToArray(m_buff+copyLen,forgeLen);

// 3、将二进制数据的地址和长度,记录下来,传参到执行接口;
Params param_pb;
param_pb.bind("mgr_data",(BYTE*)m_buffer,copyLen);
param_pb.bind("game_data",(BYTE*)m_buffer+copyLen,forgeLen);

// 3.1执行驱动接口;
ApiExecuteParamSql(sqlStr,param_pb,NULL,[](bool isSuc,DataTable* result,const char* msg){
	// 异步取出结果;
});

// 4、执行接口取出参数,填充占位符,将sqlStr和二进制数据,一起打包成二进制数据,存入redis中(通过%b来存入);
// 总长度=二进制数据长度+字符串长度-减去@所占长度;
bool ApiExecuteParamSql(const char* sqlStr,Params* param_pb,Tags* tag,CallBack cbFunc){
	std::vector<GString> sqlVec;
	SplitString(sqlStr,"@",sqlVec);	// 按照@字符切割sqlStr;
	if(sqlVec.size()!=param_pb.size()+1){RETURN_FALSE;} // 数量不能对不上,否则就是游戏层占位符号数量与参数数量不匹配;
	int m_SaveLen = strlen(sqlStr) - (sqlVec.size()-1); // 除去@字符的sql语句长度;
	for(auto& iter:param_pb){
		if(!iter){RETURN_FALSE;}
		m_SaveLen += iter->m_len;
	}
	BYTE* m_SaveBuff = new BYTE[m_Savelen];	// 申请一个总长度的数组,用来存放组装后的二进制数据;
	int offset = 0;
	for(int idx=0;idx<sqlVec.size();++idx){
	    if(m_SaveLen <= offset){RETURN_FALSE;} // 偏移量不可能大于总长度,否则就有问题;等于的话,也应该结束了;
	    int sqlLen = strlen(sqlVec[idx].c_str();
	    memcpy_s(m_SaveBuff +offset,m_SaveLen-offset,sqlVec[idx].c_str(),sqlLen ));
	    offset += sqlLen ;
	    if(idx < param_pb.size()){
		    memcpy_s(m_SaveBuff +offset,m_SaveLen-offset,param_pb[idx]->m_buff,param_pb[idx]->m_len));
		    offset += param_pb[idx]->m_len;
	    }
	}
	if(offset != m_SaveLen){
		SafeDelete(m_SaveBuff);	// 长度对不上,本次数据有问题,释放内存;
		RETURN_FALSE;
	}
	const char* redisCmd = "zadd t_role_queue %llu %b";
	UINT64 cmdOId = CreateCmdObjID();
	m_RedisReply = (redisReply)::redisCommand(m_RedisConnectPtr,redisCmd,cmdOId,m_SaveBuff,m_SaveLen);
	
	//5、解析redis的执行结果;
	...
	...
	//6、命令oid和回调函数映射起来;
	// 6.1:这里只是把命令放入到redis命令队列中
	// 6.2:后续由python从命令队列中取出命令sql语句,调用mysql执行以后,python将结果集放入到redis结果队列中
	// 6.3:SDK层在定时器中判断redis结果队列中是否有内容,有内容就取出结果,调用回调函数,回调结果到游戏层
	cmdOId_cbfunc_map[cmdOId] = cbFunc;
}

这里一共提供了3个接口:
ApiExecuteSql() :直接执行一个sql语句。
ApiExecuteParamSql() :执行一个带params的sql语句。
ApiExecuteCache() :执行带缓存功能的sql语句(会先从缓存中进行CRUD)。

2、多线程

  • 根据表名,取出对应的线程名
  • 根据线程名,取出对应的队列名,本次cmd会被放入该队列中。
    • 该队列中不同表的操作cmd,会互相影响。一个阻塞了,队列后的cmd需要等待。
    • 队列间的cmd,互不影响,t_role队列的cmd卡住了,不影响t_guild队列。

3.py脚本

多线程运行,从命令队列中取出数据

插件1.pymysql

1.execute()

1.导入头文件:from pymysql.constants import CLIENT
2.在pymysql.connect()的时候,加上client_flag=CLIENT.MULTI_STATEMENTS标记就可以执行多行sql。
  1. executemany()
    • 支持对同一条sql语句,传输一组参数。这样在批量执行的时候,效率非常高。因为mysql会预先解析sql语句,后续的传输,只传入参数。相当于进行了一次预编译。

4.redis集群,一致性hash算法

  • 增加节点
  • 删除节点

结果:

{
	"_id": "639876213569856",
	"BD": "2020-12-18 19:25:36",
	"game_data": "LK3AJ3G57HG8KJKI9EXCBO5YGNHMOPJUG=="
}

5.缓存

缓存队列:t_player表在redis的缓存。
缓存最大长度:例如配置1000,则缓存1000个活跃玩家的数据。
等待同步队列:dbsdk在执行cache的update操作时,会往该队列加入player_oid
活跃队列:记录了数据player_oid和数据加入队列的时间戳,方便计算出最旧的那条数据

  1. 缓存有两个来源:
    • py脚本的Select、Insert操作
    • dbsdk的CacheUpdate操作
  2. py组装了一条新的缓存数据,此时需要判断一下[缓存队列]是否超过[缓存最大长度]
    • 超过[缓存最大长度],并且最旧的那条数据,不在[等待同步队列]中,那么把最旧的数据移除掉。
    • 未超过[缓存最大长度],那么直接把新数据写入到[缓存队列]中,并且往[活跃队列]中添加一条记录。
  3. dbsdk在执行CacheUpdate操作时,在命中缓存的情况下,会将最新数据更新到redis[缓存队列]中,同时往[等待同步队列]中插入一条记录。
  4. [等待同步队列]中的数据,表示还没写过mysql。后续由python脚本定时写入到mysql表中。写成功以后,从[等待同步队列]中移除。

2.写缓存

6.相关机制

  1. 多线程并发
    业务请求太多了,处理不过来怎么办?
    • 线程可配置
      业务复杂度不一样,日志、战报数据量巨大,请求非常频繁。养成系统的请求非常少。
  2. 缓存
    a. 业务请求太多了,处理不过来怎么办?
    b. 需求尖刺,在某个瞬间发生大量业务请求。
    • 请求缓存
    • 结果缓存
    • 数据缓存
  3. 异常处理
    • 重入
      难免会遇到error、异常,那么命令应该怎么处理?
    • 重连
      和mysql、redis断开连接后,怎么重连。
  4. 集群
    设计目标是为了支持:全球同服。
    • 多进程响应服务(115服、116服可以都连同一个db处理)
    • 分布式处理队列(115服的业务请求,可以分发到不同线程处理)
  5. 性能分析
    写完以后,效率怎么样?
  6. 容灾
    [(game ~ dbsdk.lib) >> redis] >> python >> mysql
    [(game ~ dbsdk.lib) << redis] << python << mysql
    • mysql挂掉了
      • python是最先感知到的,返回一个错误码到redis队列中。game通过dbsdk.lib取出错误码。开始处理异常:先逐渐踢掉在线玩家,避免产生新数据。将当前的最新数据写入到redis中,此时python也要退出搬运工作状态,等待mysql恢复后,再消化redis命令。
      • mysql出问题了,python脚本如果继续工作的话,会造成数据丢失。所以当pymysql调用execute()执行失败时,需要将命令重新push到redis中。支持重入的功能。
    • python挂掉了
      • 这种情况反而是所有挂掉中,最容易处理的。只需要重启python脚本就可以了。但是如果是执行某个命令时,python触发异常,突发暴毙。那么这一条命令理论上会丢失,所以取命令时,必须使用brpoplpush接口。等命令成功被处理后,再从备份队列中移除命令。
      • python脚本启动时,优先检查备份队列中是否有命令。如果有,表明之前发生了异常,那么优先处理备份队列中的命令。
    • redis挂掉了
      • 那么dbsdk.lib会第一时间感知到,返回错误码给到game。同样的,game开始做踢人处理。但此时game不能立即退出,因为最新的数据在game内存中,还没有到redis缓存中。所以game需要保持运行,等待redis重启。
      • python脚本也会感知到redis宕掉了,只要做好重连功能即可。
    • game挂掉了
      • 这就属于上层业务策略了,存盘分为3个队列。

        • 高优先级,立即存盘。(充值的)
        • 中优先级,5分钟存盘1次。(在线的,存home+player)
        • 低优先级,10分钟存盘1次。(离线的,存home)

        目的就是保证存盘数据完整性,不能丢失玩家数据。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值