Lua 热更新不生效:改完文件线上仍是旧逻辑,package.loaded 与 upvalue 重绑定详解

一、现象:源码替换后,运行中的服务仍是旧版本

复现条件: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 的实际优势是它不依赖"这个模块被谁引用过"这份清单。

相关推荐
Joy T1 小时前
Redis Lua 原子限流:Lua 脚本、竞态条件与并发控制
数据库·redis·lua·限流·竞态条件·redis scripting
数字孪生视频孪生1 小时前
在线重构,实时同步:视频动态三维重建打造 “常更常新” 的数字底座
大数据·人工智能·重构·架构·空间计算
Decay__2 小时前
01_SQLServer 体系架构全解 —— 实例进程、关系引擎、SQLOS 与事务日志深入
运维·数据库·sql·mysql·架构
特立独行的猫A2 小时前
cj-tauri:用仓颉写桌面与鸿蒙 PC 应用
java·后端·python
mldong2 小时前
同一个节点,同一个人,办两次:jeeflow 的已办撤回
后端·架构
hpoenixf2 小时前
Deep Research:从资源升级到研究交付,我们怎么实现,又测出了什么?
架构
hpoenixf2 小时前
失败已经确定,为什么还返回不了?清理与结果结算的责任边界
架构
脚本自动化开发9 小时前
折叠双屏MU156LA:写稿修图游戏体验
后端