昨天我们新的 MMO 游戏第一次上线小规模测试,暴露了一些问题。
服务器在开服 3 小时后,突然内存暴涨,CPU 占用率提升不多。当时 SA 已经收到报警邮件,但刚巧在午餐时间,而游戏功能还正常,耽误了半个小时。处理不够及时,导致在最终没有能收集到足够在线数据前,服务器已不能正常操作。另外,忘记配置 core dump 文件输出是另一个原因。
在最后几分钟,我们收集到一些信息:某个 lua 服务陷入 C 代码中的死循环,在 skynet 控制台发 signal 无法中断它(skynet 的 signal 可以中断 lua vm 的运行 )。从 log 分析,内存暴涨是突发的,几乎在一瞬间吃光了所有内存,而并非累积。
第一次宕机后,迅速重启了服务器。同时在内网又同步运行了机器人压力测试,但是无论是外网环境和内网环境均无法重现故障。
从这次事件中,我发现了 skynet 收集运行时事故关键信息的不足,赶紧补充了几个工具脚本。
比如,skynet 控制台的观测每个服务状态和内存使用情况的控制指令,是通过循环向每个服务发送请求,由各个服务分别汇报然后汇总输出的。一旦某个服务死循环,就会阻塞这个过程,导致没有汇总报告输出。
解决这个问题倒是不复杂,只需要分别收集信息,设置超时时间,然后就可以得到部分报告,并感知到超时服务。现在,则需要从 log 中查询死循环报告。没有事先准备好这个脚本,导致突发事件中没能及时处理。
下午,问题重现。这次发现及时,服务器还可以正常操作。我们迅速的查到了死循环的服务,并用 gdb 直接 attach 到进程调试。发现工作线程卡死在该服务的 lua gc 流程中,在遍历一个极其巨大的 table 。
lua 的 GC 虽然是步进式的,但对于遍历单个 table 这个操作是原子的。当 table 大小达到上亿个 slot 时,这个步骤就变得极其漫长。因为它已经超过了物理内存的范围,使用了交换分区。实际上是在外存中遍历。
可以确定,这个服务的 vm 就是罪魁祸首,该 table 吃掉了 90% 以上的内存。从调用栈我们可以看到这个服务在处理一条仅为 15 字节的外部消息。(在 log 中也显示有这条导致死循环的消息的地址和长度,未来考虑直接在 log 中 dump 出内容)
我们的通讯使用的 sproto 协议 ,所以很快的写一个小脚本用 sproto decode 这 15 字节就还原了现场。
原来是因为某玩家在购买游戏物品时,传入了一个不正常的数量值。而服务器在处理购买的时候是用了一个 O(n) 的 for 循环处理的。在处理过程中,不断的向一个临时 table insert 数据。当这个数量达到几亿时,自然就吃光了所有内存。
如果想追究细节的话,情况是这样的:
lua 的 table 的数组部分是在装满后,翻倍增加的。而数组足够大后,一次翻倍就会立刻触发 gc 。而 gc 又会遍历 vm 中所有对象。一旦使用了交换分区,那么这个超大的 table 就会立刻从外存交换进内存遍历,还需要 copy 一次到新的内存空间,导致这个循环运行非常缓慢。
一个意外的发现是,一旦 gdb attach 进去挂起了有问题的线程后,服务器居然变得流畅了,完全不影响玩家游戏。究其原因是因为,当 gc 挂起后,不再遍历内存,而这时内存尚有冗余可以供其它业务运行。而 skynet 采用的是多工作线程平等调度的方案。每个工作线程在需要的时候才去取活干,而并没有为每个工作线程单独配置消息队列,受影响的仅仅是一个用户(实际上这个用户早已离线),它不会阻塞任何其它服务的运行。
如果这个时候直接停掉这个工作线程,将漏洞热修复,服务器完全可以正常运行下去。不过后果就是该服务的 lua vm 无法回收。所以更友好的方案是进入正常的关服流程,将玩家全部下线后,杀掉整个进程。
经过这次事故,我觉得 skynet 有必要增加一个新特性:允许开发者限制单个 lua vm 使用内存的大小。
虽然 skynet 已经在准备发布 1.0 版 ,原则上不再增加特性。但我觉得这个太重要了,不想留到正式版之后的版本中,所以立刻加上了。选择不用的话,不会有太多副作用。
这里是使用范例:https://github.com/cloudwu/skynet/blob/master/test/testmemlimit.lua 。
如果需要开启,必须在脚本一开始就调用 skynet.memlimit 设置上限,单位是字节数。一旦该 VM 使用超过这个限制,就会抛出内存错误。一般情况下,这个 vm 还可以正常工作,比如做一些退出的工作。这是因为超限通常是因为 table 翻倍这种一次性申请大块内存的操作引起的。一旦发生,虽然当前执行流程被打断,但由于 skynet 采用的是独立 coroutine 处理不同的消息,后续消息依旧可以正常处理,并有足够的内存使用。
个人建议对于玩家代理服务,可以设置上限到 128 M 左右。当然以过往经验,在正常情况通常应保持在 10M 以下。
另外,现在默认还在每个 vm 使用 32M / 64M / 128 M (依次翻倍) ... 内存时,写一条 log 作为警告,方便开发者排除线上问题。
ps. 事后我们查询了两次引发 bug 的用户,是两个不同的用户,使用的不同的设备。