1. 当你的OpenResty服务开始"发福"

最近接手了一个电商平台的API网关项目,使用OpenResty处理每天上亿的请求。某天凌晨突然收到监控告警:Nginx worker进程内存占用突破2GB!就像突然发福的中年人,我们的服务在持续运行一周后内存不断上涨。经过排查,发现是Lua脚本中存在隐蔽的内存泄漏。

2. Lua内存泄漏的典型场景

2.1 全局变量的幽灵

-- 错误示例:在模块级别定义全局缓存
local _M = {}

-- 这个字典会随着时间推移无限膨胀
local request_cache = {}

function _M.cache_request(uri, params)
    -- 将每次请求参数存入全局字典
    local key = ngx.md5(uri .. ngx.now())
    request_cache[key] = { -- 🚨 这里埋下隐患
        time = ngx.now(),
        params = params
    }
    return key
end

return _M

应用场景:需要临时缓存请求参数的场景
技术栈:OpenResty + LuaJIT 2.1
问题分析:这个字典没有设置淘汰机制,随着时间推移会持续增长。更危险的是当使用ngx.now()作为key的部分时,每个请求都会生成唯一键值。

2.2 定时器的记忆陷阱

-- 错误示例:定时任务中的闭包引用
local delay = 5  -- 5秒间隔
local handler

handler = function()
    local big_data = {}  -- 每次创建新数据
    for i=1,10000 do
        big_data[i] = string.rep("a", 1024)  -- 生成1MB数据
    end
    
    -- 保持对前一次数据的引用 🚨
    if handler.last_data then
        handler.last_data = nil  -- 本意是释放旧数据
    end
    handler.last_data = big_data
    
    ngx.timer.at(delay, handler)  -- 递归调用
end

-- 启动定时器
ngx.timer.at(delay, handler)

技术栈:OpenResty 1.19.3
死亡螺旋:虽然看似在清空旧数据,但每次递归调用都会创建新的闭包作用域,导致旧的handler闭包无法被GC回收,连带其引用的big_data也无法释放。

3. 内存泄漏检测三板斧

3.1 实时内存监控

# 使用OpenResty自带的调试接口
curl http://127.0.0.1/status/lua/shdict?detail=1

# 输出示例:
shared_dict:cache_zone
    total: 1048576 bytes
    free: 524288 bytes
    used: 524288 bytes
    ...

优点:实时性强,无需停机
缺点:只能监控shared dict的内存使用

3.2 内存快照对比

-- 在请求处理前后记录内存状态
local function mem_diff()
    collectgarbage()  -- 强制执行GC
    local before = collectgarbage("count")
    
    -- 执行怀疑有泄漏的代码
    leaky_function()
    
    collectgarbage()
    local after = collectgarbage("count")
    ngx.log(ngx.ERR, "内存变化:", after - before, "KB")
end

最佳实践:在测试环境反复执行可疑代码段,观察内存是否持续增长

3.3 火焰图定位

# 使用systemtap工具抓取内存分配热点
sudo stap --vp 10000 \
    -e 'probe process("nginx").function("luaM_malloc") {
        print_stack()
        exit()
    }' \
    -c "/usr/local/openresty/nginx/sbin/nginx -s reload"

输出解析:重点关注频繁出现的luaM_malloc调用栈,对应到Lua代码中的可疑位置

4. 修复方案实战

4.1 缓存改造计划

-- 正确示例:带LRU淘汰机制的缓存
local lru = require "resty.lrucache"
local cache, err = lru.new(200)  -- 最多缓存200个条目

function _M.safe_cache(uri, params)
    local key = ngx.md5(uri .. ngx.now() // 60)  -- 每分钟生成相同key
    local cached = cache:get(key)
    
    if not cached then
        cached = {time = ngx.now(), params = params}
        cache:set(key, cached, 60)  -- 60秒自动过期
    end
    
    return cached
end

技术栈:lua-resty-lrucache 0.10
优化点

  1. 使用时间取整降低key唯一性
  2. 显式设置缓存过期时间
  3. 限制缓存最大条目数

4.2 定时器生命周期管理

local timer_ctx = {
    active_timers = {},
    max_count = 10
}

local function safe_handler()
    -- 创建前检查活跃计时器数量
    if #timer_ctx.active_timers >= timer_ctx.max_count then
        local oldest = table.remove(timer_ctx.active_timers, 1)
        oldest:cancel()  -- 取消最旧的定时器
    end
    
    local function _handler()
        -- 业务逻辑...
        
        -- 执行完成后自动移除
        for i, t in ipairs(timer_ctx.active_timers) do
            if t == timer then
                table.remove(timer_ctx.active_timers, i)
                break
            end
        end
    end
    
    local timer, err = ngx.timer.at(5, _handler)
    if timer then
        table.insert(timer_ctx.active_timers, timer)
    end
end

核心思想:实现定时器池管理,防止无限递归创建

5. 关联技术:FFI内存管理

local ffi = require "ffi"

ffi.cdef[[
    void* malloc(size_t size);
    void free(void* ptr);
]]

local function safe_ffi_use()
    local buf = ffi.C.malloc(1024)
    -- ...使用缓冲区...
    
    -- 必须显式释放!
    ffi.C.free(buf)
    buf = nil  -- 消除Lua侧的引用
end

危险操作

  1. 忘记调用free会导致native内存泄漏
  2. 错误的内存读写可能引发段错误

6. 技术方案对比

检测手段 实时性 精度 性能损耗 适用阶段
内存监控接口 生产环境
压力测试对比 测试环境
Valgrind工具 极高 极高 开发环境
火焰图分析 诊断环境

7. 防泄漏编码规范

  1. 全局变量使用_G前缀标识
  2. 所有缓存必须设置过期时间或大小限制
  3. FFI操作必须成对使用malloc/free
  4. 定时器必须设置终止条件
  5. 避免在循环中创建闭包

8. 总结与思考

在OpenResty的实践中,Lua内存泄漏就像慢性病,初期症状不明显但后期危害极大。通过本文的检测方法和修复示例,我们可以建立起三层防御体系:

  1. 预防层:代码规范 + 模块化设计
  2. 监控层:实时内存统计 + 阈值告警
  3. 修复层:内存分析工具 + 渐进式优化

记住,没有绝对完美的代码,但通过合理的工具链和规范的编码习惯,我们可以将内存泄漏的风险降到最低。当你的服务再次"发福"时,希望这篇文章能成为你的健身教练。