热更新实现问题与排错记录
一、问题总览
在实现代码热更新功能的过程中,遇到了两个核心问题,根源都指向 skynet 框架的双层缓存设计。
| # | 问题 | 现象 | 根因 |
|---|---|---|---|
| 1 | codetest_reload 热更不生效 |
修改代码后触发重载,返回值仍是旧版本 | 只清了 Lua 层缓存,没清 C 层 codecache |
| 2 | codetest_get 间歇性失败 |
有时返回 nil, "not loaded" |
testModule 是局部变量,服务重启后丢失 |
二、skynet 的双层缓存架构
skynet 中有两层独立的缓存,只清其中一层无法完成代码热更新。
┌─────────────────────────────────────────────────────────────┐
│ Lua 层缓存(per lua_State) │
│ │
│ package.loaded["test_module"] = table: 0x... │
│ ▲ │
│ │ 同一张表引用 │
│ loaded (skynet/require.lua 局部变量) │
│ │
│ 清除方式: package.loaded["xxx"] = nil │
├─────────────────────────────────────────────────────────────┤
│ C 层缓存(per process) │
│ │
│ struct codecache { │
│ struct spinlock lock; │
│ lua_State *L; ← 独立 lua_State,存放所有已编译 proto │
│ }; │
│ │
│ key = 文件路径, value = 编译后的字节码 proto │
│ 清除方式: skynet.cache.clear() 或 codecache.clear() │
└─────────────────────────────────────────────────────────────┘
关键代码位置
| 组件 | 文件 | 行号 |
|---|---|---|
codecache 结构体定义 |
skynet/3rd/lua/lauxlib.c |
L1221-1226 |
loadfile 被劫持(先查缓存) |
skynet/3rd/lua/lauxlib.c |
L1341-1371 |
cache.clear() 实现 |
skynet/3rd/lua/lauxlib.c |
L1392-1397 |
全局 require 被 skynet 替换 |
skynet/lualib/loader.lua |
L48 |
| skynet 自定义 require | skynet/lualib/skynet/require.lua |
L24-97 |
| skynet.cache 暴露 | skynet/lualib/skynet.lua |
L49 |
三、问题一:codetest_reload 热更不生效
3.1 现象
修改 test_module.lua(version: 5 → 6),触发 codetest_reload,日志显示:
[13:17:22] codetest_load done: version=5, value=hello_v5 ← 首次加载
[13:17:48] BEFORE clear: package.loaded['test_module'] = table: 0x...
[13:17:48] AFTER clear: package.loaded['test_module'] = nil ← Lua层已清除
[13:17:48] codetest_reload: resolved file=./chatroom/lualib/test_module.lua
[13:17:48] codetest_reload done: version=5, value=hello_v5 ← ❌ 仍是旧版本!
3.2 调用链分析
hotfix_service.lua
└→ require("test_module") (全局 require,已被 skynet 替换)
└→ skynet.require("test_module") (skynet/require.lua)
├─ loaded["test_module"] → nil ✓ package.loaded 已清除
├─ loadfile("./chatroom/.../test_module.lua")
│ └→ luaL_loadfilex() (lauxlib.c,被劫持的 loadfile)
│ ├─ load_proto(filename) → 命中 C 层缓存!
│ └─ 返回旧的编译字节码 ❌ 根本不会读磁盘文件
└─ 执行旧字节码 → 得到旧版本模块
3.3 C 层缓存的工作原理
c
// lauxlib.c - luaL_loadfilex 简化逻辑
LUALIB_API int luaL_loadfilex(lua_State *L, const char *filename, ...) {
int level = cache_level(L); // 默认 CACHE_ON = 2
if (level == CACHE_OFF) {
return luaL_loadfilex_(L, ...); // 不缓存,直接读磁盘
}
proto = load_proto(filename); // ⬅ 先查 codecache
if (proto) {
lua_clonefunction(L, proto); // ⬅ 命中!直接返回,不读磁盘!
return LUA_OK;
}
// 未命中才读磁盘,然后 save_proto 存入缓存
eL = newState(L);
err = luaL_loadfilex_(eL, filename, ...);
save_proto(L, filename, proto);
...
}
3.4 修复方案
在 require 重载之前,必须同时清除 C 层缓存:
lua
-- 1. 清除 Lua 层缓存
package.loaded["test_module"] = nil
-- 2. 清除 C 层代码缓存(关键!)
skynet.cache.clear()
-- 3. 重新加载
local newMod = require("test_module")
3.5 修复的文件
chatroom/service/hotfix_service.lua --- handle_codetest_reload 中新增 skynet.cache.clear()
chatroom/lualib/res_update.lua --- CheckReloadCodes 中新增 skynet.cache.clear()(生产级热更路径同样受影响)
四、问题二:codetest_get 间歇性返回 "not loaded"
4.1 现象
call .hotfix_service "codetest_get"
→ 1 nil
→ 2 not loaded, call codetest_load first
热更功能"修了几次"后会突然不可用。
4.2 根因
testModule 是 hotfix_service.lua L110(file:///home/djh/skynet-master/chatroom/service/hotfix_service.lua#L110) 的模块级局部变量:
lua
-- if mode == "controller" then 块内部
local testModule -- ← 服务启动时初始化为 nil
每次修改 hotfix_service.lua 本身(如加调试日志、修复 bug),skynet 会重启这个 service,testModule 就回到 nil。此时直接调用 codetest_get 或 codetest_reload 都会因为没有先 codetest_load 而失败。
服务启动 → testModule = nil
│
├─ codetest_load → testModule = require("test_module") ✅ 有值
│
├─ 修改 hotfix_service.lua → 服务重启 → testModule = nil
│
└─ codetest_get → nil → "not loaded" ❌
4.3 修复方案
给 handle_codetest_get 和 handle_codetest_reload 添加自动恢复逻辑:检测到 testModule 为空时自动执行 require("test_module")。
lua
local function handle_codetest_get()
if not testModule then
-- 服务重启后 testModule 丢失,自动恢复
local ok, mod = pcall(require, "test_module")
if not ok then
return nil, "not loaded, ..."
end
testModule = mod
end
return { version = testModule.getVersion(), ... }
end
五、总结:代码热更新的正确步骤
在一个 skynet 服务中实现代码热更新,必须按以下顺序清除所有缓存:
lua
-- ============================================
-- 代码热更新 - 完整流程
-- ============================================
-- Step 1: 清除 C 层代码缓存(process 级别,所有 lua_State 共享)
-- lauxlib.c 的 struct codecache,以文件路径为 key 缓存编译后的 proto
-- loadfile 命中此缓存会跳过磁盘读取,直接返回旧的字节码
skynet.cache.clear()
-- Step 2: 清除 Lua 层模块缓存(per lua_State)
-- package.loaded 和 skynet/require.lua 的 loaded 是同一张表
package.loaded["module_name"] = nil
-- Step 3: 重新 require
local newMod = require("module_name")
注意:Step 1 和 Step 2 的顺序可以互换,但两者缺一不可。
六、启动与集成阶段问题
6.1 Lua 5.3 全局 arg 变量缺失
现象:
./chatroom/service/hotfix_service.lua:7: attempt to index a nil value (global 'arg')
根因 :hotfix_service.lua 使用 arg[1] 获取启动参数(判断 controller/agent 模式),但 Lua 5.3 不再自动暴露 arg 全局变量,需显式通过 ... 获取。
修复:
lua
-- hotfix_service.lua 文件顶部添加
local arg = {...}
6.2 logger 组件 API 不匹配
现象:
./chatroom/service/hotfix_service.lua:10: attempt to call a nil value (field 'infof')
根因 :hotfix_service.lua 调用 log.infof("..."),但 logger.lua 只提供了 info/warn/error/debug 四个基础方法,没有 xxxf 格式化变体。
修复 :在 logger.lua(file:///home/djh/skynet-master/chatroom/lualib/logger.lua) 中添加 infof/warnf/errorf/debugf 适配层,自动从 [source] 格式串提取日志来源。
6.3 inspect 模块缺失
现象:
attempt to call a nil value (global 'inspect')
根因 :多个热更新相关文件(hotfix_service.lua、controller.lua、test_hotfix.lua 等)使用了 inspect() 函数但未 require。inspect 是一个 Lua table 格式化调试工具。
修复:
- 新建 inspect.lua(file:///home/djh/skynet-master/chatroom/lualib/inspect.lua)(递归 table 打印,防循环引用,深度限制 4 层)
- 所有使用处添加
require "inspect"
6.4 cluster 模块路径错误
现象:
module 'cluster' not found
根因 :controller.lua 中写了 require "cluster",skynet 的 cluster 模块实际路径是 skynet.cluster。
修复 :改为 require "skynet.cluster"。
6.5 skynet.register 未导入
现象:
attempt to call a nil value (field 'register')
根因 :skynet.register 定义在 skynet.manager 模块中,不是 skynet 默认导出的 API。多个测试文件(test_hotfix.lua、test_code_hotfix.lua、test_code_swap_service.lua)调用了 skynet.register() 但未 require manager。
修复 :所有使用 skynet.register 的文件添加:
lua
require "skynet.manager"
6.6 测试模块 require 路径问题
现象:
module 'test_module' not found:
no file './chatroom/lualib/test_module.lua'
...
根因 :test_module.lua 最初放在 chatroom/service/,但 skynet 的 package.path 不包含此目录。搜索路径为:./?.lua → ./chatroom/lualib/?.lua → ./chatroom/etc/?.lua → skynet 内置路径。
修复 :将文件移至 chatroom/lualib/test_module.lua。
额外发现 :存在两个同名文件 chatroom/test_module.lua(旧版本)和 chatroom/lualib/test_module.lua(新版本)。package.searchpath 按路径顺序匹配,可能加载到旧文件。删除 chatroom/test_module.lua。
七、调试控制台交互问题
7.1 "Not a valid name" 错误
现象:
./skynet/service/debug_console.lua:227: Not a valid name
触发命令:
lua
call .hotfix_service "broadcast_update", {"path"}
call .test_hotfix "get"
根因 :debug_console 的 call 命令使用 . 前缀查找服务,这对应 skynet 的本地名 (skynet.name(".xxx", handle) 注册)。而 skynet.register("xxx") 注册的是全局名,debug_console 不会自动解析。
skynet.register("hotfix_service") → 全局名 "@hotfix_service"
skynet.name(".hotfix_service", ...) → 本地名 ".hotfix_service"
↑ debug_console 只认识这个
修复 :所有需要通过 debug_console 调用的服务,同时在 skynet.start 中添加本地名注册:
lua
skynet.start(function()
skynet.register("service_name") -- 全局名(集群通信用)
skynet.name(".service_name", skynet.self()) -- 本地名(debug_console 用)
end)
影响的文件:hotfix_service.lua、test_hotfix.lua、test_code_hotfix.lua。
7.2 broadcast_update 命令调用失败
现象:
[string "debug console"]:1: attempt to call a nil value (global 'broadcast_update')
根因 :命令格式错误。debug_console 的 call 语法是:
call <服务名> "<命令>", {参数}
而不是直接执行一个 Lua 函数。
修复:正确写法:
lua
call .hotfix_service "broadcast_update", {"chatroom/etc/test_config.lua"}
7.3 "Maybe forgot response session" 警告
现象:
[:0000000b] Maybe forgot response session 6 from :0000000c : @./chatroom/service/hotfix_service.lua:111
根因 :debug_console 使用 skynet.call(带 session)发送命令,期望收到 skynet.ret 回应。但 hotfix_service.lua 的 dispatch handler 没有调用 skynet.ret。同时 clusteragent 跨节点转发消息时也会带上 session。
修复 :在 dispatch handler 中捕获 session,有 session 时调用 skynet.ret:
lua
-- 改前
skynet.dispatch("lua", function(_, _, cmd, ...)
...
local ok, ret = pcall(f, ...)
end)
-- 改后
skynet.dispatch("lua", function(session, source, cmd, ...)
...
local ok, ret = pcall(f, ...)
if session ~= 0 then
skynet.ret(skynet.pack(ret)) -- 把结果返回给调用方
end
end)
关键点 :session == 0 的是 cluster.send 异步消息,不需要返回。session ~= 0 的是 skynet.call/cluster.call,必须返回。
7.4 reload_config 参数类型错误
现象:
reload_config failed: bad argument #1 to 'dofile' (string expected, got table)
根因 :debug_console 调用 call .hotfix_service "reload_config", {"chatroom/etc/test_config.lua"} 时,{...} 作为整体打包成 table 传给 handler。
修复 :在 handle_reload_config 中解包:
lua
local function handle_reload_config(configPath)
if type(configPath) == "table" then
configPath = configPath[1]
end
...
end
八、集群跨节点问题
8.1 services 表按 addr 键导致 Agent 覆盖
现象 :Controller 日志显示 SendAllAndSelf: sent to 1 services,但明明注册了两个 Agent(logic_agent 和 battle_agent)。battle 节点收到广播而 logic 节点没收到。
根因 :controller.lua(file:///home/djh/skynet-master/chatroom/lualib/controller.lua) 的 services 表以服务地址 addr 为 key:
lua
services[addr] = { name = ..., node = ..., ... }
跨节点时,clusteragent 会将不同远程 Agent 的地址都映射到同一个本地 handle(如 14)。第二个 Agent 注册时 services[14] 覆盖了第一个。
logic_agent 注册 → services[14] = { name="logic_agent" }
battle_agent 注册 → services[14] = { name="battle_agent" } ← 覆盖!
修复 :改用唯一的 name 作为 key:
lua
-- 改前
services[addr] = { name, addr, node, ... } -- addr 跨节点不唯一
-- 改后
services[name] = { name, addr, node, ... } -- name 全局唯一
所有操作(Send、SendAll、UnregService、Heartbeat、CleanupTimeout、FindService)同步改为按 name 操作。
8.2 config_manager 未初始化
现象:
call .hotfix_service "getconfig"
→ 空表(只有 version=0)
根因 :config_manager.Init() 只在 test_hotfix.lua(单进程模式专用服务)中调用。集群模式下 Controller 启动时未调用,currentConfig 和 configVersion 仍是默认值。
修复 :在 hotfix_service.lua Controller 的 skynet.start 中添加:
lua
config_manager.Init({
version = 1,
max_connections = 100,
feature_a = true,
feature_b = false,
})
九、状态管理问题
9.1 testModule 局部变量在服务重启后丢失
现象:
codetest_get 有时返回 nil, "not loaded"------似乎热更功能"修了几次"后突然不可用。
根因:
testModule 是 hotfix_service.lua Controller 块内的模块级局部变量:
lua
-- if mode == "controller" then 块内部
local testModule -- ← 服务启动时初始化为 nil
每次修改 hotfix_service.lua 本身(加调试日志、修复 bug),skynet 会重启这个 service。服务重启后 testModule 回到 nil:
服务启动 → testModule = nil
│
├─ codetest_load → testModule = require("test_module") ✅
│
├─ 修改 hotfix_service.lua → 服务重启 → testModule = nil
│
└─ codetest_get → testModule 为 nil → "not loaded" ❌
修复 :handle_codetest_get 和 handle_codetest_reload 添加自动恢复逻辑:
lua
local function handle_codetest_get()
if not testModule then
local ok, mod = pcall(require, "test_module")
if not ok then return nil, "not loaded" end
testModule = mod
end
return { version = testModule.getVersion(), ... }
end
十、问题速查表
| 问题 | 现象关键词 | 根因类别 | 修复点 |
|---|---|---|---|
arg 为 nil |
attempt to index a nil value (global 'arg') |
Lua 5.3 兼容性 | 加 local arg = {...} |
log.infof 不存在 |
attempt to call a nil value (field 'infof') |
API 不匹配 | logger.lua 加适配层 |
inspect 不存在 |
attempt to call a nil value (global 'inspect') |
依赖缺失 | 新建 inspect.lua + require |
cluster 找不到 |
module 'cluster' not found |
路径错误 | require "skynet.cluster" |
skynet.register 不存在 |
attempt to call a nil value (field 'register') |
模块未导入 | require "skynet.manager" |
| "Not a valid name" | debug_console 报错 | 全局名 vs 本地名 | skynet.name(".xxx", ...) |
broadcast_update nil |
attempt to call a nil value (global '...') |
命令语法错误 | 用 call .xxx "cmd", {args} |
| "forgot response session" | 日志 warning | dispatch 未返回结果 | skynet.ret(skynet.pack(ret)) |
| reload_config 类型错误 | string expected, got table |
参数打包方式 | type 检查 + 解包 |
| Agent 覆盖 | 只有一个节点收到广播 | addr 不唯一 | services 改用 name 键 |
config_manager 空 |
getconfig 返回空表 |
未初始化 | Controller 加 Init() |
| test_module 找不到 | module 'test_module' not found |
文件路径 | 移到 lualib/ 目录 |
| 同名文件冲突 | 加载旧版 test_module | 多份文件 | 删除旧文件 |
require 热更无效 |
reload 后返回旧版本 | C 层 codecache | skynet.cache.clear() |
testModule 丢失 |
"not loaded" 报错 | 服务重启 | 自动恢复逻辑 |