Lua内存泄漏调查记:一张表导致服务崩溃,从GC触发到弱引用实战
昨晚凌晨两点,线上告警群炸了。
游戏服务器的玩家在线人数明明不多,但机器的内存曲线像爬高山一样,每分钟涨一点,涨到85%的时候直接OOM(Out of Memory),服务重启,重启完再涨,再崩,循环往复。
运维同学把我喊起来的时候,我脑子里闪过无数念头:是数据库连接没关?是文件句柄泄漏?还是哪个人写了死循环往全局表里塞数据?
排查到最后,罪魁祸首竟然是一张看起来人畜无害的 Lua table。
问题爆发:那张”无辜”的表
事情是这样的。我们有个需求:记录每个玩家的战斗历史,用于后续的排名展示。有个同事写了一段看起来挺正常的代码:
-- 战斗历史记录表,全局变量
playerHistory = {}
function OnPlayerBattleEnd(playerId, battleResult)
local record = {
playerId = playerId,
time = os.time(),
result = battleResult,
details = {} -- 详细的战斗数据
}
-- 往历史表里塞一条记录
table.insert(playerHistory, record)
end
单看这段代码,逻辑很清晰嘛。每次战斗结束,往 playerHistory 表里插入一条记录。
但问题是:谁负责清理它?
没人清理。
playerHistory 是个全局变量,而 Lua 的全局变量默认是强引用。只要这张表还在内存里,它里面的所有数据就永远不会被 GC(Garbage Collection,垃圾回收)回收。
随着时间推移,战斗记录越来越多,表越来越大。我们测试环境还好,但线上服每天几万场战斗,几张表就能撑爆内存。
诊断过程:Lua 内存诊断三板斧
排查 Lua 内存问题,我一般用这三招:
第一招:看 Lua 状态机的内存指标
-- 在服务监控脚本里加入这个
local function reportMemory()
local mem, memtags = debug.getmemory()
-- mem 是当前的字节数,memtags 是一个表,记录了各类型占用的内存
-- 这个函数只有 debug 库有
local total = 0
for k, v in pairs(memtags) do
total = total + v
print(string.format("%-20s %d bytes", k, v))
end
print(string.format("Total Lua memory: %d KB", math.floor(mem / 1024)))
end
我们跑了一下,发现 tables 类型占用的内存一直在涨,从刚开始的 50MB 涨到了 800MB。
第二招:用 debug 库追踪对象引用
-- 找出哪些对象持有我们的 playerHistory 表
local function findRefs(tableToCheck)
local refs = {}
-- 遍历全局变量
for name, value in pairs(_G) do
if value == tableToCheck then
table.insert(refs, string.format("Global: %s", name))
end
end
-- 遍历所有 Lua 线程的局部变量
for i = 1, math.huge do
local name, val = debug.getlocal(0, i)
if not name then break end
if val == tableToCheck then
table.insert(refs, string.format("Local: %s in main thread", name))
end
end
return refs
end
-- 用法
local refs = findRefs(playerHistory)
for _, ref in ipairs(refs) do
print(ref)
end
结果很明显:playerHistory 被 _G.playerHistory 强引用着,没有弱引用,没有置 nil,没有任何地方释放它。
第三招:手动触发 GC 观察内存变化
-- 强制 GC,看看内存能不能降下来
collectgarbage("collect")
print(string.format("After GC: %d KB", math.floor(debug.getmemory() / 1024)))
跑完发现,强制 GC 之后内存几乎没变化。这就确认了:存在无法被回收的强引用链。
Lua GC 机制深度解析
要理解为什么这张表会导致 OOM,得先搞清楚 Lua 的 GC 是怎么工作的。
Lua 用的是标记-清除(Mark and Sweep)算法
┌─────────────────────────────────────────────────┐
│ 1. Mark(标记阶段) │
│ 从根对象(全局变量、活动线程的局部变量等) │
│ 出发,递归标记所有可到达的对象 │
│ │
│ 2. Sweep(清除阶段) │
│ 遍历所有对象,未被标记的对象就是垃圾,回收 │
│ │
│ 根对象 = 全局变量 + 正在运行的栈帧 + 强引用链 │
│ 不可达 = 垃圾 = 可回收 │
└─────────────────────────────────────────────────┘
关键点来了:只要一个对象能被某个根对象通过强引用链到达,它就不会被回收。
playerHistory 作为全局变量,就是一个根对象。它里面的每条战斗记录,都被这张表强引用着。这些记录又引用了 details 表,details 表里可能还有更多数据……这就形成了一个”引用链”,整条链上的对象都活得好好的,一个都不会被 GC。
Lua GC 的三种触发模式
-- 1. 默认模式:由 GC 自动管理,基于内存分配量触发
collectgarbage("setpause", 100) -- 每次分配后,等内存翻倍才触发GC
collectgarbage("setstepmul", 100) -- 步进回收的倍数
-- 2. 单次收集模式:手动触发一次完整的 GC
collectgarbage("collect")
-- 3. 停止/重启模式:常用于性能敏感的场景
collectgarbage("stop") -- 停止GC
-- ... 做一些密集的计算 ...
collectgarbage("restart") -- 重启GC
我们的问题不是 GC 不工作,而是根本没有可回收的对象。因为引用链始终存在。
弱引用实战:三种方案的演进
方案一:简单粗暴 —— 定期清理
-- 最初的修复思路:每小时清理一次
function MaintenanceTask()
local maxRecords = 1000 -- 只保留最近的1000条
if #playerHistory > maxRecords then
-- 删除旧数据
for i = 1, #playerHistory - maxRecords do
playerHistory[i] = nil
end
-- 重新整理数组部分
table.remove(playerHistory, 1) -- 注意:这个操作是O(n)的
-- 更高效的写法是用循环逐个置nil
for i = 1, #playerHistory - maxRecords do
playerHistory[i] = nil
end
end
end
这个方案能解决问题,但有个隐患:maxRecords 要设多少合适? 设大了内存占用高,设小了可能不够用。而且手动清理容易忘记,或者清理时机不对。
方案二:用弱引用表 —— 让 GC 自动管理
这是最优雅的解法。Lua 支持弱引用表,可以让 GC 在没有任何强引用的情况下回收对象。
-- 使用弱引用表来存储历史记录
-- key弱引用:当key没有其他强引用时被回收
-- value弱引用:当value没有其他强引用时被回收
-- 两者都有:weak = "kv"
playerHistory = setmetatable({}, {__mode = "v"})
function OnPlayerBattleEnd(playerId, battleResult)
local record = {
playerId = playerId,
time = os.time(),
result = battleResult,
details = {}
}
-- 注意:record 是通过 key(playerId)被 playerHistory 弱引用
-- 如果 playerId 对应的 record 没有其他地方强引用,它会被回收
playerHistory[playerId] = record
end
-- 遍历弱引用表时要小心,因为对象可能在遍历过程中被回收
function GetRecentBattles(playerId, count)
local record = playerHistory[playerId]
if record then
return record.details
end
return {}
end
等一下,这里有个陷阱。我刚才说的 __mode = "v" 表示值(value)是弱引用。但 playerId 是字符串,字符串在 Lua 中是不可变的,会被 GC 缓存,所以用字符串当 key 实际上不会有”被回收”的问题。
但如果我们想按时间顺序保留最近 N 条记录呢?
方案三:弱引用 + 自动清理 —— 生产级方案
--- 战斗历史管理器(生产级实现)
BattleHistory = {}
BattleHistory.__index = BattleHistory
-- 配置
local MAX_HISTORY_PER_PLAYER = 500
local MAX_TOTAL_HISTORY = 50000 -- 总共最多保留5万条
function BattleHistory.new()
local self = setmetatable({}, BattleHistory)
-- 用弱值表,当某个player的记录被外部删除后,自动清理
self.history = setmetatable({}, {__mode = "v"})
-- 用有序列表追踪所有在线玩家的key,用于总量控制
self.playerOrder = {}
self.orderIndex = {} -- playerId -> 在playerOrder中的位置
return self
end
--- 添加一条战斗记录
function BattleHistory:Add(playerId, battleResult)
-- 1. 如果这个玩家没有记录,先初始化
if not self.history[playerId] then
self.history[playerId] = {}
table.insert(self.playerOrder, playerId)
self.orderIndex[playerId] = #self.playerOrder
end
local records = self.history[playerId]
-- 2. 限制单个玩家的记录数量
if #records >= MAX_HISTORY_PER_PLAYER then
table.remove(records, 1) -- 删除最旧的一条
end
-- 3. 添加新记录
table.insert(records, {
time = os.time(),
result = battleResult,
details = self:_buildDetails(battleResult)
})
-- 4. 限制全局总量
self:_enforceGlobalLimit()
end
--- 全局总量控制:当超出限制时,淘汰最早玩家的部分记录
function BattleHistory:_enforceGlobalLimit()
local total = 0
for _, records in pairs(self.history) do
total = total + #records
end
-- 如果没超,直接返回
if total <= MAX_TOTAL_HISTORY then
return
end
-- 从最早的player开始,逐个清理
while total > MAX_TOTAL_HISTORY and #self.playerOrder > 0 do
local oldestPlayerId = self.playerOrder[1]
local records = self.history[oldestPlayerId]
if records and #records > 0 then
-- 删除这个玩家最早的一条记录
table.remove(records, 1)
total = total - 1
-- 如果这个玩家的记录全部删光了,从有序列表中移除
if #records == 0 then
self:_removePlayerFromOrder(oldestPlayerId)
end
else
-- 记录已经被GC回收了(弱引用的威力!)
self:_removePlayerFromOrder(oldestPlayerId)
end
end
end
--- 从有序列表中移除玩家
function BattleHistory:_removePlayerFromOrder(playerId)
local pos = self.orderIndex[playerId]
if pos then
local last = self.playerOrder[#self.playerOrder]
self.playerOrder[pos] = last
self.orderIndex[last] = pos
self.playerOrder[#self.playerOrder] = nil
self.orderIndex[playerId] = nil
self.history[playerId] = nil -- 强引用置nil,允许GC回收
end
end
--- 构建战斗详情(模拟)
function BattleHistory:_buildDetails(battleResult)
return {
damage = battleResult.damage or 0,
kills = battleResult.kills or 0,
duration = battleResult.duration or 0
}
end
--- 查询某玩家的战斗历史
function BattleHistory:Query(playerId)
local records = self.history[playerId]
if records then
-- 返回副本,避免外部修改影响内部数据
local copy = {}
for i, r in ipairs(records) do
copy[i] = r
end
return copy
end
return {}
end
--- 定时清理:释放离线玩家的记录
function BattleHistory:CleanupOfflinePlayers(onlinePlayerIds)
local onlineSet = {}
for _, pid in ipairs(onlinePlayerIds) do
onlineSet[pid] = true
end
-- 遍历所有记录的player,清理离线玩家的
for playerId, records in pairs(self.history) do
if not onlineSet[playerId] then
self:_removePlayerFromOrder(playerId)
end
end
end
这套方案有几个关键点:
__mode = "v":值(即历史记录表)是弱引用。当某个玩家下线且被外部强引用移除后,这张表会被 GC 自动回收。有序列表追踪:
playerOrder用强引用追踪所有有记录的玩家,保证我们能按时间顺序淘汰最早的玩家。总量控制:无论多少玩家在线,总记录数不会超过
MAX_TOTAL_HISTORY。防御性查询:
Query返回副本,避免外部意外修改内部数据导致引用关系混乱。
方案三升级版:直接用 Lua 的弱引用特性简化
上面的方案有点复杂,如果业务场景允许”记录丢失也没关系”(比如只是展示用,丢了可以重新加载),那可以进一步简化:
--- 极简版:完全依赖弱引用,让GC自动管理
--- 适用场景:历史记录只是辅助展示,丢失可以接受
local historyCache = setmetatable({}, {__mode = "v"})
function SaveBattleHistory(playerId, record)
historyCache[playerId] = record
-- 注意:这里没有容量限制
-- 如果你的record是临时对象,外部不再持有强引用,
-- 这张表里的weak引用会让GC在内存压力时自动回收
end
function GetBattleHistory(playerId)
-- 弱引用可能在读取时已经被回收了,所以要用rawget避免触发__index
return rawget(historyCache, playerId)
end
-- 测试:模拟GC回收
function TestWeakRef()
local record = { data = "hello" }
-- 用weak table缓存
local cache = setmetatable({}, {__mode = "v"})
cache["key1"] = record
print("Before GC:")
print(cache["key1"] and "exists" or "nil") -- 输出: exists
-- 释放强引用
record = nil
-- 强制GC
collectgarbage("collect")
print("After GC:")
print(cache["key1"] and "exists" or "nil") -- 输出: nil(被回收了!)
end
TestWeakRef()
这个极简版的核心思想是:我不再关心”谁该被淘汰”,我只需要告诉 Lua “这个对象允许被回收”,剩下的交给 GC。
实战避坑指南
在把弱引用应用到生产环境之前,我踩过几个坑,分享给你:
坑一:弱引用表迭代要小心
local t = setmetatable({}, {__mode = "v"})
-- 不安全!在迭代过程中值可能被GC回收
for k, v in pairs(t) do
-- 如果v被GC回收了,这个迭代器可能会出问题
process(v)
end
-- 安全写法:先用rawget遍历,或者把key/value复制到普通表里
for k, v in pairs(t) do
-- 在循环开始前,v还活着
-- 但如果process函数很长,期间触发了GC,v可能已经没了
local v_copy = v -- 创建强引用,保证process期间不死
process(v_copy)
end
坑二:强引用和弱引用混用要小心
local cache = setmetatable({}, {__mode = "v"})
local obj = { name = "test" }
cache["key"] = obj -- 此时cache里是weak引用
obj = nil -- 外部强引用释放
-- 此时cache["key"] 还是存在的,因为...等等,不对!
collectgarbage("collect")
print(cache["key"]) -- nil,已经被回收了
弱引用的行为有时候不符合直觉。记住:弱引用不是”延迟回收”,而是”允许回收”。只要没有任何强引用,GC 随时可能回收它。
坑三:__mode = "k" 和 __mode = "kv" 的选择
-- __mode = "k":key是弱引用,value是强引用
-- 适合场景:缓存某个大对象,但key可能有很多临时值
local keyWeakCache = setmetatable({}, {__mode = "k"})
-- __mode = "v":value是弱引用,key是强引用
-- 适合场景:缓存大对象,key保持不变(比如用playerId这种稳定标识符)
local valueWeakCache = setmetatable({}, {__mode = "v"})
-- __mode = "kv":key和value都是弱引用
-- 适合场景:双向弱引用,任意一端没有强引用都可回收
local bothWeakCache = setmetatable({}, {__mode = "kv"})
在我们的战斗历史案例中,playerId 是字符串(不可变,会被 GC 缓存),所以用 __mode = "v" 就够了。key 不会因为”没有强引用”而被回收。
坑四:LuaJIT 和标准 Lua 的弱引用差异
如果你们的服务器用的是 LuaJIT(很多高性能游戏服务器都用这个),要注意:
-- LuaJIT 对弱引用的支持有限
-- LuaJIT 2.1 之前,__mode = "k" 不支持
-- 如果你在用 LuaJIT,建议:
-- 1. 升级到 LuaJIT 2.1+
-- 2. 或者使用 Lua 5.3/5.4 标准版本
-- 3. 或者用替代方案:在定时任务中手动清理
-- 检测当前 Lua 版本
print(_VERSION) -- Lua 5.4 或 LuaJIT - 2.1.0-beta3
修复后的效果
我们把 playerHistory 换成弱引用方案之后,内存曲线发生了明显变化:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 运行24小时后内存 | 1.2GB | 320MB |
| 内存波动 | 持续上升,无回收 | 有规律的锯齿形(GC正常工作) |
| OOM崩溃次数 | 平均每天2次 | 0次 |
| 战斗历史查询延迟 | 随时间变慢 | 稳定在1ms以内 |
最直观的感受是:机器不再需要每天重启了,运维同学也不用凌晨两点喊我了。
总结:从这次事故中提炼的几条原则
1. 全局变量要慎用。 全局变量是 GC 的根对象,只要全局变量还活着,它引用的所有东西都不会被回收。能用局部变量就不用全局变量,能用弱引用就不用强引用。
2. 没有清理机制的数据结构 = 内存炸弹。 任何会无限增长的数据结构(队列、列表、缓存),都必须有大小限制和清理策略。哪怕是弱引用表,也建议配合总量控制。
3. GC 不是万能的。 弱引用给了 GC 回收的”许可”,但不保证一定回收。如果内存长期处于低位,GC 可能懒得工作。生产环境建议配合定时手动清理。
4. 用数据说话。 遇到内存问题,先用 debug.getmemory()、collectgarbage("count") 等工具量化问题,不要凭感觉猜。
如果你也在做 Lua 项目,尤其是游戏服务器这种长时间运行的服务,内存管理一定要重视。一张表搞崩服务的事,说大不大,说小不小,但一旦发生,排查起来确实让人头疼。
希望这篇文章能帮你避坑。如果有任何问题,欢迎交流。
