一、现象:源码替换后,运行中的服务仍是旧版本
复现条件:Lua 5.5.1(skynet 源码树内 3rd/lua/lua 的编译产物),两个模块加两个脚本。mod.lua 在被加载时读取同目录 version.txt 里的整数当作自己的版本号,holder.lua 持有对 mod 的独立引用。
执行 lua 1-no-reload.lua,把 version.txt 从 1 改成 2,再 require "mod" 一次:
[experiment 1] replace file only, no reload
before : hello from v1
disk now says : v2
after require : hello from v1
same table? : true
第二次 require 返回的表与第一次 == 相等,hello() 仍然返回 v1。磁盘上已经是 v2,内存里还是 v1。
换一条路径。在 A 模块里执行 mod = require "mod" 之后,A 手里的 mod.hello() 变成 v2;同一进程里 B 模块通过 require 拿到的那一份仍是 v1。一个进程内出现了两个版本同时在线。
一句话结论:替换文件只改变下一次 require 会读到什么,不改变任何已经在跑的代码。
二、结论:三层信息链,断哪层都不生效
这条链上有三层,彼此之间没有任何通知机制。
第一层是磁盘上的 .lua 文件。改它、覆盖它、换目录,都只是文件系统的事。Lua VM 不会因为文件变了而做任何动作,内核也不提供这种通知。
第二层是进程内存里的模块表。它只由 require 产生,一个模块对应一张表。文件变了但没人重新 require,这一层就不变。
第三层是调用点上握着的引用。每个 local mod = require "mod" 都在某个作用域里存了一份指向那张表的引用。第二层产生了新表,第三层不会自动改指。
三层全部接通,运行中的行为才会变。断在任何一层,表现都是"改了没生效",但病因完全不同------这一点决定了排查方向。
绕开这三层还有一条路:整目录符号链接切换后重启进程。发布时把新版本解到独立目录,current 指向它,重启服务。旧代码一直在盘上,所以回滚只需把链接指回去再重启,不需要重新上传。这条路不需要处理任何引用问题,因为新进程里根本没有旧对象。代价是每次换代码都要重启,冷却期内的玩家会掉线。
三、机制之一:require 查的是缓存表,不是文件系统
3.1 require 的三步
require 的实际动作只有三步:查 package.loaded[name],命中就返回;未命中则按搜索路径找到文件、执行它;把执行结果存入 package.loaded[name] 再返回。
第二步里"执行"意味着整个文件被跑一遍,文件末尾的 return M 就是存进缓存的那个值。所以同一个模块名永远只执行一次,后续所有 require 都是第一步命中。
这一步的接收者是 require 这个函数本身,不是文件系统。文件什么时候改的、改了没有,它完全不关心。要让第二步重新发生,必须先让第一步不命中:
lua
package.loaded["mod"] = nil
local fresh = require "mod"
3.2 skynet 在这之上还有一层字节码缓存
skynet 的 lualib/skynet.lua 提供 skynet.codecache,缓存的是 Lua 源文件编译后的字节码。它和 package.loaded 是两层不同的缓存:前者缓存编译结果,后者缓存执行结果。
两层都要清,require 才会真的重新读盘、重新编译、重新执行。只清 codecache 而 package.loaded 还留着,第一步照样命中,读到的是旧的执行结果。
codecache("CLEAR") 只影响之后的 require。已经在内存里的表、已经注册的闭包,一个都不会动。
四、机制之二:dispatch 里存的是函数对象,闭包捕获的是变量槽
4.1 skynet.dispatch 存的是什么
skynet.dispatch("lua", f) 的语义是把函数对象 f 存进当前服务的一张分发表。之后每条消息进来,取的就是这个函数对象。
存进去的是值,不是"某个文件第几行"。文件后来改成什么样,这个函数对象都不会变。所以改文件对这一层的作用是零。
4.2 闭包捕获的是变量,不是值的拷贝
Lua 的闭包捕获的是变量槽,多个闭包共享同一个槽。对槽重新赋值,所有看得见这个槽的闭包立刻读到新值。这一点是热更能够生效的前提。
lua
local logic = require "logic.drop" -- 槽里装着 表A
skynet.dispatch("lua", function(...)
logic.on_reward(...) -- 每次调用都读槽
end)
local function reload()
package.loaded["logic.drop"] = nil
skynet.codecache("CLEAR")
logic = require "logic.drop" -- 槽改指 表B
end
函数对象没换,但它每次调用都去读槽,所以槽一改,行为就变。这条成立的前提是运行时通过"表 + 字段名"查函数,而不是在启动时把函数本身取出来存进变量。
4.3 Lua 无法枚举谁持有这张表
local logic = require "logic.drop" 写在 A 模块里,local drop = require "logic.drop" 写在 B 模块里,两个都是各自 chunk 的局部变量。A 里的 reload 无法给 B 的 drop 赋值------它既不在作用域里,也不在可枚举的 upvalue 列表里。
还有更难处理的一种:player.logic = require "logic.drop",引用被存进了数据结构。这种连遍历都无从下手,除非把整个堆扫一遍逐个比对。
写法 1 和写法 2 的取舍就落在这里。
五、两种 reload 写法:差别落在变量槽上还是对象上
两段代码前四行完全相同,差别只在最后一行。
lua
-- 写法 1:重设变量
local logic = require "logic.drop"
local function reload()
package.loaded["logic.drop"] = nil
skynet.codecache("CLEAR")
logic = require "logic.drop" -- 槽改指新表
end
lua
-- 写法 2:原地覆盖
local logic = require "logic.drop"
local function reload()
package.loaded["logic.drop"] = nil
skynet.codecache("CLEAR")
local fresh = require "logic.drop"
for k, v in pairs(fresh) do logic[k] = v end -- 把新表内容写进老表
package.loaded["logic.drop"] = logic -- 缓存指回老表
end
写法 1 换掉的是槽里装的东西,老表还在,只是没人指向它。写法 2 不碰槽,改的是表里面的字段。
5.1 字节码层面的证据
luac -l 的输出(实测,Lua 5.5.1):
function <4-bytecode.lua:4,6>
3 [5] SETUPVAL 0 0 ; logic
function <4-bytecode.lua:8,10>
1 [9] SETTABUP 0 0 0 ; logic "x"
SETUPVAL 写的是 upvalue 本身,SETTABUP 写的是 upvalue 指向的表里的某个键。后一个函数里没有任何 SETUPVAL,说明那整段代码从未改过 logic 这个变量。
5.2 实测:三个实验的结果
同一份 mod.lua,同一份 holder.lua,只在 reload 那一步换成不同写法。runtime_state 是运行期手工加进模块表的字段,mod.lua 里没有它。
| 做法 | mod.hello() |
holder.get().hello() |
runtime_state |
mod == holder.get() |
|---|---|---|---|---|
| 只换文件,不 reload | v1 | v1 | 保留 | true |
| 写法 1 重设变量 | v2 | v1 | 丢失 | false |
| 写法 2 原地覆盖 | v2 | v2 | 保留 | true |
(以上为实测,bash run-all.sh 可复现)
表里三行说明三件事。写法 1 只让"自己手里那份"变新,其他持有者停在旧版本,因为它们的槽没有被改过。运行期累积在表上的字段在写法 1 下会一起消失,因为新表是重新执行模块体造出来的,模块体里没有这些字段。写法 2 改的是同一张表,所有持有者同时看到新代码,新表里没有的字段不会覆盖老表上的。
5.3 被排除的方案:每条消息都 require
还有第三种做法:不持有引用,在 dispatch 内部每次 require 一次。缓存命中时它只走查表和取字段两步,绝对开销很小,而且清缓存后下一条消息自动用新代码,不需要 reload 函数。
排除它的理由有三条。热路径上多付一次 C 函数调用和两次取字段,与其他方案差一个数量级(按字节码条数推算,未做微基准测试)。生效粒度是逐条消息,一次跨多条消息的业务流程可能前半段用旧代码、后半段用新代码。切换瞬间若文件正在被写入,还可能读到写了一半的源文件。
六、边界:热更新覆盖不到的场景
- 内存里的数据结构变更。新代码读不懂老 table,表现是热更之后立刻报错,比不更还糟。
- dispatch 的协议变更。消息参数个数、顺序、序列化方式变化,属于进程启动期就定死的部分。
- C 模块变更。
cservice/*.so、luaclib/*.so是编译产物,热更只作用于 Lua 源文件。 - 已经发生的业务副作用。发多的奖励、扣错的资源,热更只能让错误停止继续发生。
- 引用藏在数据结构里的情况。
player.logic = 表A这种存法,写法 1 既找不到也改不掉。
还有一条容易被忽略:每个服务各有独立的 Lua VM,reload 要在每个受影响的 VM 里各做一次。写法 2 解决的是一个 VM 内部多处引用的问题,不解决跨服务一次性生效的问题。
只有单引用、无运行期状态的模块,两种写法的行为完全一致。这种情况下选写法 1 不算错,只是还没遇到需要分叉的场景。
磁盘上的文件只决定下一次 require 读到什么,运行中行为的落点在内存里的那张表。改动落在变量槽上还是落在对象上,决定了它能不能传到其他持有者手里。写法 2 的实际优势是它不依赖"这个模块被谁引用过"这份清单。