接着上篇文章 变形记---抽象接口,屎山烂代码如何改造成优质漂亮的代码 ,我一直想写一些对年轻人有帮助的文档来,刚好最近有空就零零碎碎写了一些,罗列了一些提纲然后改再删,花了一个礼拜的时间。
写这一系列的 “变形记”,也是因为最近我给M部门面试服务器主程序开发的职位,我只问他们的架构设计经验,我发现相当一部分5-12年“本应该有足够开发经验”的开发组长,或开发主程序缺乏设计,缺乏容错,缺乏创新,比如一些服务器宕机如何崩溃拉起恢复玩家数据,数据库的异步线程读写如何避免被其他线程写回呢,至少目前能听到合理方案的面试者的回答不多,这也是我想写这篇文章的出发点,以此来分享给大家, 不仅仅是为了应付面试,更是解决实际问题的一种思路。
如题,举例说明:游戏服务器(或者其他业务服务器)正常运行中出现了异常崩溃,可能是异常断电引发,可能是云服务商的软硬件问题引发,这种情况下,你们的服务器架构有没有做灾难恢复处理? 使得用户/玩家的数据不丢或尽可能的减少丢失。
如果这个问题放在10年前,大家不一定会考虑这么远,服务器宕机了,可能会存在-10分钟左右的玩家数据丢档,大不了重启后给玩家补偿邮件而已,现在不敢这样了,现在大家都在逐步地做到完美,即使服务器异常宕机,也使得玩家的数据能及时存档和恢复,对于玩家几乎无感。
所以我们先聊一聊常见的服务器的数据保存策略:
数据保存策略
以前常见的长连接服务器的保存策略是
1.玩家和排行等其他表的数据从数据库加载到内存中
2.所有玩家的一些升级,加资源等影响其属性变化的都直接修改内存,但并不会立即写数据库(有些重要的数据可能会立即写库,玩家的或者其他模块的数据是定时写的)
3.服务器有定时器,每隔3-10分钟触发一次数据库落盘写的逻辑,需要遍历有变化的玩家列表,然后依次保存到数据库中。
编辑
大家发现了没有,如果服务器在本次触发定时器需要将所有的玩家数据保存到数据库中之后,又有一些玩家的等级,货币资源,技能等发生变化了,且在下次触发定时器保存到数据库的逻辑之前发生了宕机或非正常停服,那么这种场景就会造成短暂的玩家丢档问题。
缓存策略
因此我们需要借助缓存来将玩家内存里的数据及时同步到这里,即使发生宕机,你仍然可以从缓存里将缓存的数据加载到内存中。
编辑
这个缓存,可以是映射到一块硬盘的共享内存,也可以是memcache,redis等这种内存数据库,whatever,各有千秋优缺点。
我以前做RPG的卡牌策略游戏的时候,做过改造,那个时候使用redis的团队还不多,相关的用法并不熟悉,所以我们采用共享内存映射到硬盘上的一块大文件来实现,我们来看下Shamem的声明和实现:
class ShareMem { public: /*创建ShareMem 内存区 * * key 创建ShareMem 的关键值 * * Size 创建大小 * * 返回 对应ShareMem保持值 */ static HANDLE createShareMem(const char* szFile, uint64_t Size, bool bClear); /*映射ShareMem 内存区 * * handle 映射ShareMem 的保持值 * * 返回 ShareMem 的数据指针 */ static char* mapShareMem(HANDLE memHandle, uint32_t dataSize, uint64_t offset); /*关闭映射 ShareMem 内存区 * * MemoryPtr ShareMem 的数据指针 * * */ static void unMapShareMem(char* MemoryPtr, uint32_t dataSize); /* 关闭ShareMem * handle 需要关闭的ShareMem 保持值 */ static void closeShareMem(HANDLE memHandle); static void flushShareMem(char* pBaseAddr, uint32_t dataSize); };
HANDLE ShareMem::createShareMem(const char* szFile, uint64_t Size, bool bClear) { #ifndef WIN32 HANDLE fd = open(szFile, bClear ? O_CREAT|O_RDWR|O_TRUNC|O_LARGEFILE : O_CREAT|O_RDWR|O_LARGEFILE, 00777); if (fd < 0) { LOG(ERROR)("open %s failed.errno %d", szFile, errno); abort(); } lseek(fd, Size - 1, SEEK_SET); write(fd, "", 1); return fd; #else HANDLE hFile = CreateFile(szFile, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, bClear ? CREATE_ALWAYS : OPEN_ALWAYS, 0, NULL); HANDLE hMem = CreateFileMapping(hFile, NULL, PAGE_READWRITE, HIUINT32(Size), LOUINT32(Size), NULL); CloseHandle(hFile); return hMem; #endif } char* ShareMem::mapShareMem(HANDLE memHandle, uint32_t dataSize, uint64_t offset) { #ifndef WIN32 return (char*)mmap(NULL, dataSize, PROT_READ | PROT_WRITE, MAP_SHARED, memHandle, offset); #else return (char*)MapViewOfFile(memHandle, FILE_MAP_READ | FILE_MAP_WRITE, HIUINT32(offset), LOUINT32(offset), dataSize); #endif return 0; } void ShareMem::unMapShareMem(char* MemoryPtr, uint32_t dataSize) { #ifndef WIN32 munmap(MemoryPtr, dataSize); #else UnmapViewOfFile(MemoryPtr); #endif } void ShareMem::closeShareMem(HANDLE memHandle) { #ifndef WIN32 close(memHandle); #else CloseHandle(memHandle); #endif } void ShareMem::flushShareMem(char* pBaseAddr, uint32_t dataSize) { #ifndef WIN32 //msync(pBaseAddr, dataSize, MS_ASYNC); #else bool bRet = /*FlushViewOfFile(pBaseAddr, dataSize)*/true; if (!bRet) { printf("%d", GetLastError()); } #endif }
因此,当我们将玩家player对象的数据序列化到二进制数据中,保存到一个叫PlayerData的结构中,
struct PlayerData { enum {MAX_DATA_SIZE = 40960 - sizeof(uint64_t) - sizeof(uint32_t)}; uint64_t playerId; uint32_t dataLen; char payload[MAX_DATA_SIZE]; };
那么我们将玩家PlayerData数据的更新,快速查找,缓存的初始化封装成SmData,
typedef PlayerData TData; class SmData { public: SmData() { m_smHandle = NULL; } void init(const char* szFile, uint64_t Size, uint32_t trunckSize, bool bClear) { if (m_smHandle != NULL) { ShareMem::closeShareMem(m_smHandle); } m_smHandle = ShareMem::createShareMem(szFile, Size, bClear); m_smSize = 0; m_maxSize = Size; m_trunkSize = trunckSize; m_playerSmMap.clear(); playerDataMap.clear(); } void finit() { if (m_smHandle != NULL) { ShareMem::closeShareMem(m_smHandle); } } public: void append(uint64_t key, TData* pData) { m_playerSmMap.insert(make_pair(key, m_smSize)); //根据文件位置,计算出映射的位置,然后再计算出真正的位置来 uint32_t baseMapSize = (m_smSize / m_trunkSize) * m_trunkSize; uint32_t mapSize = ((m_smSize + sizeof(TData)) / m_trunkSize - m_smSize / m_trunkSize + 1) * m_trunkSize; char* pBaseAddr = ShareMem::mapShareMem(m_smHandle, mapSize, baseMapSize); uint32_t offsetSize = m_smSize - baseMapSize; char* pPlayerData = pBaseAddr + offsetSize; memcpy(pPlayerData, pData, sizeof(TData)); m_smSize += sizeof(TData); ShareMem::unMapShareMem(pBaseAddr, mapSize); } bool getData(uint64_t key, TData* pData) { bool bFound = false; hash_map<uint64_t, PlayerData>::iterator itePlayerData = playerDataMap.find(key); if (itePlayerData != playerDataMap.end()) { pData = &itePlayerData->second; bFound = true; } else { //根据文件位置,计算出映射的位置,然后再计算出真正的位置来 hash_map<uint64_t, uint64_t>::iterator itePlayerDataPos = m_playerSmMap.find(key); if (itePlayerDataPos != m_playerSmMap.end()) { uint32_t baseMapSize = (uint32_t)((itePlayerDataPos->second / m_trunkSize) * m_trunkSize); uint32_t mapSize = ((itePlayerDataPos->second + sizeof(TData)) / m_trunkSize - itePlayerDataPos->second / m_trunkSize + 1) * m_trunkSize; char* pBaseAddr = ShareMem::mapShareMem(m_smHandle, mapSize, baseMapSize); uint32_t offsetSize = itePlayerDataPos->second - baseMapSize; char* pPlayerData = pBaseAddr + offsetSize; memcpy(pData, pPlayerData, sizeof(TData)); ShareMem::unMapShareMem(pBaseAddr, mapSize); bFound = true; } else { LOG(ERROR)("can not found %llu player data", key); } } return bFound; } void update(uint64_t key, TData* pData) { hash_map<uint64_t, uint64_t>::iterator iteSm = m_playerSmMap.find(key); if (iteSm != m_playerSmMap.end()) { uint32_t baseMapSize = (iteSm->second / m_trunkSize) * m_trunkSize; uint32_t mapSize = ((iteSm->second + sizeof(TData)) / m_trunkSize - iteSm->second / m_trunkSize + 1) * m_trunkSize; char* pBaseAddr = ShareMem::mapShareMem(m_smHandle, mapSize, baseMapSize); uint32_t offsetSize = iteSm->second - baseMapSize; char* pPlayerData = pBaseAddr + offsetSize; memcpy(pPlayerData, pData, sizeof(TData)); ShareMem::flushShareMem(pPlayerData, sizeof(TData)); ShareMem::unMapShareMem(pBaseAddr, mapSize); LOG(DEBUG)("player %llu serialize", key); } else { if (m_smSize + sizeof(TData) <= m_maxSize) { m_playerSmMap.insert(make_pair(key, m_smSize)); //根据文件位置,计算出映射的位置,然后再计算出真正的位置来 uint32_t baseMapSize = (m_smSize / m_trunkSize) * m_trunkSize; uint32_t mapSize = ((m_smSize + sizeof(TData)) / m_trunkSize - m_smSize / m_trunkSize + 1) * m_trunkSize; char* pBaseAddr = ShareMem::mapShareMem(m_smHandle, mapSize, baseMapSize); uint32_t offsetSize = m_smSize - baseMapSize; char* pPlayerData = pBaseAddr + offsetSize; memcpy(pPlayerData, pData, sizeof(TData)); m_smSize += sizeof(TData); ShareMem::flushShareMem(pPlayerData, sizeof(TData)); ShareMem::unMapShareMem(pBaseAddr, mapSize); LOG(DEBUG)("player %llu serialize new", key); } else { playerDataMap[key] = *pData; } } } public: uint64_t getSmSize() const { return m_smSize; } private: hash_map<uint64_t, uint64_t> m_playerSmMap; //玩家基础数据到共享内存的映射 playerid,数据在文件中的位置 HANDLE m_smHandle; uint64_t m_smSize; uint64_t m_maxSize; uint32_t m_trunkSize; hash_map<uint64_t, TData> playerDataMap; //玩家基础数据 };
如何使用呢
SmData smPlayerData; smPlayerData.init("playerdata.cache", SMFILE_MAX_SIZE, g_gs.m_sysPageSize, true);
如果有新增的玩家数据,那么你需要将player数据序列化到PlayerData对象playerData中
smPlayerData.append(playerId, &playerData);
如果需要更新缓存则使用
smPlayerData.update(pPlayer->m_playerId, &playerData);
一般系统分配4G的共享内存即可满足 10W人的缓存记录,不过共享内存有个缺陷就是如果当前服务器的硬盘坏了或磁盘满,那么你映射到当前硬盘的共享内存就会出现异常,可能造成一些新的数据无法被添加进来,也无法及时更新,另外由于缓存块是按照固定大小来分配和查找的,当缓存块的大小超过限制就无法添加新的缓存块进来,也会造成可能丢档的情况。
随着硬件和技术越来越成熟,redis,memcache提供了更方便的操作api来给开发者提供服务,就拿redis来说,无论是哨兵模式还是集群模式的数据库服务商,他们都可以提供良好的容灾,主从备份服务,大大减少开发者对缓存容灾的过度考虑和设计。
崩溃拉起
上面的图只是说了如何借助缓存存数据,没有说一旦崩溃如何拉起数据,所以这里我做个补充: 即时数据都通过内存来修改,并同步至缓存中,内存数据约=缓存数据,因此缓存里存在的是热数据,需要保存的数据都有脏数据标志,定时器触发后,将有脏数据标志的玩家数据保存到数据库中(冷数据),如果发生宕机,可按照下列流程来处理:
编辑
这个图只是个例,并不是所有的服务器都是这样的。
因为有一些游戏服务器他是启动的时候将所有玩家的数据都加载到内存中,这个时候如果缓存里有数据,就要以缓存的数据为准,因为数据是先内存-》缓存-》数据库,缓存里没有了再以数据库中的为准。
有一些服务器却是只加载一部分活跃玩家的数据,那么加载的时候也需要以缓存的为准,当内存里找不到,则继续从缓存中找,缓存未命中,则从数据库中查找。
实际上这种崩溃拉起的思路和方案不仅仅适用于游戏服务器这种行业,在直播,云会议等高可用,可容灾的服务行业也有成熟的方案。