c++游戏后端开源框架学习——wukong(十、lua热更新)

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)怎么热更新?

  1. 编译新的.so文件,替换服务器上的旧文件
  2. 触发Redis PubSub的WK_Hotfix消息
  3. 服务器关闭所有旧Lua VM(hotfixVersion_++
  4. 下次消息到达时创建新Lua VM
  5. 新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服二进制 需要(重新编译) 重启进程

框架的热更新主要覆盖前两层,第三层(主程序)无法热更,只能重启。

相关推荐
vipjx11 小时前
2026最新解决:PanDownload账号限速、下载出错?百度网盘不限速修复实录
开源
ONLYOFFICE1 小时前
微软365停止支持,如何实现无痛切换?
microsoft·开源·onlyoffice
我变成萤火虫1 小时前
2026 ICPC沈阳邀请赛Vp补题
数据结构·c++·算法·贪心算法·stl·排序算法·动态规划
DevHub2 小时前
电视盒子刷 Armbian 教程:30 元旧盒子变 NAS 跑 Docker,200+ 机型可刷
linux·嵌入式硬件·docker·容器·开源·电视盒子
字节跳动的猫2 小时前
开源电商系统选型观察|2026 三款可私有化部署电商框架技术评估
开源
小小龙学IT2 小时前
Google Test(gtest/gmock)开源 C++ 单元测试与 Mock 框架完全指南
c++·单元测试·开源
luj_176811 小时前
桥牌思维启示:系统设计的模块化架构
c语言·开发语言·c++·经验分享·算法
会周易的程序员12 小时前
aiDgePLC iec61131 虚拟机 完整使用文档
c++·物联网·架构·st·iec61131