« February 2014 | Main | April 2014 »

March 27, 2014

在不同的 lua vm 间共享 Proto

在 skynet 这种应用中,同一个系统进程里很轻易的就会创建数千个 lua 虚拟机。lua 虚拟机本身的开销很小,在不加载任何库(包括基础库)时,仅几百字节。但是,实际应用时,还需要加载各种库。

在 lua 虚拟机中加载 C 语言编写的库,同一进程中只会存在一份 C 函数原型。但 lua 编写的库则需要在每个虚拟机中创建一份拷贝。当有几千个虚拟机运行着同一份脚本时,这个浪费是巨大的。

我们知道,lua 里的 function 是 first-class 类型的。lua 把函数称为 closure ,它其实是函数原型 proto 和绑定在上面的 upvalue 的复合体。对于 Lua 实现的函数,即使没有绑定 upvalue ,我们在语言层面看到的 function 依然是一个 closure ,只不过其 upvalue 数量为 0 罢了。

btw, 用 C 编写的 function 不同:不绑定 upvalue 的 C function 被称为 light C function ,可视为只有原型的函数。

如果函数的实现是一致的,那么函数原型就也是一致的。无论你的进程中开启了多少个 lua 虚拟机,它们只要跑着一样的代码,那么用到的函数原型也应该是一样的。只不过用 C 编写的函数原型可以在进程的代码段只存在一份,而 Lua 编写的函数原型由于种种原因必须逐个复制到独立的虚拟机数据空间中。

这些限制有哪些呢?

函数原型包含了三类数据:字节码、常量表、调试信息(包括字节码对应的行号、函数名、局部变量名等等)。这些数据都是只读的,理论上是可以被共享的。

但是函数原型(proto) 也是 lua 的基础类型(但没有暴露到语言层),依然是被 Lua 虚拟机管理的 gcobject ,它需要参于垃圾收集的过程。Lua 在实现时并没有考虑将多个虚拟机共享数据。

如果我们需要共享,第一步就是要改变 proto 类型的生命期管理。不能再由单个 lua 虚拟机的 gc 扫描流程决定是否要释放一个不再被引用的 proto 。

一个完备的方案是对 proto 做一个线程安全的引用计数,但我们也可以简单粗暴的直接在内存中保留所有的 proto 对象,无论是否有人引用它。

保留所有用过的函数在内存中这种做法是广泛存在的,如果你对比看 C 层次的函数,即使 C 函数存在于动态库中,我们也不能轻易卸载动态库,这有让其它模块保留过动态库中函数指针变得无效。另外,由于调试信息的存在,引用计数的方案会对 lua 实现做相当大的改变。

第二步,我们需要考虑常量表。对于常量字符串,往往是不可以被多个 lua 虚拟机共享的。尤其是短字符串,lua 会对短字符串做唯一化 (string interning) 处理,同样的短字符串在同一个 lua 虚拟机中只有一份。不同的 lua 虚拟机中的短字符串一定会被判定为不同的。如果对常量表中的字符串也做共享处理,那么除了需要给 lua 实现增加一种字符串类型(不被 gc 管理的字符串)外,还会降低字符串处理速度(目前 lua 在做短字符串比较时,直接比较对象指针,可以达到 O(1) 的处理速度;而如果常量字符串在不同的虚拟机中的话,比较会变成 O(n) 的复杂度)。

第三步,每个 proto 对象中带有一个 closure cache 。绑定同样 upvalue 的 proto 生成的 closure 可以被复用。但如果 proto 是跨虚拟机的,这个 cache 就很难正常工作了。

第四步,调试信息中也有大量的字符串。考察一下 Lua 实现可以发现,Lua 的 api 仅将这些字符串用内部字符串对象储存参与 gc 管理,但并不会把这些字符串对象传递到别的地方。所有 api 都是返回这些字符串对象的 C string 指针的。


针对这些问题,我们可以开始对 lua 的实现做改造了。

我们可以将 proto 数据结构拆分成可共享和不可共享两部分。不可共享的有常量表和 cache ,其它都可以共享。不可共享部分继承原有的 proto 结构,再用一个指针指向共享部分即可。我们需要在共享的数据结构中保留一个它实际存在于的 lua 虚拟机的指针。只有这个虚拟机才有权利回收它所占的内存。而其它引用它的 lua 虚拟机在 gc 时,可以检查这个指针来决定是否要标记清除它。

lua 提供了一个 api lua_topointer 可以返回一个 lua 函数对象中的原型指针(注:这是 undocument 的)。我们只需要再添加一个 api 把函数原型还原成 closure 即可。

这里引入了一个新 api 叫 lua_clonefunction 它能复制一份函数原型的常量表到当前的 lua 虚拟机中,并创建其它需要的数据结构。

我给 lua 5.2.3 打好了 patch 支持这个特性 。并将它合并到 skynet 的主干上了。

为了更好的利用这个特性,我在 skynet 中,改写了 luaL_loadfilex 。这个 patch 版的文件加载函数是线程安全的。它为每个文件名对应的函数(lua 中加载一个源文件,就生成一个函数)创建一份独立的 lua 虚拟机,并将生成好的函数原型指针记录下来。之后同名文件的加载就不再有文件 IO ,不必再次解析文件,直接用 lua_clonefunction 复制一份出来。

为了 skynet 服务器可以热更新 lua 脚本,还增加了 clear cache 的方法(skynet.cache.clear),可以将 cache 重置。当然,之前加载过的代码其实是没有从内存中清理掉的,这一定程度上会带来一些内存泄露。但考虑到这个 patch 可以给系统节约的内存,不是过于频繁的热更新是可以接受的。


这个 patch 可以带来的好处:

  1. 对于我们的项目(陌陌争霸 ),每个在线用户大约可以少消耗 1M 内存。

  2. 为一个在线用户初始化 lua 虚拟机的时间加快了 4 倍。

  3. 由于字节码占用的内存更为集中,提高了 cpu 内的 cache 利用率。

但是,这个 patch 也增加了热更新的复杂度。需要主动清理 cache ,并考虑历史上的过期版本的代码占据内存不能回收的问题。如果不想在 skynet 中使用这个 patch ,可以在 makefile 中调整 lua 库的链接,指向官版的 lua 即可。

March 23, 2014

Skynet 新的 socket.channel 模式

大部分外部网络服务都是请求回应模式的,skynet 和外部数据库对接的时候,直接用 socket api 编写 driver 往往很繁琐。需要解决读取异步回应的问题,还需要正确处理连接断开后重连的问题。

这个周末,我试着给 skynet 的 socket 模块加了一个叫做 channel 的模式,用来简化这类问题的处理。

可以用 socket.channel { host = hostname, port = port_number } 创建出一个对象。这个对象用来和外部服务器通讯。

对于 redis 的协议模式,我们每次发送一个请求,就对应着可以读到一个回应包。请求队列和回应队列是次序一致的。

那么,就可以使用 channel:request(request, response) 这个 api 获取回应信息。

这里 request 是一个字符串,即需要发送给服务器的请求内容。而 response 是一个函数,要求它可以返回两个值:第一个是一个 boolean ,true 表示回应内容正确,这时第 2 个返回值就是返回的对象。

如果 response 函数返回 false,第 2 个返回值是错误对象。这会导致 channel:request 将它以异常形式抛出。

而 mongo 的协议模式,并不保证请求和回应之间的次序,它是用一个 session id 来表识的。有可能出现先发出的请求,后收到回应。这时,我们应该在 channel 初始化的时候提供一个 response 包解析函数。

socket.channel { host = hostname, port = port_number , response = dispatcher }

这里的 dispatcher 就是这样的一个函数,它需要返回 3 个值:

第一个是 session ,表示这个包对应的是哪一次请求。

第二三个返回值的含义和前面所描述的 response 函数相同:一个 boolean 加一个返回对象。

在发起请求的时候,使用 channel:request(request, session) 即可。这里的第 2 个参数是这次请求的 session 。返回值的含义是一致的。

如果请求不需要接收返回包(mongo 的通讯协议中,有许多这样的协议),可以调用 channel:request(request) 即可。如果之后需要收取回应包,可以再调用 channel:response(response) 。这种用法现在被用来实现 redis 的 watch 模式 (pubsub 模式) 。

channel 模块会捕获 response 函数处理过程中的异常,包括网络异常断开的请求。任何在处理回应数据的过程中产生的异常都会强制连接断开,尝试重新连接服务器。重连完成后,未收到回应的请求将被重新发送。

如果每次连接服务器需要做一些认证等流程,可以在 channel 初始化的时候给出一个 auth 方法,底层会在每次成功连接上后调用。


有了 channel 模式后,socket 的 lock 机制一般就不需要在使用了。因为无论是请求和回应的处理都是 coroutine 安全的。请求被要求在一次 socket 写操作中完成;而回应的处理被统一安排在一个 coroutine 内分发。

多个 coroutine 同时提起 request 不会相互阻塞,所以 redis driver 原来提供的 batch 模式就意义不大了,这次重写 redis driver 我将其删去(如果有需要做流水线命令,可以考虑以新的方式实现)。

由于这次改动较大,所以我暂时提交到了一个临时的新分支 channel 上。希望有在用 skynet mongo driver 的同学帮忙测试下新版本的 driver 有没有明显的问题。

March 11, 2014

linode 广告时间

这是一篇关于 VPS 虚拟主机服务的广告。

经营这个 blog 的 9 年间,我收到过十多次 email ,希望可以在我的 blog 上挂上文字广告条,都被我拒绝了。我不喜欢广告,但推荐觉得不错的东西是例外。

4 年前我把 blog 服务从网易的服务器迁到 linode 时,我写了一篇 blog 。没想到之后 3 年多仅靠推荐就支撑了 vps 的开销。

前天收到 linode 新账单,发现很久没有推荐新用户了(每推荐一个新用户可以有 20 美元的回扣),账户里已经花的差不多。我希望可以保持让这个 blog 自己负担起它的 vps 费用,所以重新写一篇吧 :)

如果有朋友也想租用 vps ,可以 点这个链接 或者或者在 linode 填我的 referral code : 538bab39bc1265a2ce54115d1f86e2bc81e4d133 。

在国内,个人租用虚拟主机必须使用海外的服务,相信对于我的读者来说是一个常识。即使你不谈政治,也希望有一个不受约束的自由发言地,对吧?即使不写点什么,一个稳定的翻墙通道、一个可以随意放点文件的网络储存地都是有必要的。出差到外地,不清楚当地的 wifi 是否安全,最好的方法就是让自己的设备先 vpn 到自己的 vps 上,这样可以大大减少被劫持的几率。

linode 是老牌的 vps 提供商,他绝对不是最便宜的,但却是我知道的最好的。我用的 20 美元一月的服务,用国内的信用卡(比如招商银行的)就可以买单。这些年用下来没有出过什么问题,我的虚拟主机每次重启后都是几百天的稳定运行时间。重启往往是因为自己想把操作系统升级。或是平均两年一次的机房调整(通常伴随着硬件升级),那都是提前通知,自己登陆到控制面板一键完成的。

我对 linode 的客服相当满意,提过几次 ticket ,无论是什么时间,都在 5 分钟内就有人工回复。通常一两次交流就搞定问题了。提 ticket 基本都是换机房。一开始我在美国机房,后来发现日本开了新机房速度相当快就要求换过去。后来日本 linode 的 IP 大批被墙,又换回美国。这几次折腾都是几分钟搞定。

linode 的带宽根本用不完。记得一开始似乎是 100G 一个月,现在已经加到了 2T 。平常翻墙看看 youtube 也只用的了一个零头。我刚开始用的时候,空间是给的 16 G ,现在已经加到了 48G 。8 个 CPU 核心 1G 内存对于个人用户绰绰有余了。

友情提示:域名注册也千万不要选用国内的服务商。迁移不方便,更逃不过备案等等烦心事。我从万网迁到 name.com 后非常满意,供参考。

March 04, 2014

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

注:陌陌争霸的数据库部分我没有参与具体设计,只是参与了一些讨论和提出一些意见。在出现问题的时候,也都是由肥龙、晓靖、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 语言。所以到今天,我们还没有把这套新机制部署到生产环境。

March 03, 2014

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

我们公司开始用 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 过的有更高的效率

我们没有使用批量插入,而我们是单条逐条插入的。所以性能低下并不在于逐条调用 getLastError ,我们为了保证写入性能,都是单向推送,不获取 getLastError 的(最低 Write concern 级别)。我认为在我们的业务情况下,按时间片让一台机器接受一组数据是更好的利用方式。

March 02, 2014

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

为什么大部分网络服务都需要一个数据库在后台支撑整个系统?

这通常是因为大部分系统的一个运行周期都很短,对于传统的网站服务来说,从收到一个 HTTP 请求开始,到终端用户收到这个请求的结果为止,就是一个运行周期。而其间可能处理的数据集是很大的,通常没有时间(甚至没有空间)把所有数据都加载到内存,处理其中涉及的一小部分,然后保存在磁盘上再退出。

当数据量巨大时,任何对数据的操作的算法和数据结构都需要精心设计,这不是随便一个程序员就可以轻松完成的任务。尤其是数据量大到超过内存容量时,很多算法和数据结构对大部分非此领域的程序员来说都是陌生的。本着专业的事情交给专业的人来做的原则,一般系统都会把这部分工作交给独立的数据库来完成。

对数据的操作只有抽象的足够简单,系统才能健壮,这便有了 SQL 语言做一层抽象,让数据管理的工作可以独立出来。甚至于你想牺牲一部分的特性来提高性能,还可以选用近年来流行的各种 NOSQL 数据库。

可在 MMO 游戏服务器领域,事情发生了一点点变化。

数据和业务逻辑是密切相关的,改变非常频繁。MMO 服务器需要持续快速的响应用户的请求。我们几乎不可能把一切数据都放在独立的数据库中,比如玩家在虚拟世界中的位置,以及他所影响的其他玩家的列表;玩家战斗时的各种属性变化,还有和玩家互动的那些 NPC 的状态改变……

最大的矛盾是:MMO 游戏中数据集的改变不再是简单的 SQL 可以表达的东西,不可能交给数据库服务期内部完成。无论什么类型的数据库,都不是为这种应用设计的。如果你硬要套用其它领域的应用模式的话,游戏服务器只能频繁的把各种数据从数据库中读出来,按游戏逻辑做出改变,再写回去。数据库变成了一个很低效的数据中转中心,无论你是否使用内存数据库,都改变不了这个低效的本质。

我听过无数从别的领域转行到游戏领域做开发的程序员设计出来的糟糕系统。他们最终仅仅把数据库当成一个可靠的数据储存点和中转点,认为把所谓重要的数据写进数据库就万事大吉,然后再别扭的从另一个位置把数据从数据库读出来使用。系统中充满了对数据库的奇怪异步回调用来改善系统的反应速度,而系统却始终步履阑珊。能做对已经是极限了,更何况游戏系统不仅仅是输入输出正确就是正确,如果超过了应用的响应时间,一切都是不正确的。


为了让系统健壮,构架师在构架系统时,一定会把系统隔离成不同的模块,并尽量简化模块间的沟通规则。这样你可以单独校验每个模块的质量,必要的时候可以更换。几乎没有人会因为效率或开发方便等原因而把应用代码写到 OS 内核中去跑就是这个道理。

每个模块只对输入它的数据负责,保证输出的正确。通常测试也只对这个正确性负责。同学们最容易忽略的一点是,每个模块都对它输入数据的处理速度有一个上限,也就是它的吞吐量。

一旦输入速度大于处理速度,模块实现的再正确也是白搭。因为永远都不会有输出了。

对于大部分模块,只要内存管够,这都不是问题。实际运作的系统中很少有持续大数据量的输入的,从一个较长的时间看,总的数据输入是小于处理能力的,暂时没能处理的数据堆积在内存就行了。

凡事都有例外。一个健壮的系统都需要对例外做处理。一个工作在 server 模式的数据库是这样解决这个例外的:它会支持查询连接的并发,并发的查询相互间对计算资源的占用是公平的,相互不影响(至少是设计上的理想)。而操作系统或数据库本身会限制并发的连接数,一旦达到最高连接数,系统会拒绝服务;这样就把超过处理能力的输入挡在了模块外面。按这种设计,就不会有输入(只要能抵达)永远没有回应了。

可惜,这样做的代价是,你必须在模块间加入请求失败的处理。一个设计不谨慎的系统最容易在错误处理上栽跟头。他们总是期望任何一个模块都能正确处理上级的请求。

btw, 为什么 12306 的订票系统在高负载的情况下完全不可用?就是这点没处理好。我指的是,一个实现正确的系统,一定不会连网页的刷不出来,不给用户正确的提示,哪怕只是错误提示;也不应该在高负载下,有效处理能力急剧下降。我指的是,一旦用户能进入正常流程,就应该顺利把至少一个环节顺利完成,而不是突然就卡在那里没有任何回应。

快跑题了。我谈到这点,其实是想表达,说的容易,做起来是很难的。下一篇我会写到我们在过年前出的一个事故和这个就有一些关系。


八卦时间:

陌陌劲舞团是陌陌游戏平台上线的第 2 款游戏。我们的陌陌争霸还在开发的时候,这款游戏就打算上线了。我对这个产品有限的了解都是道听途说,所以如果有更清楚内情的同学发现说的不对,也请谅解。对于技术问题,我想八卦的真相就并不那么重要了,有则改之,无则嘉勉。

劲舞团这个品牌原本是属于韩国人的,但这款游戏在国内曾经异常火爆,在国内代理它的久游也就买下来 IP ,自己制作手机版。据我所知,陌陌劲舞团完全是在上海开发的,没韩国人什么事。

这是个比较简单的游戏,至少服务器部分很简单,也就是统计下分数,查查排名,以及解决一下收费问题而已。刨掉这些部分,它就是个单机游戏,根本不需要服务器。

因为劲舞团的品牌名气,以及陌陌巨大的用户群,游戏一上线就在 ios 免费榜飚上去了。如果不是企鹅公司看不顺眼,立刻上线了节奏大师,估计还会在榜单上更火一些。事后证明节奏大师的上线也很仓促,完全是为了打击竞争对手抢着上的,因为后者的服务器也不稳定,很快就挂掉了,完全不像一个大公司应有的质量。

陌陌劲舞团顺利拉来了用户后,第一天服务器就出了状况,重启了几次后完全不解决问题。所以决定停服休整。一停就是三天。当时我就纳闷了,哪有修个小 bug 预计要三天的?这肯定是有结构性问题了。当时我们的项目按计划也就最后半个月的时间了,本来陌陌的人督的我们很紧的,一下子人全飞去了上海。

一周后陌陌劲舞团才重新上线,远超过当初预计的 3 天。事情一解决,陌陌的技术班底,从 CTO 到下面大多数人,全部飞到广州和我们开会,让我们重视服务器稳定性问题。会议内容主要是强调陌陌平台初期导入用户瞬间爆发量巨大,以及了解一下我们的设计细节确保没有大的问题。我所了解到的八卦就是在这段时间听来的。

陌陌劲舞团使用的是 MongoDB 。似乎这玩意很受游戏开发者喜爱。我想主要是因为用起来简单直接吧。游戏从业者如果之前没有别的领域的开发经验,对数据库这东西一知半解的人居多。尤其是从客户端开发过来的人,他们通常的习惯就是看 API 文档,了解怎么用看起来正确就够了。然后上线测试一下,好像也对,工作似乎就结束了。就算有压力测试,也很难做到和生产环境一致。

上线前,据说双方沟通过。陌陌方想确认系统能不能横向扩展,得到的答复是可以:加硬件即可。我想陌陌劲舞团开发方的思路是这样的:我们的服务器系统很简单,不都是过一下数据库么?MongoDB 是被很多人验证过的,不会在这么简单的业务中出问题吧。至于负载,不是还有 mongos 么?放心啦,没事的。

最终的直接问题出在排行榜上。当有两万人在线时(没错,才两万人而已),大量用户的排行榜查询阻塞了数据库。导致不仅仅是排行榜刷不出来,连冲值业务也受到了影响。土豪们充不进去钱,谈什么玩游戏啊。最终产生了雪崩,整个数据库都不正常使得游戏系统工作不起来。

为啥用了这么长时间才修好这个 bug ?

负责陌陌劲舞团的服务器开发的人在项目做完就离职了。想想一个设计有问题的系统交给非设计者维护有多糟糕吧?任何清醒的程序员都知道,这个时候即使是重写也比改问题简单。陌陌的同学做了个正确的决定,直接派自己的人驻留在上海,把服务器重新写了一遍。

陌陌的技术背景是 Redis 的,他们的系统用 redis 构建,所以重写就用了 redis 取代 mongodb 。写到这里,我完全没有 redis 和 mongodb 谁好谁差的意思。关键在人,你对什么熟悉就用什么,哪种数据库都能对付这点小业务,关键看你能不能用对。

Redis 里正好有一个有序集(Sorted Set) 的数据结构,你用 ZADD 插入完数据后,它就天然有序了。这个插入是 O( M * log(N)) 的时间复杂度,基本可以满足需求。而用 ZRANGE 查询榜单仅需 O(log(N)+M) 的时间复杂度。

那么使用 Redis ,利用 sorted set 做排行榜系统是我们的唯一选择么?绝对不是。我们也不可能为了这个特性必须选择 redis 做数据库。但这个例子可以说明:如果数据库提供内在的特性可以对数据集做一些操作,我们就直接用,但需要了解这种操作的性能。它需要和整个系统对它的性能期望匹配。

陌陌劲舞团使用 mongodb 内置的排序功能去做排行榜本也不是大问题。或许仅仅只是实现的人对 mongo 不熟悉造成的性能低下。这些随着系统重建已经无法深究了。但核心问题是,仅仅一个排行榜系统的错误实现为何会影响整个系统的稳定性?

下面就是我的猜测了:

许多程序员为了提高数据库的吞吐量,并不是一个事务就给数据库建一个连接,用完就关掉的。因为新建 TCP 连接是个开销较大的操作。维持太多连接对系统也是一个开销。同学们喜欢做一个叫做连接池的东西,在系统其它部分和数据库对接的地方走这个连接池。只要一个旧有连接没有断开,就一直把对数据库的请求通过固定连接发给数据库,等待返回。

在数据库的吞吐量满足系统需求的时候,这个模块很容易实现正确。但一旦超出需求,连接池上的数据就会越积越多,数据库查询越来越慢。而调用数据库的模块却不觉得这是问题。

正确的行为应该是让连接池快速反馈,断开并扔掉不可能处理完的请求,让请求方把这个不能处理的错误反馈到上个环节,直到流量被限制在合理的范围内。整个系统才能不至于崩溃。当错误被迫反馈到玩家那里时,他顶多看到的是查询失败,而不太会影响到别的功能。


陌陌争霸怎样做排行榜的?

在上一篇里就有同学问道,如果你们不用数据库,怎么做排行榜呢? 其实我在上一篇正文里就有解答:

“服务器只是在不断的创造新数据并让这些数据在内存中流通而已,它没有任何需要从外部读取数据。如果内存无限大,且服务器永远不会当机,数据库这个设施没有存在的必要。”

排行榜单也是数据之一,游戏服务器开服一刻起,没有任何玩家有排名信息。随着玩家名次更替,榜单才逐步形成。我们只需要在玩家分数变化的时候同步榜单的变化即可。而玩家查询仅仅是取走有序的榜单而已。

你看,这个过程和数据库无关不是?需要设计的是调整榜单的算法,和榜单的数据结构以保证维持榜单的性能足够强就好了。因为玩家名词更替的频率远小于玩家网络包的频率,那么这个模块的处理能力所需要的下限很容易满足。我们不用考虑处理不过来的情况。

针对陌陌争霸我们是这样做的:

陌陌争霸中用于排名的分数区间不大,也就是 0 分到 5000 分。而参与排名的人数众多,数以百万计。对百万用户做插入排序,每个插入即使是 O(N) 的也不可接受。可事实是大量玩家的分数相同,都是并列排名的。所以我们只需要做 5000 个桶,每个桶里仅记录这个分数有多少个人就可以了。

当玩家分数变迁,把原来的桶减一,新的桶加一。这个操作就是 O(1) 的。

而排行榜的查询仅需要把当前分数靠前的桶累加,就能获知查询者的名次。对于上百万玩家,看到哪些人和你并列的人的名字是没有意义的。这个查询虽然是 O(n) 复杂度,但 n 只有区区 5000 ,还可以做 cache 以应对查询频率远高于更新频率的情况。

真正需要精确知道人名的是榜单的前 200 个人,而对前 200 个人做插入排序也很快,所以并不会造成性能问题。

我们在系统的单点做排行榜的维持,完全没有外部数据库操作,它只是一小段操作普通内存结构的 c 代码。而这个单点远远成为不了整个系统的热点。

我们在系统临时退出时,把已经排好的榜单落地,下次启动的时候恢复。但也不必完全信任落地的数据,可以用离线脚本检索整个数据库重新生成一份正确的榜单。所以数据库中的榜单只是被 cache 起来而已,系统运行期间是不需要写入数据库的,也不用担心数据丢失。


好吧,还是没谈到我们自己踩的坑,就又到了吃饭时间 :( 。明天我将写写陌陌争霸在运营期间遇到的第一起数据库事故,它和 mongos 有关。同时也会谈谈我们在代理狂刃期间帮狂刃填的一些和 mongodb 有关的坑。

March 01, 2014

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

陌陌争霸 这个项目一开始不叫这个名字,它在 2013 年中的时候,还只是一个我们公司 用来试水移动游戏的试验项目。最开始的目标很明确,COC 是打动我的第一款基于移动平台网络游戏,让我看到了和传统 MMO 不同的网络游戏设计方向。我觉得只需要把其中最核心的部分剥离出来,我们很快可以做出一个简单的却不同于以往 MMO 的游戏,然后就可以着手在此基础上发展。

至于后来找到陌陌合作,是个机缘巧合的故事。我们的试验项目完成,却没想好怎么推给玩家去玩(而这类游戏没有一定的玩家群体基本玩不起来),而陌陌游戏平台刚上线,仅有的一款产品(类似泡泡龙的游戏)成绩不佳。因为我们公司和陌陌的创始人都曾经在网易工作,非常熟悉。这款游戏也就只花了一个月时间就在陌陌游戏平台发布了。

一开始我们只把刚完成狂刃 启动器项目的阿楠调过来换掉我来做这个项目,我在做完了初期的图形引擎工作后,就把游戏的实现交给了他。我们只打算做客户端,因为只有这部分需要重新积累技术经验;而服务器不会和传统 MMO 有太大的不同。而我们公司已经围绕 skynet 这套服务器框架开发有很长一段时间了,随时都可以快速把这个手游项目的服务器快速搭建起来。

到 2013 年夏天,感觉应该开始动手做服务器部分了。晓靖在斗罗大陆的端游项目中积累了不少服务器开发的经验,也是除我之外,对 skynet 最为熟悉的人;如果这个试验项目只配备一个程序来开发服务器的话,没有更好的人选了。

从那个时候起,我们开始考虑服务器的结构,其中也包括了数据库的选型和构架。

skynet 有自己的 IO 模型,如果要足够高效,最好是能用 skynet 提供的 socket 库自己写 DB 的 driver 。因为 redis 的协议最简洁,所以最先我只给 skynet 制作了 redis 的 driver 。而我们代理的游戏狂刃的开发方使用的是 MongoDB ,为了运营方便,我们的平台也使用它做了不少东西,我便制作给 skynet 制作了初步的 mongodb driver 。到服务器开始开发时,我们有了两个选择。


我十多年的游戏行业从业经验告诉我,数据库在实时交互性较强的在线游戏中,主要起的是一个数据备份容灾的作用。很少会将其用于数据交换。而在其它领域,很多开发者则选择把数据库作为业务模块间的数据交换,带着这个思路来做游戏,往往会带来很严重的性能问题。

简单说,理论上,由于游戏服务器往往 7 * 24 小时持续工作,且玩家具有强交互性,大部分游戏世界里的数据都一直存在于内存中。当服务器启动后,一旦数据加载完毕,大部分不再需要退出内存。服务器只是在不断的创造新数据并让这些数据在内存中流通而已,它没有任何需要从外部读取数据。如果内存无限大,且服务器永远不会当机,数据库这个设施没有存在的必要。

当然这两个前提条件都不可能成立。

对于内存无限大这个条件,传统 MMORPG 游戏需要消耗的内存是 O(n) 的,n 和总用户数相关。虽然同时玩游戏的用户数(活跃用户数)有限,很难持续增长;但总用户数的确是随时间增长的。我们只要把 n 从总用户数变成活跃用户数后,基本就能维持内存的需求。

最简单的做法是,当一个用户不活跃后,就把这部分数据落地(写入磁盘),当他有一天又变得活跃后,再从磁盘加载回来。在端游早期,用户活跃的标准就是他是否在线。我们在用户上线的时候加载他的数据,离线的时候将数据落地即可。从开发角度看,数据如何保存,最简单的方法不是使用数据库,而是以用户 id 为文件名,把用户数据序列化成文本写入文件系统即可。这也就是网易早期游戏的通用做法。

对于服务器稳定性的要求,我们不可能作到 100% 不当机,所以数据还是要定期存盘的。可以是按时间为周期保存,也可以是在关键操作发生时保存。这样在灾难发生的时候可以恢复回来。

btw, 一个系统所需要管理的数据总量小于系统总的内存量这一点,不仅仅在游戏领域,其实很多别的系统也存在。所以 redis 这种纯内存数据库才有了广泛的应用空间。redis 的 BGSAVE 以及 BGSAVE 的两种模式,也对应了上面所指的数据落地策略。


至于,如何操作这些数据的问题,既然数据都在你系统的内存中,总可以写出对应的算法去处理它们吧?明白了这一点,就能明白为什么在大多数在线游戏系统中,选用怎样的数据库就不是什么重要的问题了。

当然,一个在线游戏的运营还是需要大量的游戏内数据分析的。本着不同的业务逻辑尽量分离的原则,我们还是需要把游戏内的数据输出出来,交给专业的系统,专业的人来处理。这一部分的数据量远大于游戏系统为玩家服务时所需要的量。我认为它的空间复杂度是 O(n * m) 的。其中有两个维度,一是玩家的总数,二是运营的时间。游戏服务器需要把运营过程中的数据吐出,保存到可以处理这么大数据量的数据库中去。我们把这部分数据称为运营 log ,这个名称我觉得不太合适,因为它容易和程序输出的供调试分析的错误 log 相混淆,不过历史上在网易工作时大家都这么叫,我也不打算起个新名词了。


陌陌争霸在服务器方面的选型和构架按着这个思路做出来:

我们用 redis 保存玩家的数据,考虑到玩家数量可能很多,一个 redis 仓库可能不够,我们使用了 32 个 redis 仓库,按玩家 id 分开存放。在部署方面,可以在用户数量较少的时候,把多个 redis 仓库部署在同一台物理机上,再随着用户规模扩大而分开部署。如果 32 个仓库不够的话,进一步细分也不会是难事。

在前三个月,我们不用太考虑冷热数据的问题,这个期间还谈不上流失玩家,所有玩家数据都是热数据。由于开发时间紧迫,我们把冷数据处理留到后期再处理。

至于数据落地的问题,redis 已有 bgsave 的能力,我们只需要细调就好了。

而运营 log 和一些随时间自然增长的数据,比如战斗录像,我们选择了不受内存限制,且易于做数据分析的 mongodb 。由于担心数据量过大,使用了 mongos 分片。

初期的设计就是这样了,只到今天,也没有在结构上做什么调整。但是在操作过程中踩了许多坑,都是值得好好记录下来的经验。

今天要去吃饭了,明天接着写。

预告:陌陌游戏平台的第二款游戏:陌陌劲舞团先于陌陌争霸半个月上线。上线后不到两天就宣布停服,停服时间一再延长,一直拖了一周。传言说问题就出在数据库这块。下篇打算八卦一下这个事情。