谈谈陌陌争霸在数据库方面踩过的坑(mongoDB篇)

我们公司开始用 mongoDB 并不是因为开始的技术选型,而是我们代理的第一款游戏《 狂刃 》的开发商选择了它。

这款游戏在我们代理协议签订后,就进入了接近一年的共同开发期。期间发现了很多和数据库相关的问题,迫使我们熟悉了 mongoDB 。在那个期间,我们搭建的运营平台自然也选择了 mongoDB 作为数据库,这样维护人员就可以专心一种数据库了。

经过一些简单的了解,我发现国内很多游戏开发者都不约而同的采用了 mongoDB ,这是为什么呢?我的看法是这样的:

游戏的需求多变,很难在一开始就把数据结构设计清楚。而游戏领域的许多程序员的技术背景又和其他领域不同。

在设计游戏服务器前,他们更多的是在设计 游戏的客户端:画面、键盘鼠标交互、UI 才是他们花精力最多的地方。对该怎么使用数据库没有太多了解。这个时候,出现了 mongoDB 这样的 NOSQL 数据库。mongoDB 是基于文档的,不需要你设计数据表,和动态语言更容易结合。看起来很美好,你只需要把随便一个结构的数据对象往数据库里一塞,然后就祈祷数据库系统会为你搞定其它的事情。如果数据库干的不错,性能不够,那是数据库的责任,和我无关。看到那些评测数据又表明 mongoDB 的性能非常棒,似乎没有什么可担心的了。

其实无论什么系统,在对性能有要求的环境下,完全当黑盒用都是不行的。

游戏更是如此。上篇我就谈过,我们绝对不可能把游戏里数据的变化全部扔到数据库中去做。传统数据库并非为游戏设计的。

比如,你把一群玩家的坐标同步到数据库,能够把具体某个玩家附近玩家列表查询出来么?mongoDB 倒是提供了 geo 类型,可以用 near 或 within 指令查询得到附近用户。可他能满足 10Hz 的更新频率么?

我们可以把玩家的 buf 公式一一送入数据库,然后修改一些属性值,就可以查询到通过 buf 运算得到的结果么?

这类问题有很多,即使你能找到方法让数据库为你工作,那么性能也是堪忧的。当我们能在特定的数据库服务内一一去解决她们,最终数据库就是一个游戏服务器了。

狂刃这个项目在我们公司是负责平台建设的蜗牛同学跟的。我从他那里听来了许多错误使用 mongoDB 的趣闻。

一开始,整个数据库完全没有为查询建索引。在没什么数据的情况下,即使所有的查询都是 O(N) 的,遍历整个数据库,也不会有问题。可想而知,用户量一上来,性能会下降的多快。

然后,数据库又被建立了大量的无用的索引,和一些错误的复合索引,同样恶化了系统。感觉就是哪里似乎有点性能问题,那就是少了个索引的缘故。这种病 急乱投医的现象,在项目开发后期很容易出现。其实解决方法很简单:主导设计的人只要静下心来好好想一想,数据库系统其实也就是一个管理数据的封闭模块。如 果你来管理这些数据,怎样的数据结构更利于满足特定的检索,需要哪些索引数据辅助。

最终的问题依旧是算法和数据结构,不同的是,不需要你实现它,而需要你理解它。

另外,数据库是被设计成可以并发访问的,而并发永远是复杂的东西。mongoDB 缺乏事务操作,需要用文档操作的原子性来模拟。这很容易被没经验的人用错(这是个怪圈,越是没数据库经验的人越喜欢 mongoDB ,因为限制少,看起来更自然。)。

狂刃出过这样一个 bug :想让用户注册的时候用户名唯一,所以在用户注册的时候先查一下数据库看用户名是否存在,如果不存在就允许创建一个这个名字的用户。可想而之,上线运营不出一天,同名用户就会出现了。

因为公司项目需要,我给 skynet 增加了 mongo driver 。老实说,实现这个 driver 的时候,我对 mongo 就兴趣寥寥。最后只实现了最底层的通讯协议,光这个部分,它的协议设计就已经是很难看的了。但是即使这样,我也耐着性子把这部分做完,而不想使用现成的 driver 。

mongo 的官方 driver 都是内置 socket 通讯模块的。这种做法很难单独把协议解析部分提取出来,附加到自己项目的 IO 模型中去。(btw, redis 这方面就好的多,因为它的协议足够简单,你可以用几十行代码就实现它的通讯协议,而不需要依赖 driver 模块。)

狂刃服务器的 IO 采用的 boost.asio ,我很好奇他是怎样把 mongoDB 官方 C++ driver 整合进去的。不出所料,他们开了一个独立线程处理 mongo 的数据,然后把数据对象跨线程发出来。细究这个实现就能看出问题来。程序员很容易误解 mongoDB client api 的内在含义。

一开始,狂刃的开发同学以为从 mongo 中取到一组查询结果后,调用 cursor 的 findnext 只在对象内存中迭代,所有结果都是一开始一次性返回的。以为把一开始的 bson 对象从 mongo 线程转移到主线程中就好了。可事实并不是这样,mongo 一次只会返回一组查询结果,当结果迭代完时,findnext 还会自动提交新的查询请求。这时,对象已经不在原有的 mongo 线程中了。

学过 C++ 的同学可以想像一下,让你去 code review 不是你参于的 C++ 项目去找到 bug 需要多少功夫?对了,你还要在想像中要加上被各种 boost.asio 回调函数拆得支离破碎的业务流程。所以去年有那么一段日子,我们需要完全停下手头其他的工作,认真的从头阅读那数以万行计的 C++ 代码。

老八卦别人似乎不太厚道,下面来谈谈我们自己犯的错误。

陌陌争霸出的第一起服务器事故是在 2014 年一月中旬的一个周末。准确说,这次算不上重大运营事故,因为没有玩家数据受损,也没有意外停服。但却是我们第一次发现早先设计中有考虑不足的地方。

1 月 12 日周日。下午 17 点左右,我们的 SA Aply 发现我们运营用的 log 延迟了 3 个小时才到运营平台。但数据还是源源不断的进入,系统也很稳定,就没有特别深究。

到了晚上 20 点半,平台组的刘阳报告说运营数据已经延迟了 5 个小时了,这才引起了大家的警觉。由于是周末,开发人员都回家休息了,晓靖 21 点上线检查,这时发现游戏服务器内存占用比平常同期高了 10G 之多,并在持续上升。

我大约是在 21 点接到电话的,在电话中讨论分析了一下,觉得是 log 数据从 skynet 的 log 服务发走,可能被积压在 socket server 的一个链表上。这段代码并不复杂,插入新的写入数据是 O(1) 操作,所以没有阻塞玩家游戏的风险。而输出 log 的频率还不至于短期把所有内存吃光。游戏服务器暂时是安全的。

晚21点40分,虽然没能分析出事故的源头,但我们立刻采取了应急方案。重新启动了一套游戏服务器,在线将旧服务器上的 80% 玩家导到新的备用服务器上。并同时启动了新的 log 数据库集群。打算挺到周一再在固定维护时间处理。

晚 23 点,新启动的游戏服务器也出现了 log 输出延迟。因为运营 log 是输出到一个 mongos 管理的集群中的,我们尝试在旧的集群(已无新数据写入,但依旧没有消化完滞留的旧数据)做了删除部分索引的尝试,没有什么效果。

凌晨 0:45 ,开启了新的备机群,取消了 mongos ,让每台机器独立连接一个单独的 mongoDB ,情况终于好转了。

以上,是当时事故记录的节选。

彻底搞明白事故起源是周二的事情了。

表面上看起来是在 mongos 服务上堆积了大量的数据库插入操作。让这个单点过载了。我们起初的运营 log 输出是有点偏多,比如每个士兵的训练都有一条单独的 log ,而陌陌争霸游戏中这种 log 是巨量的。我们裁减并精简了一部分 log 但似乎并不能从根本上解释这起事故。

问题出在 mongos 的 shard key 的选择上。mongo 可以指定 document 的若干字段为 shard key ,mongos 把这个 key 当成一个整数,按整数区间把 document 分成若干个桶。再把桶均匀分配到背后的从机上。

如果你的 key 是有规律的数字,而你又需要这种规律不至于破坏桶分配的公平性,你还可以将一个 hash 算法应用于原始选择的 key 上,让 key 足够散列开。我们一开始就是按自增 id 的散列结果做 key 的。

错误的 shard key 选择就是这起事故的罪魁祸首。

因为我们是大量的顺序写操作,应该优先保证写入的流畅。如果用随机散列的方式去看待这些 document 的话,新旧 log 就很大几率被分配到一起。而 mongo 并不是一条一个单位将数据落地的,而是一块块的进行。这种冷热数据的交织会导致写盘 IO 量远远大于 log 实际的输出量。

最后我们调整了 shard key ,按 log 时间和自增 id 分开,就把 mongo 数据落地的 IO 量下降了几个数量级。

看吧,理解系统如何工作的很重要。

ps, 这起事故后,我给 skynet 加了更多的监控,方便预警单个模块的过载。这帮助我们更快的定位后面出现的问题。那些关于 redis 的故事,且听下回分解。

3 月 5 日补充:

根据下面的留言讨论,总结一下:

关于 shard key 的选择在 mongoDB 文档中被讨论过 。但和我们遇到的情况有所不同。

有同学提到,这篇文章里描述在批量写入的时候,数字做 key 要比 hash 过的有更高的效率

相关推荐