注:陌陌争霸的数据库部分我没有参与具体设计,只是参与了一些讨论和提出一些意见。 在出现问题的时候,也都是由肥龙、晓靖、Aply 同学判断研究解决的。所以我对 Redis 的判断大多也从他们的讨论中听来,加上自己的一些猜测,并没有去仔细阅读 Redis 文档和阅读 Redis 代码。
虽然我们最终都解决了问题,但本文中说描述的技术细节还是很有可能与事实相悖,请阅读的同学自行甄别。
在陌陌争霸之前,我们并没有大规模使用过 Redis 。只是直觉上感觉 Redis 很适合我们的架构:我们这个游戏不依赖数据库帮我们处理任何数据,总的数据量虽然较大,但增长速度有限。由于单台服务机处理能力有限,而游戏又不能分服, 玩家在任何时间地点登陆,都只会看到一个世界。
所以我们需要有一个数据中心独立于游戏系统。而这个数据中心只负责数据中转和数据落地就可以了。Redis 看起来就是最佳选择,游戏系统对它只有按玩家 ID 索引出玩家的数据这一个需求。
我们将数据中心分为 32 个库,按玩家 ID 分开。不同的玩家之间数据是完全独立的。在设计时,我坚决反对了从一个单点访问数据中心的做法,坚持每个游戏服务器节点都要多每个数据仓库直接连接。因为在这里制造一个单点毫无必要。
根据我们事前对游戏数据量的估算,前期我们只需要把 32 个数据仓库部署到 4 台物理机上即可,每台机器上启动 8 个 Redis 进程。一开始我们使用 64G 内存的机器,后来增加到了 96G 内存。实测每个 Redis 服务会占到 4~5 G 内存,看起来是绰绰有余的。
由于我们仅仅是从文档上了解的 Redis 数据落地机制,不清楚会踏上什么坑,为了保险起见,还配备了 4 台物理机做为从机,对主机进行数据同步备份。
Redis 支持两种 BGSAVE 的策略,一种是快照方式,在发起落地指令时,fork 出一个进程把整个内存 dump 到硬盘上;另一种唤作 AOF 方式,把所有对数据库的写操作记录下来。我们的游戏不适合用 AOF 方式,因为我们的写入操作实在的太频繁了,且数据量巨大。
第一次事故出在 2 月 3 日,新年假期还没有过去。由于整个假期都相安无事,运维也相对懈怠。
中午的时候,有一台数据服务主机无法被游戏服务器访问到,影响了部分用户登陆。在线尝试修复连接无果,只好开始了长达 2 个小时的停机维护。
在维护期间,初步确定了问题。是由于上午一台从机的内存耗尽,导致了从机的数据库服务重启。在从机重新对主机连接,8 个 Redis 同时发送 SYNC 的冲击下,把主机击毁了。
这里存在两个问题,我们需要分别讨论:
问题一:从机的硬件配置和主机是相同的,为什么从机会先出现内存不足。
问题二:为何重新进行 SYNC 操作会导致主机过载。
问题一当时我们没有深究,因为我们没有估算准确过年期间用户增长的速度,而正确部署数据库。数据库的内存需求增加到了一个临界点,所以感觉内存不足 的意外发生在主机还是从机都是很有可能的。从机先挂掉或许只是碰巧而已(现在反思恐怕不是这样, 冷备脚本很可能是罪魁祸首)。早期我们是定时轮流 BGSAVE 的,当数据量增长时,应该适当调大 BGSAVE 间隔,避免同一台物理机上的 redis 服务同时做 BGSAVE ,而导致 fork 多个进程需要消耗太多内存。由于过年期间都回家过年去了,这件事情也被忽略了。
问题二是因为我们对主从同步的机制了解不足:
仔细想想,如果你来实现同步会怎么做?由于达到同步状态需要一定的时间。同步最好不要干涉正常服务,那么保证同步的一致性用锁肯定是不好的。所以 Redis 在同步时也触发了 fork 来保证从机连上来发出 SYNC 后,能够顺利到达一个正确的同步点。当我们的从机重启后,8 个 slave redis 同时开启同步,等于瞬间在主机上 fork 出 8 个 redis 进程,这使得主机 redis 进程进入交换分区的概率大大提高了。
在这次事故后,我们取消了 slave 机。因为这使系统部署更复杂了,增加了许多不稳定因素,且未必提高了数据安全性。同时,我们改进了 bgsave 的机制,不再用定时器触发,而是由一个脚本去保证同一台物理机上的多个 redis 的 bgsave 可以轮流进行。另外,以前在从机上做冷备的机制也移到了主机上。好在我们可以用脚本控制冷备的时间,以及错开 BGSAVE 的 IO 高峰期。
第二次事故最出现在最近( 2 月 27 日)。
我们已经多次调整了 Redis 数据库的部署,保证数据服务器有足够的内存。但还是出了次事故。事故最终的发生还是因为内存不足而导致某个 Redis 进程使用了交换分区而处理能力大大下降。在大量数据拥入的情况下,发生了雪崩效应:晓靖在原来控制 BGSAVE 的脚本中加了行保底规则,如果 30 分钟没有收到 BGSAVE 指令,就强制执行一次保障数据最终可以落地(对这条规则我个人是有异议的)。结果数据服务器在对外部失去响应之后的半小时,多个 redis 服务同时进入 BGSAVE 状态,吃光了内存。
花了一天时间追查事故的元凶。我们发现是冷备机制惹的祸。我们会定期把 redis 数据库文件复制一份打包备份。而操作系统在拷贝文件时,似乎利用了大量的内存做文件 cache 而没有及时释放。这导致在一次 BGSAVE 发生的时候,系统内存使用量大大超过了我们原先预期的上限。
这次我们调整了操作系统的内核参数,关掉了 cache ,暂时解决了问题。
经过这次事故之后,我反思了数据落地策略。我觉得定期做 BGSAVE 似乎并不是好的方案。至少它是浪费的。因为每次 BGSAVE 都会把所有的数据存盘,而实际上,内存数据库中大量的数据是没有变更过的。一目前 10 到 20 分钟的保存周期,数据变更的只有这个时间段内上线的玩家以及他们攻击过的玩家(每 20 分钟大约发生 1 到 2 次攻击),这个数字远远少于全部玩家数量。
我希望可以只备份变更的数据,但又不希望用内建的 AOF 机制,因为 AOF 会不断追加同一份数据,导致硬盘空间太快增长。
我们也不希望给游戏服务和数据库服务之间增加一个中间层,这白白牺牲了读性能,而读性能是整个系统中至关重要的。仅仅对写指令做转发也是不可靠的。因为失去和读指令的时序,有可能使数据版本错乱。
如果在游戏服务器要写数据时同时向 Redis 和另一个数据落地服务同时各发一份数据怎样?首先,我们需要增加版本机制,保证能识别出不同位置收到的写操作的先后(我记得在狂刃中,就发生过数据版本错 乱的 Bug );其次,这会使游戏服务器和数据服务器间的写带宽加倍。
最后我想了一个简单的方法:在数据服务器的物理机上启动一个监护服务。当游戏服务器向数据服务推送数据并确认成功后,再把这组数据的 ID 同时发送给这个监护服务。它再从 Redis 中把数据读回来,并保存在本地。
因为这个监护服务和 Redis 1 比 1 配置在同一台机器上,而硬盘写速度是大于网络带宽的,它一定不会过载。至于 Redis ,就成了一个纯粹的内存数据库,不再运行 BGSAVE 。
这个监护进程同时也做数据落地。对于数据落地,我选择的是 unqlite ,几行代码就可以做好它的 Lua 封装。它的数据库文件只有一个,更方便做冷备。当然 levelDB 也是个不错的选择,如果它是用 C 而不是 C++ 实现的话,我会考虑后者的。
和游戏服务器的对接,我在数据库机器上启动了一个独立的 skynet 进程,监听同步 ID 的请求。因为它只需要处理很简单几个 Redis 操作,我特地手写了 Redis 指令。最终这个服务 只有一个 lua 脚本 ,其实它是由三个 skynet 服务构成的,一个监听外部端口,一个处理连接上的 Redis 同步指令,一个单点写入数据到 unqlite 。为了使得数据恢复高效,我特地在保存玩家数据的时候,把恢复用的 Redis 指令拼好。这样一旦需要恢复,只用从 unqlite 中读出玩家数据,直接发送给 Redis 即可。
有了这个东西,就一并把 Redis 中的冷热数据解决了。长期不登陆的玩家,我们可以定期从 Redis 中清掉,万一这个玩家登陆回来,只需要让它帮忙恢复。
晓靖不喜欢我依赖 skynet 的实现。他一开始想用 python 实现一个同样的东西,后来他又对 Go 语言产生了兴趣,想借这个需求玩一下 Go 语言。所以到今天,我们还没有把这套新机制部署到生产环境。