Lua 热更新原理详解
澄清一个常见误解:Lua热更新不是"C++里有一段Lua逻辑在跑",而是"C++在运行时动态切换消息处理函数的调用入口"。
一、你的理解哪里对了,哪里错了
你的原理解
"在C++中有一段是利用lua运行的逻辑,才能使用lua热更"
这个理解部分正确:C++确实嵌入了Lua虚拟机来执行Lua脚本。
但关键不在"有没有Lua在跑",而在于C++如何决定走哪条代码路径:
消息到达
│
├── needHotfix == false → 调用C++编译好的handler函数(编译时就固定了)
│
└── needHotfix == true → 调用Lua函数 fix_<消息号>() (运行时从文件加载,可替换)
热更新的本质 是:C++在消息分派时,有一个if/else分支决定走C++原函数还是走Lua函数。这个分支标志needHotfix可以在运行时通过Redis动态修改。
更准确的理解
不是:C++代码 ← → Lua代码(互相调用,模糊边界)
而是:C++是宿主,Lua是被嵌入的脚本引擎,C++决定何时调用Lua
二、整体架构
┌─────────────────────────────────────────────────────────────────┐
│ Lobby Server (C++进程) │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 1. 消息分派层 (MessageTarget::handleMessage) │ │
│ │ │ │
│ │ 消息到达 → 查注册表 → needHotfix? │ │
│ │ ├── false → 调用C++ handler(编译时绑定) │ │
│ │ └── true → 调用 callHotfix() │ │
│ └───────────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────────────────▼───────────────────────────┐ │
│ │ 2. C++→Lua桥接层 (MessageTarget::callHotfix) │ │
│ │ │ │
│ │ 从Lua VM池取一个lua_State │ │
│ │ lua_getglobal(L, "fix_1000") ← 找Lua函数 │ │
│ │ lua_pushlightuserdata(L, this) ← 压入C++对象指针 │ │
│ │ lua_pcall(L, ...) ← 调用Lua函数 │ │
│ │ 归还lua_State到池中 │ │
│ └───────────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────────────────▼───────────────────────────┐ │
│ │ 3. Lua脚本层 (hotfix/fix_1000.lua) │ │
│ │ │ │
│ │ local fixecho = require "libfixecho" ← 加载C++库 │ │
│ │ function fix_1000(a, b, c) │ │
│ │ return fixecho.lua_echo(a, b, c) │ │
│ │ end │ │
│ └───────────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────────────────▼───────────────────────────┐ │
│ │ 4. C++动态库层 (libfixecho.so) │ │
│ │ │ │
│ │ lua_echo(L) { │ │
│ │ // 从Lua栈取回C++指针 │ │
│ │ MyLobbyObject *gobj = (MyLobbyObject*) │ │
│ │ lua_touserdata(L, 1); │ │
│ │ gobj->send(...); ← 直接操作C++对象 │ │
│ │ } │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
三、逐层拆解
第一层:C++消息分派------决定走哪条路
文件:share/msghdl/message_target.cpp:30-75
cpp
void MessageTarget::handleMessage(int32_t msgType, uint16_t tag,
const std::string &rawMsg) {
// 查注册表,获取这个消息的处理信息
bool needHotfix;
MessageHandle handle; // C++处理函数指针
g_MessageHandleManager.getMessageInfo(msgType, proto, needCoroutine,
needHotfix, handle);
// 解析protobuf消息
google::protobuf::Message *msg = proto->New();
msg->ParseFromString(rawMsg);
#ifdef USE_MESSAGE_HOTFIX
if (needHotfix) {
callHotfix(msgType, tag, targetMsg); // ← 走Lua路径
} else {
handle(getPtr(), tag, targetMsg); // ← 走C++原路径
}
#else
handle(getPtr(), tag, targetMsg); // ← 没有编译热更功能
#endif
}
关键点:
needHotfix标志是运行时可变 的,存在MessageHandleManager的注册表中- 这个标志由
MessageHotfixManager通过Redis动态修改 #ifdef USE_MESSAGE_HOTFIX控制是否编译热更代码(只有Lobby服开启)
第二层:热更新管理器------运行时修改标志
文件:share/msghdl/message_hotfix_manager.cpp
cpp
void MessageHotfixManager::init() {
// 1. 启动时从Redis加载初始热更配置
RoutineEnvironment::startCoroutine([](void *arg) -> void* {
auto* manager = (MessageHotfixManager*)arg;
manager->resetHotfix();
return NULL;
}, this);
// 2. 订阅Redis的"WK_Hotfix"主题,收到通知就重新加载
PubsubService::Subscribe("WK_Hotfix", true,
[this](const std::string& topic, const std::string& msg) {
resetHotfix(); // ← 任何时候收到PubSub消息都会触发
});
// 3. 启动清理协程,定期回收空闲的Lua VM
RoutineEnvironment::startCoroutine(updateRoutine, this);
}
resetHotfix()做了什么:
cpp
void MessageHotfixManager::resetHotfix() {
// 从Redis读取热更消息列表(JSON数组,如 [1000, 1001])
redisContext *cache = g_RedisPoolManager.getCoreCache()->take();
std::string hotfixData;
RedisUtils::GetHotfixData(cache, hotfixData); // GET "HotfixMsgs"
// hotfixData = "[1000]"
Document doc;
doc.Parse(hotfixData.c_str());
// 先清除所有消息的hotfix标志
g_MessageHandleManager.clearNeedHotfix();
// 遍历需要热更的消息列表
for (SizeType i = 0; i < doc.Size(); i++) {
int msgType = doc[i].GetInt(); // 如 1000
// 只处理本服注册了的消息
if (!g_MessageHandleManager.isRegistedMessage(msgType)) {
continue;
}
// 设置这个消息的needHotfix=true ← 核心操作
g_MessageHandleManager.setNeedHotfix(msgType);
// 加载对应的Lua脚本文件
// 约定文件名:hotfix/fix_<消息号>.lua
std::ostringstream oss;
oss << "hotfix/fix_" << msgType << ".lua";
std::string content;
Utility::loadFileToString(oss.str().c_str(), content);
hotfixMap_[msgType] = content; // 存到内存
}
// 版本号+1,旧的Lua VM全部关闭
hotfixVersion_++;
for (auto info : luaStates_) {
lua_close(info.L);
}
luaStates_.clear();
}
这个函数是热更新的核心:它把"哪些消息走Lua"这个决策动态写入了C++的内存,不需要重新编译。
第三层:C++调用Lua------桥接层
文件:share/msghdl/message_target.cpp:103-151
cpp
void MessageTarget::callHotfix(int32_t msgType, uint16_t tag,
std::shared_ptr<google::protobuf::Message> msg) {
// 1. 从池中取一个Lua VM
LuaStateInfo lsInfo;
g_MessageHotfixManager.getLuaStateInfo(lsInfo);
// 用unique_ptr保护,确保异常时也能归还或关闭
std::unique_ptr<lua_State, decltype(&lua_close)> L(lsInfo.L, lua_close);
// 2. 构造函数名:fix_<消息号>,如 fix_1000
std::ostringstream oss;
oss << "fix_" << msgType;
std::string luaFun = oss.str();
// 3. 从Lua全局表中找这个函数
lua_getglobal(L.get(), luaFun.c_str());
if (lua_isnil(L.get(), -1)) {
ERROR_LOG("Lua function %s not found\n", luaFun.c_str());
return;
}
// 4. 压入参数(3个)
lua_pushlightuserdata(L.get(), this); // 参数1: C++对象指针
lua_pushlightuserdata(L.get(), msg.get()); // 参数2: protobuf消息指针
lua_pushnumber(L.get(), tag); // 参数3: 消息tag
// 5. 调用Lua函数!
if (lua_pcall(L.get(), 3, 1, 0) != LUA_OK) {
ERROR_LOG("Lua call failed: %s\n", lua_tostring(L.get(), -1));
return;
}
// 6. 读取返回值
int result = lua_tonumber(L.get(), -1);
lua_pop(L.get(), 1);
// 7. 归还Lua VM到池中
L.release(); // 阻止unique_ptr关闭lua_State
g_MessageHotfixManager.backLuaStateInfo(lsInfo);
}
关键理解:
lua_getglobal:从Lua的全局变量表中查找名为fix_1000的函数lua_pushlightuserdata:把C++的裸指针压入Lua栈,Lua侧可以取回lua_pcall:实际调用Lua函数,期间Lua虚拟机执行Lua字节码- C++对象指针直接传给Lua,没有序列化/反序列化开销
第四层:Lua脚本------胶水层
文件:demo/hotfix/fixEcho/lua/fix_1000.lua
lua
-- 设置C++动态库的搜索路径
package.cpath = 'hotfix/?.so;'
-- 加载C++编译的动态库
local fixecho = require "libfixecho"
-- 定义消息处理函数(名字必须叫 fix_<消息号>)
function fix_1000(a, b, c)
-- a = C++的MessageTarget指针(lightuserdata)
-- b = C++的protobuf::Message指针(lightuserdata)
-- c = tag数字
return fixecho.lua_echo(a, b, c)
end
关键理解:
- Lua脚本本身可以很简单,只是把参数转发给C++动态库
- 也可以在Lua中写纯逻辑(如修改数值、做条件判断),不需要C++库
- Lua脚本是文本文件,修改后不需要编译,重启Lua VM即可生效
第五层:C++动态库------真正的业务逻辑
文件:demo/hotfix/fixEcho/src/fix_echo.cpp
cpp
// 这个函数注册为Lua可调用的C函数
static int lua_echo(lua_State *L) {
// 从Lua栈取回C++指针(就是callHotfix压入的那些)
MyLobbyObject *gobj = (MyLobbyObject*)lua_touserdata(L, 1);
wukong::pb::StringValue *msg = (wukong::pb::StringValue*)lua_touserdata(L, 2);
uint16_t tag = (uint16_t)lua_tonumber(L, 3);
// 直接操作C++对象!
gobj->setExp(gobj->getExp() + 1);
gobj->send(S2C_MESSAGE_ID_ECHO, tag, *msg);
lua_pushnumber(L, 0); // 返回值
return 1;
}
// 注册函数表
static const struct luaL_Reg myLib[] = {
{"lua_echo", lua_echo},
{NULL, NULL}
};
// Lua require时的入口
extern "C" int luaopen_libfixecho(lua_State *L) {
luaL_newlib(L, myLib);
return 1;
}
关键理解:
- 动态库
.so文件可以被Lua的require在运行时加载 - 替换
.so文件后,重启Lua VM就会加载新版本 - 动态库中可以直接操作C++对象(通过传入的指针),性能与原生C++几乎一样
- 这就是"热更新C++代码"的真正含义:不是修改正在运行的C++代码,而是替换被Lua加载的动态库
四、热更新的完整流程
触发热更新
运维人员修改Redis:
SET "HotfixMsgs" "[1000]" ← 告诉服务器:1000号消息走Lua
PUBLISH "WK_Hotfix" "reload" ← 通知所有服务器重新加载
服务器内部流程
1. Redis PubSub收到"WK_Hotfix"消息
└→ MessageHotfixManager::resetHotfix() 被调用
2. 从Redis读取热更消息列表
└→ hotfixData = "[1000]"
3. 遍历消息列表
├── 对消息1000:setNeedHotfix(1000, true)
├── 加载文件 hotfix/fix_1000.lua 到内存
└── 存入 hotfixMap_[1000] = "<Lua文件内容>"
4. hotfixVersion_++ (版本号递增)
└── 关闭所有旧的Lua VM
5. 下一条消息1000到达时
├── handleMessage() 查注册表 → needHotfix=true
├── callHotfix() 被调用
├── 创建新的Lua VM(因为旧的都关了)
├── 在新VM中加载 fix_1000.lua
├── 调用 fix_1000(this, msg, tag)
└── Lua内部调用 libfixecho.so 的 lua_echo()
取消热更新
运维人员修改Redis:
SET "HotfixMsgs" "[]" ← 空数组,没有消息需要热更
PUBLISH "WK_Hotfix" "reload" ← 通知重新加载
服务器收到后,clearNeedHotfix()清除所有标志,消息1000重新走C++原函数。
五、Lua VM池化机制
每次调用Lua函数都创建/销毁Lua VM开销很大,框架用了对象池:
cpp
// message_hotfix_manager.h
struct LuaStateInfo {
lua_State *L; // Lua虚拟机
time_t lastUsedAt; // 最后使用时间
int version; // 创建时的hotfixVersion
};
std::list<LuaStateInfo> luaStates_; // VM池
int hotfixVersion_; // 当前版本号
工作流程:
取VM:
├── 池非空 → 取最后一个返回
└── 池为空 → 新建lua_State,加载所有hotfix脚本
归还VM:
├── 版本号匹配 → 放回池中(可复用)
└── 版本号不匹配 → lua_close关闭(是旧版本,丢弃)
定期清理(每60秒):
└── 关闭超过1分钟未使用的VM,但最少保留10个
版本号的作用 :热更后hotfixVersion_++,池中所有旧VM的版本号都不匹配了。归还时检测到版本号不一致,直接关闭,不会复用旧逻辑的VM。这保证了热更后不会有残留的旧Lua逻辑在跑。
六、关键问题解答
Q1:为什么不能直接修改C++代码来热更?
C++是编译型语言,代码编译成机器码后无法在运行时替换。要修改C++逻辑必须重新编译并重启进程。
Lua是解释型语言,代码在运行时由Lua虚拟机解释执行。修改Lua文件后,重新加载到Lua VM即可生效,不需要重启进程。
Q2:Lua热更后,C++原函数还在吗?
还在 。C++原函数是编译进二进制文件的,永远不会消失。needHotfix标志只是决定了调用哪个函数:
cpp
if (needHotfix) {
callHotfix(msgType, tag, msg); // 走Lua
} else {
handle(obj, tag, msg); // 走C++原函数
}
取消热更(needHotfix=false)后,消息重新走C++原函数。
Q3:Lua不经过C++能直接操作游戏对象吗?
不能直接操作,必须通过C++提供的接口。在本框架中,有两种方式:
方式一:lightuserdata传递指针
cpp
// C++侧:把指针压入Lua栈
lua_pushlightuserdata(L, this); // C++对象指针
lua_pushlightuserdata(L, msg.get()); // protobuf消息指针
// Lua侧:转发给C++动态库
function fix_1000(a, b, c)
return fixecho.lua_echo(a, b, c) -- a, b是指针,c是数字
end
// C++动态库侧:从Lua栈取回指针,直接操作C++对象
MyLobbyObject *gobj = (MyLobbyObject*)lua_touserdata(L, 1);
gobj->setExp(gobj->getExp() + 1); // 直接调用C++方法
方式二:在Lua中写纯逻辑
lua
function fix_1000(a, b, c)
-- 不调C++库,纯Lua逻辑
-- 但a是指针,Lua不能直接操作它
-- 只能做数值计算、条件判断等
return 0
end
所以:Lua热更新的性能取决于"Lua做多少,C++动态库做多少"。纯Lua做复杂逻辑会慢,但修改简单参数/条件分支就很快。
Q4:C++动态库(.so)怎么热更新?
- 编译新的
.so文件,替换服务器上的旧文件 - 触发Redis PubSub的
WK_Hotfix消息 - 服务器关闭所有旧Lua VM(
hotfixVersion_++) - 下次消息到达时创建新Lua VM
- 新Lua VM执行
require "libfixecho"时加载新的.so文件
注意 :Linux的.so文件如果被进程映射(已加载),直接覆盖可能失败。需要:
- 先
dlclose旧库(通过关闭旧Lua VM间接实现) - 或者用不同的文件名(如
libfixecho_v2.so),修改Lua脚本中的require路径 - 或者用
mv替换(Linux允许mv覆盖已加载的so,旧进程仍用旧的,新加载用新的)
Q5:这个框架的热更新和传统Lua热更新有什么区别?
| 对比项 | 传统Lua框架(如skynet) | 本框架 |
|---|---|---|
| 业务逻辑 | 全部用Lua写 | C++为主,Lua仅做热更补丁 |
| 热更粒度 | 整个Lua文件/模块 | 单个消息处理函数 |
| 性能 | Lua执行,较慢 | Lua调C++动态库,接近原生 |
| 触发方式 | 修改文件+信号 | Redis PubSub |
| 适用场景 | 快速迭代开发 | 线上紧急修复 |
本框架的设计思路是C++为主,Lua为补丁:正常运行时所有逻辑走C++,只有需要紧急修复的消息才走Lua。修复完后可以取消热更标记,重新走C++。
七、数据流完整路径
以消息1000(ECHO)为例,展示从客户端到响应的完整路径:
1. 客户端发送 C2S_MESSAGE_ID_ECHO (消息号1000)
│
▼
2. Gateway收到,根据消息ID高16位判断是LOBBY消息
└→ forwardIn RPC 转发到 Lobby服
3. Lobby服的 MessageTarget::handleMessage(1000, tag, rawMsg)
│
├── 查注册表:消息1000的 needHotfix = true
│
▼
4. callHotfix(1000, tag, msg)
│
├── 从VM池取lua_State
├── lua_getglobal(L, "fix_1000")
├── lua_pushlightuserdata(L, this) ← LobbyObject指针
├── lua_pushlightuserdata(L, msg.get()) ← StringValue指针
├── lua_pushnumber(L, tag) ← tag值
│
▼
5. Lua执行 fix_1000(a, b, c)
│
├── require "libfixecho" ← 加载C++动态库
└── return fixecho.lua_echo(a, b, c)
│
▼
6. C++动态库执行 lua_echo(L)
│
├── lua_touserdata(L, 1) → MyLobbyObject* gobj
├── lua_touserdata(L, 2) → StringValue* msg
├── lua_tonumber(L, 3) → tag
│
├── gobj->setExp(gobj->getExp() + 1) ← 修改玩家经验值
└── gobj->send(S2C_MESSAGE_ID_ECHO, tag, *msg) ← 发回包给客户端
│
▼
7. lua_echo返回1,push返回值到Lua栈
│
▼
8. Lua fix_1000 返回
│
▼
9. callHotfix 读取返回值,归还lua_State到池
│
▼
10. gobj->send() 通过Gateway的forwardOut发给客户端
八、安全注意事项
8.1 指针安全
cpp
// C++把裸指针传给Lua
lua_pushlightuserdata(L, this); // LobbyObject*
lua_pushlightuserdata(L, msg.get()); // protobuf::Message*
风险:
- 如果Lua VM存活时间超过C++对象生命周期,指针就悬空了
- 如果Lua脚本把指针存到全局变量,下次调用时对象可能已销毁
框架的防护:
- Lua VM池化,每次调用后立即归还,不会长期持有指针
hotfixVersion_机制保证热更后旧VM全部关闭- 但如果Lua脚本自己存指针到全局表,框架无法防护
8.2 Lua脚本安全
Lua脚本可以执行任意Lua代码,包括:
os.execute()执行系统命令(如果加载了os库)- 文件读写
- 死循环导致协程卡死
框架的防护:
- 只加载了
package库(luaopen_package),没有加载os/io等库 lua_pcall保护模式调用,错误不会崩溃进程- 但Lua脚本中的死循环仍会卡住协程
8.3 动态库安全
C++动态库.so有和C++主程序相同的权限:
- 可以访问进程内存
- 可以调用系统API
- 一个有bug的动态库可以导致进程崩溃
实际建议:动态库代码必须经过完整测试,不能随意替换。
九、总结
你的理解修正
| 原理解 | 正确理解 |
|---|---|
| C++中有一段Lua逻辑在跑 | C++嵌入了Lua VM,按需调用Lua函数 |
| Lua热更需要C++配合 | C++代码编译时就预留了if(needHotfix)分支,运行时通过Redis切换 |
| Lua直接操作游戏对象 | Lua通过lightuserdata拿到C++指针,转发给C++动态库操作 |
| 热更=修改C++代码 | 热更=替换消息处理入口(从C++函数切换到Lua函数) |
热更新的本质
编译时:C++代码中预埋了 if(needHotfix) { callLua(); } else { callCpp(); }
运行时:通过Redis动态修改 needHotfix 标志
通过Redis PubSub通知所有服务器实例
替换Lua脚本文件和C++动态库
效果: 特定消息的处理逻辑在运行时被替换,无需重启进程
三层热更能力
| 层次 | 修改内容 | 是否需要编译 | 生效方式 |
|---|---|---|---|
| Lua脚本 | fix_1000.lua |
不需要 | 重新加载Lua VM |
| C++动态库 | libfixecho.so |
需要(编译.so) | Lua require时加载新.so |
| C++主程序 | Lobby服二进制 | 需要(重新编译) | 重启进程 |
框架的热更新主要覆盖前两层,第三层(主程序)无法热更,只能重启。