一次线上 热更新 故障的复盘记录
事情是这样的
上周三晚上八点多,我们照常给线上推了一次热更新(skynet + Lua 那套老组合),本来是个很小的改动,一个是结算接口 reward(id, num) 加了个 reason 参数,一个是把玩家身上的 hp 字段统一改名成 hp_now。本地测了没毛病,就按老流程把 patch 推上去了。不出问题的话就没有问题了,结果偏偏出了问题。
十分钟后运营在群里炸了,说有玩家反馈金币翻倍,同时错误日志开始刷 attempt to compare nil with number,一分钟几百条。当时我的第一反应是完了,这次改的东西正好踩在代码和状态的交界处,八成是热更新的锅。
排查过程
先说下我们的热更新机制,非常朴素:patch 脚本把新模块塞进 package.loaded,然后遍历模块表把函数一个个换掉,大概就这么几行:
package.loaded["game.battle"] = new_mod
for k, v in pairs(new_mod) do
old_mod[k] = v
end
这套东西跑了三年没出过大事,所以大家都习惯了闭眼推。这次一查日志就发现问题不对劲,报错的调用栈全指向一批更新前注册的 skynet.timeout 定时器——闭包里捕获的还是旧版本的函数引用。也就是说,模块表里的函数确实换成新的了,但散落在定时器、协程、事件回调里的旧函数还活着,同一个服务里,新旧两套代码在同时跑。
根因
复盘的时候把锅拆开看,其实就三条:
- 函数加参数没给默认值,默认所有调用方同步升级。热更新底下根本不存在“所有调用方同步”这回事,旧闭包永远存在。
- 字段改名只考虑了从数据库加载的路径,迁移逻辑写在加载里。线上玩家的数据全在内存,压根不走加载,等于只迁移了一半,另一半读出来是 nil。
- 我们的 reload 只换
package.loaded里的函数,不管闭包。以前没炸,纯粹是以前的改动没踩到这些还没死透的旧代码。
说实话这三条单拎出来我都知道,但凑在一起推上去的时候,我是真没想那么多。
更恶心的是两条旧路径串起来变成了资损:新代码读 hp_now 读到 nil,判断直接报错,而结算那边有个兜底逻辑,捕获到异常会当成“数据异常,补发一次”,于是金币就翻倍了。一个字段名,配上一段好心设计的兜底,就是一次事故。
怎么处理的
顺序上,先停掉所有待推的 patch,然后写了个修复脚本在内存里给在线玩家补字段:
local function repair(player)
if player.hp_now == nil and player.hp ~= nil then
player.hp_now = player.hp
player.hp = nil
end
end
想回滚是回滚不了的,字段已经写了一半新的,只能往前修。这个道理以前在别人的复盘里见过,这次算是真懂了。
奖励回收比想象中麻烦,得从结算流水里反查出那段时间被补发的记录,挨个扣回去,再给收到扣款的玩家发公告道歉,这块运营同事帮了大忙。代码侧倒是简单,reward 加上默认参数,再把所有定时器回调改成走注册表间接寻址,不让闭包直接咬住函数引用:
-- 之前,闭包会一直持有旧函数
skynet.timeout(100, function() do_settle(room) end)
-- 之后,调用时按名字解析到最新版本
timer_reg["settle"] = do_settle
skynet.timeout(100, function() timer_reg["settle"](room) end)
这样旧闭包就算还活着,调到的也是新代码了。这套写法不新鲜,你们要是本来就有统一的延迟调用入口,改起来就是十分钟的事,没有的话没准儿得像我一样满仓库找调用点。
后续加的几条防呆
都写进了 checklist,以后推 patch 之前对着过一遍:
- 函数参数只许加带默认值的;字段不许改名,非要改就得同时交一个内存迁移脚本,跑完所有在线实体才算更新完成。
- 定时器、延迟调用一律走注册表间接寻址,禁止闭包直接持有业务函数。
- 先灰度一个节点,观察十分钟再全量。以前是嫌麻烦全量一起推,现在想想省下的那十分钟真不值。
- patch 脚本必须幂等,重复执行不能出事。这次修复脚本半路报错重跑了一次,幸亏提前写了幂等,不然就是二次事故。
热更新这东西,平时越顺手越危险。三年没出事不代表机制没问题,只代表你的改动一直没踩到线上还没死干净的那些旧代码。写下来提醒自己,没准儿也提醒了正在闭眼推 patch 的你。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。