最近主要忙着功能开发,一时感觉没什么值得分享的。索性就数据存储这个话题聊聊,顺便自己也理一下思路,希望后面做起来能顺利一点,目前我们项目数据存储还完全没做,只是简单的把每个玩家数据单独存一个文件在硬盘上:-)
关于性能
网络游戏的数据存储有其特殊性。游戏是交互性比较强的产品,对系统响应时间的要求特别高,如果在处理逻辑时同步地去进行数据库 IO 是不可接受的。另外玩家在游戏过程中数据变化非常剧烈,也就是说写数据的频率会特别高,如果设计不合理,数据存储是很容易成为系统瓶颈的。
不妨拿 BBS 系统来做一个比较。我们逛 BBS 时大部分时间都是在浏览,相比之下发帖行为是小概率事件。用户花 20 分钟写完一个帖子点击发布按钮,3 秒钟或 10 秒钟后提示发送成功都是完全可以接受的,甚至提示发送失败要求用户重新再发一次也不是什么大问题。所以对于上述 BBS 系统,在用户提交请求时同步去读写数据库是完全可行的。而网络游戏要做的是同时支持数千人以数秒为间隔不停地发贴,要保证响应时间控制在数毫秒内,还要保证发出去的帖其他人能立即看到。
业内的通行做法是将数据库的职能尽量简化,只拿数据库做数据最终的备份仓库来用。即在服务器进程启动时从数据库中加载所有的全局数据,在玩家上线时从数据库中加载玩家的所有数据,之后所有的逻辑操作都在内存中进行,从而避免游戏过程中读写数据库带来的响应过慢。变化的数据以一定的时间间隔(一般为数分钟)异步写入数据库,这样就缓解了数据库写的压力。
很显然,这样一来数据库所需要的功能是如此简单,只需要有 GET 和 SET 两个接口就完全够用。所以选择合适的 NoSQL 数据库来提高数据存取性能也就理所当然了,必要的时候加入 memcache 来提高读数据的速度也是顺理成章的事情了。这也是我们现在完全没有数据库只是存文件也能正常开发功能的原因——说到底读文件和写文件也就是 GET 和 SET 操作嘛,到时候换一下接口就行了。
当然了这套做法是从传统的 MMORPG 演化而来的。现在大量的手游交互性比较弱,实时性不强,用户量小时处理逻辑时直接去读写数据库往往也完全过得去。但是在系统设计阶段就把数据存储的隐患避免掉还是有好处的,万一哪天游戏火了呢?云风的博客上有具体的案例可以参考:谈谈陌陌争霸在数据库方面踩过的坑(排行榜篇)。
关于容灾
丢档可能是游戏服务器程序员永远的噩梦,虽然没亲身经历过,我相信不幸遭遇运营中游戏丢档并处理过数据恢复的程序员心中应该都有不可磨灭的创伤……
就像策划往往不理解为什么写一个 100% 不会崩溃的程序那么难,软件程序员往往也不理解为什么服务器主机会宕掉,硬盘会直接被写废。实际上在硬件工程师看来,电脑硬件不断出各种问题才是正常的吧。嗯我想说的是,我们应该在系统设计时就应该考虑好容灾,尽量降低系统故障带来的损失。
进程崩溃
根据前面的讨论,为了快速响应客户端请求,也为了降低数据库写的压力,变化的数据并没有立即写进数据库,而是以一定的时间间隔存盘。这个取巧的做法其实有很大的问题:我们修改完内存中的数据后就告知客户端操作完成,但这时数据并没有成功落地,如果这时游戏进程异常崩溃就会造成回档,回档的最大时长为存盘间隔(数分钟)。
那么游戏进程能不能不崩溃呢?有可能,但是比较难,因为游戏进程往往复杂并且迭代很疯狂,要 100% 保证不崩我觉得还得看具体的语言和框架。还是那句话,我们尽量在设计阶段就把风险规避掉。
主要思路就是用一个稳定的进程来分担风险。前面提到可以加入 memcache 进程提高读数据的性能,很自然地我们可以利用 memcache 这个稳定的进程来暂存数据。方案是这样的:每台有游戏进程运行的主机上都启一个 memcache 进程并一直运行,读数据时先从 memcache 读,若读取失败再到数据库读取,当玩家数据变化时同步写入 memcache。这样即使游戏进程崩溃,重启后会首先从 memcache 中读出正确的数据。
往 memcache 写数据一般是通过 HTTP 或 socket,会对客户端请求响应速度略有影响,由于两进程在同一主机,一般来说是可以接受的。如果游戏对响应速度特别敏感,可以用共享内存的方式进行进程间通信,前几年在畅游做 MMORPG 就是用的这种方式,只是共享内存并不易实现,程序复杂度比较高。这两种方式没有对错之分,只是需要根据游戏的需求权衡。
主机崩溃
按照之前的设计,游戏进程和 memcache 都在同一主机,一旦主机崩溃难免会造成丢档。这依旧是一个需要权衡的问题。
- 如果小概率的短时间丢档可以忍受,那么就这样了,做好性能测试将存盘时间尽量缩短点就行。毕竟 linux 是比较稳定的系统,主机崩溃并不是常态。真出了问题等玩家投诉时查日志补偿回去就好了。
- 如果游戏对响应速度不敏感,那么将 memcache 移至另一主机。暂且认为两台主机同时崩溃的概率可以忽略不计。
- 如果想两全其美,可以考虑保留原 memcache 的基础上在另一主机上新增一级 memcache。当然这样一来系统就更复杂了,我个人并不推荐。
数据库损坏
游戏数据库硬盘往往使用 RAID 技术,理论上几乎不可能出现损坏。但是就怕碰上天灾人祸,机房自然灾害也好,程序员误操作也好,如果不做好备份一旦出了问题绝对是致命的,直接毁掉一家公司也不是没可能。
数据库备份从技术上其实没什么好说的,就是主从备份读写分离什么的。这里我想分享之前的一点经历。
在上家公司用的数据库是 Tokyo Cabinet,这是一款很简洁的 KV 数据库。问题是貌似很少有人用,文档也比较匮乏,后来我们在实际部署时不知道是 bug 还是配置不对,主从数据库总是会有些不同步。折腾了一段时间后我们干脆换了思路,不依赖主从同步机制了,直接在存盘的时候分别往两个数据库存盘。因为存盘过程是异步的,系统的瓶颈并不在这儿,所以存一次还是存两次也就无所谓了。如果对数据库的运维不熟悉而头疼的话可以考虑下这个思路:-)
想象中的 proxycache
游戏服务器领域其实比较封闭,上文涉及到的 NoSQL、memcache 都是从 Web 发展出来的组件,某种程度上来说并不是特别适合游戏服务器。上面的设计中游戏进程承担了太多数据存储的功能,比如对玩家数据的变化一方面要同步存入 memcache,一方面又要掐表计时并按间隔存入数据库;再比如读数据时先要去 memcache 中查询,发现缓存失效时还要去数据库中读取。
我认为可以把数据存储部分完全从游戏进程中抽离出来,只提供给游戏进程 GET 和 SET 接口,这样游戏进程就可以专注于处理逻辑了。抽离出来的进程也就是想象中的 proxycache 了,可以用来替代前面设计中的 memcache。
proxycache 是两种角色的结合体。
首先它作为 proxy 是游戏进程访问数据库的代理。收到 GET 请求时自已去向数据库请求后转发回游戏进程,收到 SET 请求时按照一定的间隔写入数据库。可以在 SET 参数中加入延时参数,比如不重要的数据变化可以接受 5 分钟内存盘,重要的数据变化可以要求立即写入数据库。proxycache 内部根据请求参数排好优先队列后依次存储,这样可以将异常宕机带来的损失降至最低。
其次它作为 cache 能缓存数据。从数据库读回来的数据和游戏进程写进来的数据都可以缓存起来,不但实现了 memcache 的功能还简化了游戏进程的逻辑。
当然了现在都只是纸上谈兵而已,希望之后抽个空给实现出来:-)
数据库的选择
我们项目数据库完全没开始做的一个主要原因是还没选好用什么数据库。目前我对数据库的期望是这样的:首先是足够简单的 NoSQL 数据库,最好是最简单的 Key-Value,根据我们的设计 Key 只需要 string,Value 只需要 Blob 就完全够用了;然后希望是做为独立进程运行;再就是运维要方便,如果数据文件只有一个那是极好的。
先是看了 Redis。坦率地说,我认为 Redis 并不适合网络游戏。Redis 本来是设计为内存数据库,在数据落地方面处理得比较粗糙,bgsave 采用的 fork 方式,必须预留出足够的内存,怎么都觉得浪费。而且网络游戏运营一段时间后往往数据库中大部分都是冷数据,Redis 不分青红皂白一股脑读进内存实在是有些不可接受。可能还是用作缓存比较对路吧。
再就是 MongoDB。功能强大但是复杂度大大超出了我们的期望,出了问题怕是难以驾驭。
现在主要是两个想法:
- 用比较熟悉的 Tokyo Cabinet,只是这个项目已经多年没有更新了,也没有社区什么的。现在我们用 Go 语言,自己写个 driver 肯定是免不了的了。
- RocksDB 或 LevelDB 的封装。可选项有 LedisDB,SSDB,还有很多类似的,可真心没法判断是不是靠谱 ToT
Q&A
最后选择了什么数据库呢?
我比较倾向于使用 LevelDB 的封装。首先是试了 SSDB,发现不是很稳定,测试中挂了好几次。现在用的是 RiakDB,同样可以选择 LevelDB 做存储后端,轻松构建去中心化的集群,测试结果还是不错的,在这里安利一下。
另外现在阿里云提供持久化的 KVStore 服务了,如果靠谱的话我考虑迁上去,毕竟自己做运维,折腾数据库还是挺麻烦的……