一、现象:同一个模块,三种手段改出三种覆盖面
环境:skynet 源码 commit 64391f732e76,Lua 5.5.1(3rd/lua/lua),节点上跑两个服务 A 和 B,两者各自 require 同一份 drop.lua。
改掉 drop.lua 之后,三种手段的覆盖面不同。只改文件,A 和 B 都不变,因为两个 VM 里的 package.loaded 都还留着旧表。在 A 里执行一次模块 reload,A 变,B 不变——B 是另一个 VM,它那份表从头到尾没被碰过。执行 clearcache 清掉编译缓存,A 和 B 仍然都不变,因为清缓存只是让下一次 require 重新读盘,而此刻没有任何人在 require。
前两条是纯 Lua 层面的实测(mod.lua + holder.lua 两个文件的实验,输出见上一篇)。第三条依据的是 service/debug_console.lua 第 180 行:COMMAND.clearcache 的全部实现就是调用 codecache.clear(),源码里没有别的动作。
三种"没生效"的表现看起来一样,病因完全不同,要用的手段也不同。这就是在 skynet 里做热更要先分清的事。
二、结论:skynet 给的是三条粒度不同的通道,不是一条
debug_console 的命令表(service/debug_console.lua 第 144 行起)里,与热更相关的有三条命令:clearcache、inject、以及 debug 协议背后的 injectcode。它们作用在完全不同的层上。
| 通道 | 落点 | 能改什么 | 丢不丢状态 |
|---|---|---|---|
clearcache | Lua 代码编译缓存 | 不直接改运行中的任何东西,只影响之后的 require | 不丢 |
inject | 目标服务的 upvalue 表 | upvalue 里存着的 table 的内容 | 不丢 |
injectcode | 目标协程栈帧的局部变量和 upvalue | 局部变量也能写回 | 不丢 |
三条都不重启进程,所以都不丢服务内的内存状态。代价是三条都只作用于单个服务,且都只活在内存里——服务一重启,补丁就没了。
被排除的选项有两个。整进程重启覆盖面最大,但代价是全部服务掉线。每条消息都 require 一次能让改动自动生效,代价是热路径上多付一次 C 调用和两次取字段(按字节码条数推算,未做微基准测试),且生效时机不可控。
三、机制:三条通道各自改到了什么
3.1 clearcache 清的是编译缓存,不是模块表
skynet.lua 第 49 行把 codecache 挂到 skynet.cache 上,debug_console 的 clearcache 调的就是它的 clear()。
清掉的是 Lua 源文件编译后的字节码。清完之后下一次 require 会重新读盘、重新编译,但 package.loaded 里那张表不会消失。所以单发 clearcache 对运行中的服务没有任何效果,它只是把"下一次重读"的前提准备好了。
要让改动真正落地,得有人在清完之后重新 require。这个人不能是框架,只能是一段主动执行的代码,或者一次服务重启。
3.2 inject 把源码送进目标服务的 upvalue 环境
COMMAND.inject(address, filename) 的实现只有十行左右(debug_console.lua 第 270 行起):打开文件读全部源码,然后
skynet.call(address, "debug", "RUN", source, filename, ...)
目标服务侧接住这段源码的是 lualib/skynet/inject.lua。它做四件事。
第一件,遍历传进来的函数,用 debug.getupvalue 把每个函数的上层值递归收集成一张表 u。遇到 table 就收下,遇到 function 就继续往下钻。
第二件,从 u.proto 里取出协议表,把每个协议的 dispatch 函数也做一遍同样的收集,得到 p。
第三件,构造一个执行环境:
local env = setmetatable({ print = print, _U = u, _P = p }, { __index = _ENV })
local func = load(source, filename, "bt", env)
_U 就是收集来的 upvalue 表,_P 是协议表,__index 指向 _ENV 所以全局函数照样能用。
第四件,skynet.pcall(func, ...) 执行,把 print 的输出收集成字符串数组返回。
关键在 _U 里的值和目标服务自己的 upvalue 是同一张 table,不是拷贝。注入代码里写 _U.player.gold = 0,改的就是那个服务内存里正在用的玩家数据。
_U 的键是 upvalue 的名字,也就是服务里那个 local 变量的名字。服务写的是 local drop = require "logic.drop",补丁里就得用 _U.drop 去拿,写模块名没用。
拿到之后往这张 table 里写什么都可以,包括函数字段。_U.logic.on_reward = 新的实现 会直接改变服务的后续行为,因为服务每次调用都是通过这张表查字段。
inject 碰不到局部变量。局部变量在栈帧上,不挂在任何函数的 upvalue 里,收集不到。它同样碰不到函数类型的 upvalue——getupvaluetable 遇到 function 是递归进去收集它的 upvalue,而不是把这个函数本身放进 _U。所以 local f = require("x").f 这种写法,补丁里没有 _U.f 可以替换。
3.3 injectcode 用 Debug 库把补丁 graft 到栈帧上
lualib/skynet/injectcode.lua 是更底层的一条。它不用 upvalue 表当跳板,直接操作栈帧。
先用 debug.getlocal(co, level, i) 逐个枚举目标协程当前栈帧的局部变量,再用 debug.getupvalue 枚举目标函数的 upvalue,两者合起来拼成一个参数表,套进模板生成一个新的函数去 load。
关键一步是 debug.upvaluejoin(func, i, f, upvalue_id)。它让注入函数的某个 upvalue 与目标函数的某个 upvalue 共享同一个槽,写入一边等于写入另一边。
执行完之后,模板里第二个返回值 update 会给出需要写回的局部变量,外层用 debug.setlocal(co, level, k, v) 把它们写回目标栈帧。
所以这条通道既能读也能改目标函数作用域里的局部变量。代价是它依赖 debug 库、依赖拿到目标协程,而且写回只在执行点那一刻有效。
3.4 补丁能改到哪一步,由代码最初的引用方式决定
3.2 里那句「_U.logic.on_reward 可以替换」不是无条件成立的。它取决于服务当初是怎么引用这个模块的。同一个模块,两种写法,补丁的命中率完全不同。
-- 补丁碰不到的写法:函数被闭包捕获,永远指向旧实现
local on_reward = require "reward".on_reward
skynet.dispatch("lua", function(session, source, cmd, ...)
if cmd == "reward" then return on_reward(...) end
end)
-- 补丁能改的写法:通过表间接调用,表本身可以被替换
local reward = require "reward"
skynet.dispatch("lua", function(session, source, cmd, ...)
if cmd == "reward" then return reward.on_reward(...) end
end)
差别在闭包捕获了什么。第一种写法捕获的是一个函数对象,getupvaluetable 遇到函数类型的 upvalue 是递归进去收集它的 upvalue,不把函数本身放进 _U,所以补丁里没有 _U.on_reward 这个入口,只能看着它继续调旧实现。第二种写法捕获的是 table reward,它会被收进 _U,补丁写 _U.reward.on_reward = 新实现 就生效。
还有一种更省事的做法:服务自己预留一个热更入口,不依赖注入。
local function reload()
package.loaded["reward"] = nil -- 丢掉模块缓存
skynet.cache.clear() -- 丢掉编译缓存
reward = require "reward" -- 重新读盘,顺着 current 读到新文件
end
这段用的是重设变量,只覆盖这一个文件里的 reward。同一 VM 里如果还有别的模块也 require 了 reward,它们手里的引用不会被改到。要一次覆盖全部持有者,得用第四节那种原地覆盖表的写法。
预留入口和注入各有代价。预留入口需要业务代码里事先写好判断逻辑和触发条件,写之前就要想到"这个服务以后可能要热更"。注入不需要业务配合,但受 3.2 和本节这两条限制:碰不到局部变量,也碰不到直接捕获的函数对象。
四、操作:一次修复的完整顺序
先定位。从日志里找出报错时的服务地址和文件行号,确认要改的是哪个服务里的哪个模块。同一份逻辑模块被多个服务引用时,要列出全部受影响的地址。
再判断能不能更。改了数据结构、改了 dispatch 协议、改了 C 模块的,三条通道都不适用,直接走重启。这一步先用掉,可以省掉后面全部的无效尝试。
然后选通道。只是公式算错、判断写反这类纯逻辑问题,且补丁需要碰服务内部状态时,用 inject。需要改的是函数内部的局部变量(比如某个循环里的累加器),用 injectcode。两项都不沾,只是想让新代码被读到,clearcache 加一次服务重启最省事。
执行。进入调试台:
telnet 127.0.0.1 8000
先 list 确认服务地址,再注入:
inject :00000008 /opt/hotfix/drop_fix.lua
补丁里从哪里拿新代码,决定了这次热更之后磁盘和内存是否一致。做法是让补丁顺着 current 读发布目录,再原地覆盖服务里那张表:
-- patch.lua:入库、评审、版本化的热更脚本
local skynet = require "skynet"
local TARGET = _U.drop -- 服务里 local drop 那个 upvalue,只有 table 类型会被收集
package.loaded["logic.drop"] = nil
skynet.cache.clear() -- 清编译缓存
local fresh = require "logic.drop" -- 顺着 current 读到新代码
for k, v in pairs(fresh) do TARGET[k] = v end
print("patched logic.drop")
_U 里的值和服务的 upvalue 是同一张 table,所以往 TARGET 里写字段,服务下次调用就走新代码,而 _U 里的引用本身不需要换。
确认生效。inject 的返回值就是补丁里所有 print 的输出,按行拼接。上面这段注入后调试台会直接回显 patched logic.drop,没回显就是没进去。这一步比翻日志快,也是这套通道里唯一自带的生效确认。
回滚。注入是无状态的,它执行完就结束,所以回滚等于再注入一份旧逻辑;或者把代码目录切回旧版,clearcache 之后重启该服务。
收尾。确认这次修复已经在发布目录里。补丁脚本本身要入库并带版本号,否则下一次重启虽然加载的是同一份代码,但没人说得清上一次为什么那样改。
五、边界:三条通道都碰不到的东西
- 数据结构变更。新代码读不懂老 table,注入后立刻报错,比不注入更糟。
- dispatch 协议变更。消息参数个数、顺序、序列化方式变化,属于服务启动期就定死的部分。
- C 模块。
cservice/*.so、luaclib/*.so是编译产物,三条通道都只作用于 Lua 源文件。 - 已经发生的业务副作用。发多的奖励、扣错的资源,热更只能让错误停止继续发生。
- VM 边界之外的东西。一个节点上几十个服务就是几十个 VM,注入到 A 服务的补丁到不了 B 服务;跨节点同理。
- 磁盘上没有的改动。注入只写内存,补丁里那段逻辑如果没有对应的发布,重启就回到旧代码。
inject 还有一个容易忽略的限制:它收集的是「函数的 upvalue 里的 table」。如果服务的状态存在局部变量里、或者存在闭包里但被 debug.getupvalue 看不到的位置(比如通过 debug.upvaluejoin 共享的槽),_U 里就没有它。这种情况下 inject 会静默地什么都没改到,返回值照样是那几行 print。
injectcode 的限制在协程。它要拿到目标协程的栈帧,协程处于挂起状态时写回的效果取决于挂起点;对正在 yield 中的协程做 setlocal,恢复后未必按预期生效。
热更之后磁盘和内存会不会分叉,取决于补丁从哪里拿代码,不取决于用了哪条通道。补丁从 /tmp 现写,两边就分叉,机器再也判断不出线上是什么;补丁顺着 current 读发布目录并原地覆盖,两边就是同一份,重启之后自动收敛。
可以靠技术手段控制的漂移,有三处:服务启动时上报 VERSION,热更时更新这个上报值,管理端随时能查到每个服务跑的是哪版;补丁脚本入库并绑定 release 号,注入源不允许是临时目录;把发布和注入串成一条流水线,发布成功才允许注入。
剩下真正只能靠纪律的只有一种情况:绕过发布,在调试台直接敲代码改状态,改完不进版本库。这时候机器无法判断线上是什么,因为改动的来源根本不在进版本库之前。
三条通道的覆盖面都止于单个服务。要一次覆盖全部服务,只有重启进程,或者把每个服务都执行一遍。
在 skynet 里做热更,先要分清"没生效"是哪一层断的:文件没被读到、模块没重新 require、还是补丁没送到目标服务。
clearcache 只准备读取条件,真正的改动必须由某段主动执行的代码完成。inject 与 injectcode 是两种完成方式,前者改 upvalue 里的状态,后者改栈帧上的局部变量。
三条通道都不写磁盘,所以顺序是先把磁盘改对,再让内存跟上。反过来做,等于用内存去追一个还没定稿的磁盘状态。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/m0_74234485/article/details/170380445



