最近我负责的一个线上 Lua 服务偶尔会 crash,陆陆续续查了好几个星期,上周终于解决了,记录一下排查的过程和思路,这次排查中第一次按照之前看的一本故障排查的技巧进行分析,感觉很有用。
首先介绍下线上服务的架构,我们线上是一个推荐系统,推荐系统的重排模块使用 Lua 开发,使用 Lua 的原因,主要是 Lua 作为脚本语言,性能比较好,容易和 C++结合,也适合应付需求的迭代(我们系统 80% 的临时需求,都在重排模块实现,需要快速迭代)。
重排模块的网络框架用 C++ 实现,我们对 C++ 和 Lua 的交互接口进行了一层封装;Lua 代码用来实现重排的业务逻辑。Lua 和 C++ 通过一些接口来互相调用,例如
Profile 和 Meta 都使用 Protobuf 定义,框架会通过 Protobuf 的反射,把 Protobuf 结构转 换成 Lua 中的 table,而 DocInfo 则放在共享内存中,没有使用 Protobuf 定义。DocInfo 信息需要经常更新,更新 DocInfo 的操作不是一个增量更新,而是全量更新 DocInfo,因此更新操作以后,同一个 DocInfo 的地址会发生变化,我们会使用 double buffer 的方法,来更新 DocInfo,减少更新操作对请求的影响。
在迭代过程中,GetDocInfo 是做了优化的。最初 GetDocInfo 需要实现一个转换函数,把 DocInfo 整体翻译成 Lua table,但分析发现一个 DocInfo 中的字段并不都会在 Lua 中使用,因此这个整体的翻译操作,实际上做了很多无用功。因此,我们使用了 Lua 的 userdata 来进行优化,原理就是 Lua 调用 GetDocInfo 时,直接返回一个 userdata 的结构,userdata 实际 的内容,是一个指向 DocInfo 实际内存位置的指针,当真的需要使用 DocInfo 中的某个字段时,再通过 userdata 的 index 方法,解析对应的字段,返回给 Lua,这个优化可以把整个重排的耗时降低10%左右,单接口的耗时减少 50% 以上,这当然不好直接比较,因为修改后的方法,只是获取一个指针,相当于是在使用字段时分摊了翻译字段的时间。
此外,这个过程还可以实现一些缓存,例如:
分析 crash
crash 就发生在使用 DocInfo 的某个字段时,core 文件能够提供的信息很少,一是因为堆栈中的 DocInfo 地址已经不可读,无法从 DocInfo 中获取导致 crash 的 id;二是服务采用了 gcc -O2 的编译参数,一些堆栈信息被优化了,堆栈中没有发现有用信息;三是因为调用过程经过了 Lua,gdb 无法直接打印 Lua 中的堆栈信息,不知道 Lua 代码执行到了哪一行,也导致了分析没有头绪。
调试信息一
由于线索比较少,首先就是希望能找到导致 crash 的 id,通过逐层打印堆栈信息,我发现可以在 index 对应的处理函数中增加一个临时变量,记录 DocInfo 的 id,这样没准下次 crash 时,就可以知道导致 crash 的 id了。
再次 crash 时,我检查乐这个增加的 id 信息,发现已经不是一个合法的 id,此时我推测是不是调用 GetDocInfo 获取的 Docinfo 就是错的,如果是这样,虽然还没有找到问题的原因,但是可以增加代码,处理这种异常,例如:我可以在 GetDocInfo 内部获取到 DocInfo 指针时,立刻将输入的 id 和 DocInfo 的 id 做一次对比,如果不一致,就认为 DocInfo 已经失效,返回错误。
调试信息二
这次改动并没有解决问题,进程还是会 crash,这说明在获取 Docinfo 时,这个 id 还是正确的,是在之后的某个时间点, DocInfo 的地址才失效。此时暂时没有思路,希望能有更多的调试信息,我想看到 Lua 的调用栈,看看能不能有线索,google 发现 OpenResty 的项目中,有个开源的 工具 可以通过 lua_State 指针在 gdb 中展示 Lua 的堆栈信息,只是这个工具和 OpenResty 有些依赖,还需要自己 小小修改,尝试了几次后,我看到了core 文件中的 Lua 堆栈,同时 Lua 各个栈中的变量也都可以查看,此时可以参考的信息变多了,但是依然没有发现线索。
调试信息三
于是我又回过头,思考什么情况下 DocInfo 地址会失效。就算是地址失效也分两种情况:
这些猜测都要想办法验证。首先就是修改代码,在 C++ 中增加一个 debug 信息,记录当前请求中,所有调用 GetDocInfo 的 id 和对应返回的 DocInfo 地址。我希望不光是能在 core 文件的堆栈中看到这个信息,还要能看到 crash 进程之前请求中的 debug 信息;另外,通过/proc/id/smap 可以看到共享内存中两份索引使用的内存地址范围,这样就可以通过 DocInfo 的地址,知道用的是哪份索引文件。
第 N 次分析 crash
再次遇到 crash,此时因为有几个思路都要验证,为了不走回头了,我特意开了一个文档,记录自己这次分析中的一些信息,例如
然后对这些信息进行分析
最后的分析
此时已经有些思路了,感觉 crash 的请求,是使用了缓存中的 DocInfo 地址。检查代码,发现上面提到过的第三种缓存可能会有些问题。我们服务的 Lua 调用链如下所示:
-- main.lua
function main()
local tuner = require "tuner.lua"
tuner.doTuner()
-- tuner.lua
local context = {
local cache = {}
local function GetDoc(id)
if context.cache[id] ~= nil then
return context.cache[id]
local DocInfo = GetDocInfo(id)
if DocInfo ~= nil then
context.cache[id] = DocInfo
return DocInfo
local _M = {}
function _M.doTuner()
GetDoc(id)
-- other logic
context.cache = {}
return metas