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
voidMessageTarget::handleMessage(int32_tmsgType,uint16_ttag,conststd::string&rawMsg){// 查注册表,获取这个消息的处理信息boolneedHotfix;MessageHandle handle;// C++处理函数指针g_MessageHandleManager.getMessageInfo(msgType,proto,needCoroutine,needHotfix,handle);// 解析protobuf消息google::protobuf::Message*msg=proto->New();msg->ParseFromString(rawMsg);#ifdefUSE_MESSAGE_HOTFIXif(needHotfix){callHotfix(msgType,tag,targetMsg);// ← 走Lua路径}else{handle(getPtr(),tag,targetMsg);// ← 走C++原路径}#elsehandle(getPtr(),tag,targetMsg);// ← 没有编译热更功能#endif}关键点:
needHotfix标志是运行时可变的,存在MessageHandleManager的注册表中- 这个标志由
MessageHotfixManager通过Redis动态修改 #ifdef USE_MESSAGE_HOTFIX控制是否编译热更代码(只有Lobby服开启)
第二层:热更新管理器——运行时修改标志
文件:share/msghdl/message_hotfix_manager.cpp
voidMessageHotfixManager::init(){// 1. 启动时从Redis加载初始热更配置RoutineEnvironment::startCoroutine([](void*arg)->void*{auto*manager=(MessageHotfixManager*)arg;manager->resetHotfix();returnNULL;},this);// 2. 订阅Redis的"WK_Hotfix"主题,收到通知就重新加载PubsubService::Subscribe("WK_Hotfix",true,[this](conststd::string&topic,conststd::string&msg){resetHotfix();// ← 任何时候收到PubSub消息都会触发});// 3. 启动清理协程,定期回收空闲的Lua VMRoutineEnvironment::startCoroutine(updateRoutine,this);}resetHotfix()做了什么:
voidMessageHotfixManager::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++){intmsgType=doc[i].GetInt();// 如 1000// 只处理本服注册了的消息if(!g_MessageHandleManager.isRegistedMessage(msgType)){continue;}// 设置这个消息的needHotfix=true ← 核心操作g_MessageHandleManager.setNeedHotfix(msgType);// 加载对应的Lua脚本文件// 约定文件名:hotfix/fix_<消息号>.luastd::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(autoinfo:luaStates_){lua_close(info.L);}luaStates_.clear();}这个函数是热更新的核心:它把"哪些消息走Lua"这个决策动态写入了C++的内存,不需要重新编译。
第三层:C++调用Lua——桥接层
文件:share/msghdl/message_target.cpp:103-151
voidMessageTarget::callHotfix(int32_tmsgType,uint16_ttag,std::shared_ptr<google::protobuf::Message>msg){// 1. 从池中取一个Lua VMLuaStateInfo lsInfo;g_MessageHotfixManager.getLuaStateInfo(lsInfo);// 用unique_ptr保护,确保异常时也能归还或关闭std::unique_ptr<lua_State,decltype(&lua_close)>L(lsInfo.L,lua_close);// 2. 构造函数名:fix_<消息号>,如 fix_1000std::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. 读取返回值intresult=lua_tonumber(L.get(),-1);lua_pop(L.get(),1);// 7. 归还Lua VM到池中L.release();// 阻止unique_ptr关闭lua_Stateg_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
-- 设置C++动态库的搜索路径package.cpath='hotfix/?.so;'-- 加载C++编译的动态库localfixecho=require"libfixecho"-- 定义消息处理函数(名字必须叫 fix_<消息号>)functionfix_1000(a,b,c)-- a = C++的MessageTarget指针(lightuserdata)-- b = C++的protobuf::Message指针(lightuserdata)-- c = tag数字returnfixecho.lua_echo(a,b,c)end关键理解:
- Lua脚本本身可以很简单,只是把参数转发给C++动态库
- 也可以在Lua中写纯逻辑(如修改数值、做条件判断),不需要C++库
- Lua脚本是文本文件,修改后不需要编译,重启Lua VM即可生效
第五层:C++动态库——真正的业务逻辑
文件:demo/hotfix/fixEcho/src/fix_echo.cpp
// 这个函数注册为Lua可调用的C函数staticintlua_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_ttag=(uint16_t)lua_tonumber(L,3);// 直接操作C++对象!gobj->setExp(gobj->getExp()+1);gobj->send(S2C_MESSAGE_ID_ECHO,tag,*msg);lua_pushnumber(L,0);// 返回值return1;}// 注册函数表staticconststructluaL_RegmyLib[]={{"lua_echo",lua_echo},{NULL,NULL}};// Lua require时的入口extern"C"intluaopen_libfixecho(lua_State*L){luaL_newlib(L,myLib);return1;}关键理解:
- 动态库
.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开销很大,框架用了对象池:
// message_hotfix_manager.hstructLuaStateInfo{lua_State*L;// Lua虚拟机time_t lastUsedAt;// 最后使用时间intversion;// 创建时的hotfixVersion};std::list<LuaStateInfo>luaStates_;// VM池inthotfixVersion_;// 当前版本号工作流程:
取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标志只是决定了调用哪个函数:
if(needHotfix){callHotfix(msgType,tag,msg);// 走Lua}else{handle(obj,tag,msg);// 走C++原函数}取消热更(needHotfix=false)后,消息重新走C++原函数。
Q3:Lua不经过C++能直接操作游戏对象吗?
不能直接操作,必须通过C++提供的接口。在本框架中,有两种方式:
方式一:lightuserdata传递指针
// C++侧:把指针压入Lua栈lua_pushlightuserdata(L,this);// C++对象指针lua_pushlightuserdata(L,msg.get());// protobuf消息指针// Lua侧:转发给C++动态库functionfix_1000(a,b,c)returnfixecho.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中写纯逻辑
functionfix_1000(a,b,c)-- 不调C++库,纯Lua逻辑-- 但a是指针,Lua不能直接操作它-- 只能做数值计算、条件判断等return0end所以: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 指针安全
// C++把裸指针传给Lualua_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服二进制 | 需要(重新编译) | 重启进程 |
框架的热更新主要覆盖前两层,第三层(主程序)无法热更,只能重启。