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
优化点:
- 使用时间取整降低key唯一性
- 显式设置缓存过期时间
- 限制缓存最大条目数
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
危险操作:
- 忘记调用free会导致native内存泄漏
- 错误的内存读写可能引发段错误
6. 技术方案对比
检测手段 | 实时性 | 精度 | 性能损耗 | 适用阶段 |
---|---|---|---|---|
内存监控接口 | 高 | 中 | 低 | 生产环境 |
压力测试对比 | 低 | 高 | 中 | 测试环境 |
Valgrind工具 | 低 | 极高 | 极高 | 开发环境 |
火焰图分析 | 中 | 高 | 中 | 诊断环境 |
7. 防泄漏编码规范
- 全局变量使用
_G
前缀标识 - 所有缓存必须设置过期时间或大小限制
- FFI操作必须成对使用malloc/free
- 定时器必须设置终止条件
- 避免在循环中创建闭包
8. 总结与思考
在OpenResty的实践中,Lua内存泄漏就像慢性病,初期症状不明显但后期危害极大。通过本文的检测方法和修复示例,我们可以建立起三层防御体系:
- 预防层:代码规范 + 模块化设计
- 监控层:实时内存统计 + 阈值告警
- 修复层:内存分析工具 + 渐进式优化
记住,没有绝对完美的代码,但通过合理的工具链和规范的编码习惯,我们可以将内存泄漏的风险降到最低。当你的服务再次"发福"时,希望这篇文章能成为你的健身教练。