« April 2016 | Main | June 2016 »

May 13, 2016

代理服务和过载保护

在 skynet 中,有时候为一个服务实现一个前置的代理服务是很有必要的。

比如,你希望对这个服务发起的请求是支持超时的,就不必在功能实现的服务中实现,那样会增加无谓的复杂性。你可以在功能实现的服务前加一个代理服务,当超时发生时,通知请求方。关于这个实现,我在 blog 中给过一个示例

同理,当你需要做一些负载均衡的处理的时候,也可以做一个代理服务,让请求分摊到多个可以完成类似功能的服务中去,实现比较简单,本文就不展开了。

今天想谈一下怎么利用代理服务更好的为一些热点服务提供过载保护。

过载保护在两年前我就写过,这两年的运营产品经验表明,对于缺乏经验的 skynet 使用者,它是最容易碰到的问题。这可能也是并发环境的最需要解决的问题。

我想过在未来的版本中,做一些更底层的保护措施。不过目前的 skynet 1.0 版,我们依旧可以在上层做一些工作。

假设我们已经知道了某个服务容易成为热点,那么前置一个代理服务就可以做很多事情。

所谓代理服务,就是向真正的功能服务发起请求时,请求消息发到另一个代理服务中,由这个代理服务转发这个请求给真正的功能服务;同样,回应消息也会被代理服务转发回去。

代理服务在过滤这些消息时,可以做一些工作,例如:

如果某个服务的请求过于频繁,则可以暂时搁置这些请求,而优先转发其它服务的请求。这样便增加了公平性。而如果请求来源的服务已经退出,在代理服务中若还存在他所未发出的请求,则可以直接将这些消息丢弃,减轻功能服务的负担。

我们还可以从代理服务勘察对应的功能服务的负载情况,如果功能服务过忙,也可以暂时缓存新的请求。这样可以让我们从调试控制台对功能服务发起调试控制指令时,可以更快的回应。(从调试控制台直接对功能服务发起命令,不经过代理)在线上解决问题时,往往服务过载后,调试指令响应迟缓会极大降低线上处理故障的效率。


skynet 对编写代理服务已经提供了不错的支持,你可以参考 clusterproxy 服务 来编写自己的服务代理。

通常一个代理服务是这样的:

local skynet = require "skynet"
require "skynet.manager"    -- inject skynet.forward_type

skynet.register_protocol {
    name = "system",
    id = skynet.PTYPE_SYSTEM,
    unpack = function (...) return ... end,
}

local forward_map = {
    [skynet.PTYPE_LUA] = skynet.PTYPE_SYSTEM,
    [skynet.PTYPE_RESPONSE] = skynet.PTYPE_RESPONSE,    -- don't free response message
}

local realsvr = tonumber((...))

skynet.forward_type( forward_map ,function()
    skynet.dispatch("system", function (session, source, msg, sz)
        skynet.ret(skynet.rawcall(realsvr, "lua", msg, sz))
    end)
end)

上面的代码并不完整,你需要根据你的真正业务逻辑补全它。

使用 skynet.forward_type 需要提供一张映射表,表示你需要处理哪些类型的协议。除此之外,和 skynet.start 的用法一致。

在映射表中的协议消息,框架不会释放消息所占的内存,这是为了避免做不必要的消息拷贝;同时,你也必须小心的处理它们,避免内存泄漏。

在上面的例子中,所有的 lua 协议类别的消息被重定向为 system 类别(这样你可以重新定义 unpack 函数,不做任何解包处理)。然后在随后的 dispatch 函数中,使用 skynet.rawcall 直接发送消息指针,从而绕过打包流程和对回应包的解包流程。

这里还映射了 response 类消息,仅仅是为了让框架不要释放它。在后续 dispatch 内,skynet.rawcall 返回的回应包是 C 指针和长度,直接交给 skynet.ret 就可以回应给原始请求方了。

这里的示例只处理了请求回应模式的消息。如果你还向正确转发单向的 skynet.send 行为,可以在里面多判断一下 session 是否为 0 。(请求消息的 session 约定为不等于 0 的整数)


如何判断功能服务是否过载?

如果是线上监控,你可以查看 log 。目前在消息队列过长来不及处理时,会输出 "May overload, message queue length = xxx" 。

服务自己也可以通过 skynet.mqlen 查一下当前待处理的消息队列长度。

如果你想取查询对方是否可能及时处理消息,比较简单的方法是实现一条协议,立刻返回。你作为请求方,使用 skynet.now 测试一下这个请求的回应速度。在最新的 skynet 版本中,我增加了默认的 debug 协议 ping 来做这件事情。

local ti = skynet.now()
skynet.call(address, "debug", "PING")
ti = skynet.now() - ti

使用 debug console 的话,可以使用 ping address 命令。

btw. 在上面的代理服务中使用 debug ping 是安全的。虽然 response 类消息的行为被 skynet.forward_type 修改为不会释放,但 debug ping 协议比较特殊,它的回应消息的内容为空。

如果你需要在代理服务中发起其它请求,记得使用 rawcall 并销毁掉回应包指针。

skynet.trash(skynet.rawcall(address, "lua", skynet.pack(...)))

需要手工销毁 ping 的 response 消息。


如何即时获知一个服务已经退出?

skynet 早期推荐的做法是使用 skynet.monitor 定义一个自己的监控服务。所有服务的退出都会通知它。但这种用法现在已经不推荐了。

如果被监控的服务是一个 lua 服务的话,目前最简单的方法是向这个服务发送一个永不返回的请求。而当该服务正常退出的话,这个没有返回的请求将会由框架向请求方抛出一个 error 。所以,只要你使用 pcall 向需要监控的服务发起一个 skynet.call ,就能感知到服务退出了。

skynet 最新版本增加了一个 debug 指令 link 可以帮助你做这件事:

pcall(skynet.call, address, "debug", "LINK")

May 07, 2016

skynet 服务的沙盒保护

昨天我们新的 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 的用户,是两个不同的用户,使用的不同的设备。