找回密码
 register
搜索
查看: 163|回复: 0

洱海月 服务端配置TXT热重载接入教程

[复制链接]
  • 打卡等级:本地老炮
  • 打卡总天数:533
  • 打卡月天数:22
  • 打卡总奖励:530
  • 最近打卡:2026-06-24 01:45:59
Waylee 发表于 2026-4-4 13:50 | 显示全部楼层 |阅读模式 | Google Chrome | Windows 10

马上注册,查看网站隐藏内容!!

您需要 登录 才可以下载或查看,没有账号?register

×

1. 目标

本文档说明如何在一个原本没有 reload_config 能力的 Skynet/Lua 服务端环境里,新增一套和 reload_scripts 同类型的命令式配置热重载能力。

目标不是“所有 txt 自动监听热更新”,而是做成下面这种更稳的模式:

  • 手动执行命令
  • 可以自定义要重载的目标
  • 支持运行时缓存刷新
  • 支持场景服广播
  • 支持输出耗时统计

最终使用方式:

./reload_config.exp 6002 --list
./reload_config.exp 6002 shop_table
./reload_config.exp 6002 ability
./reload_config.exp 6002 char_mount scene
./reload_config.exp 6002 activity_notice activity

2. 为什么不能直接做“任意 txt 热重载”

很多项目第一次做热重载时,直觉是:

  1. 重新读文件
  2. 更新一份配置表
  3. 结束

但真实服务端里通常不够,因为配置往往有三层状态:

  1. 配置源文件
  2. .CfgDB 或中间缓存层
  3. 各个服务内的运行时缓存

如果只改第 1 层或第 2 层,经常会出现这些问题:

  • sharetable 已经更新了,但服务本地还拿旧引用
  • 技能、能力、冲击、商店这类模块有二级缓存,必须重建
  • 场景类配置和已经刷出来的对象有状态耦合
  • 副本场景有休眠实例,不能盲目广播

所以这套方案的核心思想是:

  • 不做“无脑任意文件热更”
  • 做“目标驱动式热更”
  • 每个目标都明确知道:
    • 它是否来自 .CfgDB
    • 它是否需要刷新 scene runtime
    • 它是否需要特殊缓存清理

3. 最终架构

flowchart TD     A["debug_console / reload_config.exp"] --> B["Manager / Service reload_config(target)"]     B --> C["configreload.normalize_target(target)"]     C --> D{"CfgDB target?"}     D -- Yes --> E[".CfgDB reload_config(target)"]     E --> F["cfghelper:read_xxx() 重新读 txt / ini"]     F --> G["configenginer:reload(target)"]     G --> H["sharetable.loadtable + sharetable.update"]     H --> I["各服务本地 configs[name] 刷新"]     I --> J["scene/activity/ybexchange 运行时缓存重建"]     D -- No --> J     B --> K["返回 target / success_count / fail_count / duration_ms 等统计"]

4. 改造思路总览

改造分 8 步:

  1. 新增一个目标路由模块 configreload.lua
  2. configenginer.lua 增加强制重载接口
  3. cfghelper.lua 增加 .CfgDB reload_configlist_reloadable_configs
  4. 在 scene core 里增加 reload_config(target),并重建运行时缓存
  5. 在 scene manager / copy scene manager / dynamic scene manager 增加广播入口
  6. activity / ybexchange 这些非 scene 服务增加重载入口
  7. 补齐能力、技能、冲击、天赋、坐骑、采集点的特殊缓存刷新
  8. 增加运维脚本 reload_config.exp

5. 第一步:新增目标路由模块

文件:

  • lualib/configreload.lua

职责:

  • 归一化目标名
  • 定义别名
  • 区分 .CfgDB 型目标和 scene 特化目标
  • 定义哪些目标需要刷新哪类运行时缓存

完整代码:

local configreload = {}

local aliases = {
    ["ability.txt"] = "ability",
    ["activitynotice.txt"] = "activity_notice",
    ["activityruler.txt"] = "activity_ruler",
    ["charmount.txt"] = "char_mount",
    ["citybuilding.txt"] = "city_building",
    ["cityinfo.txt"] = "city_info",
    ["commonitem.txt"] = "common_item",
    ["configinfo.ini"] = "config_info",
    ["equipbase.txt"] = "equip_base",
    ["growpoint.txt"] = "grow_point",
    ["itemcompound.txt"] = "item_compound",
    ["scene-grow-point"] = "scene_grow_point",
    ["scene_grow_point"] = "scene_grow_point",
    ["char-mount"] = "char_mount",
    ["char_mount"] = "char_mount",
    ["sectdesc.txt"] = "sect_desc",
    ["shoptable.txt"] = "shop_table",
    ["skilldata_v1.txt"] = "skill_data",
    ["skilltemplate_v1.txt"] = "skill_template",
    ["standardimpact.txt"] = "std_impact_config",
}

local scene_special_targets = {
    char_mount = true,
    scene_grow_point = true,
}

local skill_runtime_targets = {
    exterior_ride = true,
    skill_data = true,
    skill_template = true,
    xinfa_v1 = true,
}

local impact_runtime_targets = {
    std_impact_config = true,
}

local talent_runtime_targets = {
    sect_desc = true,
}

local ability_runtime_targets = {
    ability = true,
}

local shop_runtime_targets = {
    shop_table = true,
}

local grow_point_runtime_targets = {
    grow_point = true,
}

local activity_runtime_targets = {
    activity_notice = true,
    activity_ruler = true,
}

function configreload.normalize_target(target)
    if type(target) ~= "string" then
        return nil, "reload target must be a string"
    end
    local value = string.match(target, "^%s*(.-)%s*$")
    if value == nil or value == "" then
        return nil, "reload target is empty"
    end
    local lower = string.lower(value)
    return aliases[lower] or lower
end

function configreload.is_scene_special_target(target)
    return scene_special_targets[target] == true
end

function configreload.is_cfgdb_target(target)
    return not configreload.is_scene_special_target(target)
end

function configreload.should_reload_skill_runtime(target)
    return skill_runtime_targets[target] == true
end

function configreload.should_reload_impact_runtime(target)
    return impact_runtime_targets[target] == true
end

function configreload.should_reload_talent_runtime(target)
    return talent_runtime_targets[target] == true
end

function configreload.should_reload_ability_runtime(target)
    return ability_runtime_targets[target] == true
end

function configreload.should_reload_shop_runtime(target)
    return shop_runtime_targets[target] == true
end

function configreload.should_reload_grow_point_runtime(target)
    return grow_point_runtime_targets[target] == true
end

function configreload.should_reload_activity_runtime(target)
    return activity_runtime_targets[target] == true
end

return configreload

设计重点:

  • char_mountscene_grow_point 不走 .CfgDB
  • 业务侧永远只收敛到一个 target
  • 后续新增配置时,只需要补 alias 和 runtime 分类

6. 第二步:给 configenginer 增加强制 reload

文件:

  • lualib/configenginer.lua

核心问题:

sharetable.update(name) 不是万能的。

因为原有 loadall() 的逻辑是:

  • 只有当本地 sharetable.query(name)nil 时,才会从 .CfgDB 拉一次

所以如果已经加载过,再调 loadall(),并不会重新从 .CfgDB 拿最新数据。

因此必须增加一个显式的强制重载接口。

关键代码:

local config_set = {}

for _, name in ipairs(configs) do
    config_set[name] = true
end

function configenginer:is_supported_config(name)
    return config_set[name] == true
end

function configenginer:list_supported_configs()
    return { table.unpack(configs) }
end

function configenginer:reload(name)
    assert(self:is_supported_config(name), name)
    local cmd = string.format("get_%s", name)
    local config = skynet.call(".CfgDB", "lua", cmd)
    sharetable.loadtable(name, config)
    sharetable.update(name)
    config = sharetable.query(name)
    self.configs[name] = config
    return config
end

这一段是整套能力最关键的点之一。

7. 第三步:让 .CfgDB 支持按目标重读配置

文件:

  • lualib/cfghelper.lua
  • services/cfgdb.lua

这里其实 services/cfgdb.lua 不需要改分发逻辑,因为它本身就是通过 CfgHelper[k] 动态分发的,只要 cfghelper.lua 里新增方法,.CfgDB 就能直接调用。

关键代码:

local function elapsed_ms(start_tick)
    return (skynet.now() - start_tick) * 10
end

local function resolve_reload_reader(self, key)
    local method_name = string.format("read_%s", key)
    if self[method_name] then
        return method_name
    end
    local compact_key = string.gsub(key, "_config$", "")
    method_name = string.format("read_%s", compact_key)
    if self[method_name] then
        return method_name
    end
end

function cfghelper:list_reloadable_configs()
    local configenginer = require "configenginer":getinstance()
    local configs = configenginer:list_supported_configs()
    table.sort(configs)
    return configs
end

function cfghelper:reload_config(target)
    local start_tick = skynet.now()
    local key, err = configreload.normalize_target(target)
    assert(key, err)
    assert(configreload.is_cfgdb_target(key), string.format("%s needs to be reloaded in scene services", key))
    local reader_name = assert(resolve_reload_reader(self, key), key)
    local reader = assert(self[reader_name], key)
    reader(self)
    collectgarbage("collect")
    return {
        target = key,
        reader = reader_name,
        duration_ms = elapsed_ms(start_tick),
    }
end

8. 第四步:在 scene 服务里真正做热重载

文件:

  • services/scene/scenecore.lua

这一层是真正把“配置重读”变成“运行时生效”的关键。

如果只刷新 .CfgDBconfigenginer,很多 scene 内的缓存对象、逻辑表、索引表仍然还是旧的。

所以 scene 必须显式地做一次 runtime rebuild。

关键代码:

local function resolve_scene_grow_point_files(scene)
    local scn = scene:get_scn()
    if scn and scn.System and scn.System.growpointdata and scn.System.growpointsetup then
        return scn.System.growpointdata, scn.System.growpointsetup
    end
    local conf = scene.conf or {}
    if conf.growpointdata and conf.growpointsetup then
        return conf.growpointdata, conf.growpointsetup
    end
end

local function reload_scene_runtime(scene, target)
    local runtime_reloaded = false
    if configreload.should_reload_skill_runtime(target) then
        scene.skillenginer:loadall()
        runtime_reloaded = true
    end
    if configreload.should_reload_impact_runtime(target) then
        scene.impactenginer:loadall()
        runtime_reloaded = true
    end
    if configreload.should_reload_talent_runtime(target) then
        scene.talentenginer:loadall()
        runtime_reloaded = true
    end
    if configreload.should_reload_ability_runtime(target) then
        abilityenginer:loadall()
        runtime_reloaded = true
    end
    if configreload.should_reload_shop_runtime(target) then
        shopenginer:init()
        runtime_reloaded = true
    end
    if configreload.should_reload_grow_point_runtime(target) then
        if scene.growpointenginer and scene.growpointenginer.grow_up_config and scene.growpointenginer.set_up_config then
            scene.growpointenginer:load()
            runtime_reloaded = true
        end
    elseif target == "scene_grow_point" then
        local data_file, setup_file = resolve_scene_grow_point_files(scene)
        if data_file and setup_file then
            scene.growpointenginer:load_data_and_set_up(data_file, setup_file)
            runtime_reloaded = true
        end
    elseif target == "char_mount" then
        ai_human.reload_char_mount_config()
        runtime_reloaded = true
    end
    return runtime_reloaded
end

function scenecore:reload_config(target, skip_cfgdb_reload)
    local start_tick = skynet.now()
    local key, err = configreload.normalize_target(target)
    assert(key, err)
    local cfgdb_ms = 0
    local config_ms = 0
    if configreload.is_cfgdb_target(key) then
        assert(configenginer:is_supported_config(key), key)
        if not skip_cfgdb_reload then
            local cfgdb_tick = skynet.now()
            skynet.call(".CfgDB", "lua", "reload_config", key)
            cfgdb_ms = elapsed_ms(cfgdb_tick)
        end
        local config_tick = skynet.now()
        configenginer:reload(key)
        config_ms = elapsed_ms(config_tick)
    end
    local runtime_tick = skynet.now()
    local runtime_reloaded = reload_scene_runtime(self, key)
    local runtime_ms = runtime_reloaded and elapsed_ms(runtime_tick) or 0
    assert(configreload.is_cfgdb_target(key) or runtime_reloaded, key)
    return {
        scope = "scene",
        scene_id = self.id,
        target = key,
        runtime = runtime_reloaded,
        cfgdb_ms = cfgdb_ms,
        config_ms = config_ms,
        runtime_ms = runtime_ms,
        duration_ms = elapsed_ms(start_tick),
    }
end

设计重点:

  • skip_cfgdb_reload 很重要,manager 广播时必须传 true,否则每个 scene 都会重复去打一次 .CfgDB
  • scene_grow_pointchar_mount 都是 scene special target,不走 .CfgDB
  • assert(configreload.is_cfgdb_target(key) or runtime_reloaded, key) 可以把“不支持的目标”第一时间打爆,避免静默失败

如果原来的 services/scene/scene.lua 是这种自动转发写法:

local CMD = setmetatable({}, {
    __index = function(_, k)
        local method = SceneCore[k]
        assert(method, k)
        return function(...) return method(SceneCore, ...) end
    end
})

那么这里不需要额外加 dispatch,reload_config 会自动透传到 SceneCore

9. 第五步:给 manager 增加广播入口

文件:

  • lualib/scenemanager_core.lua
  • lualib/copyscenemanager_core.lua
  • lualib/dynamicscenemanager_core.lua

这一层负责三件事:

  1. 只在 manager 本地重载一次 .CfgDBconfigenginer
  2. 再把 reload_config 广播给所有 scene
  3. 汇总成功数、失败数、耗时,供运维判断是否正常

执行链路如下:

sequenceDiagram     participant Ops as 运维脚本     participant Mgr as SceneManager     participant CfgDB as .CfgDB     participant Local as 本地 configenginer     participant Scene as Scene 实例     Ops->>Mgr: call .SceneManager "reload_config","shop_table"     Mgr->>CfgDB: reload_config(shop_table)     Mgr->>Local: configenginer:reload(shop_table)     loop fanout         Mgr->>Scene: reload_config(shop_table, true)         Scene->>Scene: 重建运行时缓存     end     Mgr-->>Ops: success_count / fail_count / duration_ms

主场景 manager:

function scenemanager_core:reload_config(target)
    local start_tick = skynet.now()
    local key, err = configreload.normalize_target(target)
    assert(key, err)
    local cfgdb_ms = 0
    local local_config_ms = 0
    if configreload.is_cfgdb_target(key) then
        local cfgdb_tick = skynet.now()
        skynet.call(".CfgDB", "lua", "reload_config", key)
        cfgdb_ms = elapsed_ms(cfgdb_tick)
        if configenginer:is_supported_config(key) then
            local config_tick = skynet.now()
            configenginer:reload(key)
            local_config_ms = elapsed_ms(config_tick)
        end
    end
    local success_count = 0
    local fail_count = 0
    local fanout_tick = skynet.now()
    for _, scene in ipairs(self.scenes) do
        local r, reload_err = xpcall(skynet.call, debug.traceback, scene, "lua", "reload_config", key, true)
        if r then
            success_count = success_count + 1
        else
            fail_count = fail_count + 1
            print("reload_config error =", reload_err)
        end
    end
    return {
        scope = "scene_manager",
        target = key,
        success_count = success_count,
        fail_count = fail_count,
        cfgdb_ms = cfgdb_ms,
        local_config_ms = local_config_ms,
        fanout_ms = elapsed_ms(fanout_tick),
        duration_ms = elapsed_ms(start_tick),
    }
end

这里有一个细节,实际代码里要写成:

local_config_ms = elapsed_ms(config_tick)

不要误写成新的 local local_config_ms,否则会把外层变量遮掉。

副本场景 manager:

function copyscenemanager_core:reload_config(target)
    local start_tick = skynet.now()
    local key, err = configreload.normalize_target(target)
    assert(key, err)
    local cfgdb_ms = 0
    local local_config_ms = 0
    if configreload.is_cfgdb_target(key) then
        local cfgdb_tick = skynet.now()
        skynet.call(".CfgDB", "lua", "reload_config", key)
        cfgdb_ms = elapsed_ms(cfgdb_tick)
        if configenginer:is_supported_config(key) then
            local config_tick = skynet.now()
            configenginer:reload(key)
            local_config_ms = elapsed_ms(config_tick)
        end
    end
    local success_count = 0
    local fail_count = 0
    local fanout_tick = skynet.now()
    for _, scene in ipairs(self.load) do
        local should_reload = true
        if key == "scene_grow_point" then
            should_reload = skynet.call(scene, "lua", "get_status") == define.SCENE_STATUS.SCENE_STATUS_RUNNING
        end
        if should_reload then
            local r, reload_err = pcall(skynet.call, scene, "lua", "reload_config", key, true)
            if r then
                success_count = success_count + 1
            else
                fail_count = fail_count + 1
                print("reload_config error =", reload_err)
            end
        end
    end
    return {
        scope = "copy_scene_manager",
        target = key,
        success_count = success_count,
        fail_count = fail_count,
        cfgdb_ms = cfgdb_ms,
        local_config_ms = local_config_ms,
        fanout_ms = elapsed_ms(fanout_tick),
        duration_ms = elapsed_ms(start_tick),
    }
end

动态场景 manager:

function dynamicscenemanager_core:reload_config(target)
    local start_tick = skynet.now()
    local key, err = configreload.normalize_target(target)
    assert(key, err)
    local cfgdb_ms = 0
    local local_config_ms = 0
    if configreload.is_cfgdb_target(key) then
        local cfgdb_tick = skynet.now()
        skynet.call(".CfgDB", "lua", "reload_config", key)
        cfgdb_ms = elapsed_ms(cfgdb_tick)
        if configenginer:is_supported_config(key) then
            local config_tick = skynet.now()
            configenginer:reload(key)
            local_config_ms = elapsed_ms(config_tick)
        end
    end
    local success_count = 0
    local fail_count = 0
    local fanout_tick = skynet.now()
    for _, scene in ipairs(self.scenes) do
        local handle = scene.scene
        local r, reload_err = xpcall(skynet.call, debug.traceback, handle, "lua", "reload_config", key, true)
        if r then
            success_count = success_count + 1
        else
            fail_count = fail_count + 1
            print("reload_config error =", reload_err)
        end
    end
    return {
        scope = "dynamic_scene_manager",
        target = key,
        success_count = success_count,
        fail_count = fail_count,
        cfgdb_ms = cfgdb_ms,
        local_config_ms = local_config_ms,
        fanout_ms = elapsed_ms(fanout_tick),
        duration_ms = elapsed_ms(start_tick),
    }
end

10. 第六步:给业务服务加 reload_config

文件:

  • services/activity/activitycore.lua
  • services/ybexchange/ybexchange_core.lua

不是所有配置都只在 scene 用。

比如:

  • activity_notice
  • activity_ruler
  • 某些兑换、商城、配置说明类数据

如果这些服务内部也缓存了配置,那么也必须补 reload 入口。

activitycore.lua

function activitycore:reload_config(target)
    local start_tick = skynet.now()
    local key, err = configreload.normalize_target(target)
    assert(key, err)
    assert(configreload.is_cfgdb_target(key), string.format("%s needs to be reloaded in scene services", key))
    assert(configenginer:is_supported_config(key), key)
    local cfgdb_tick = skynet.now()
    skynet.call(".CfgDB", "lua", "reload_config", key)
    local cfgdb_ms = elapsed_ms(cfgdb_tick)
    local config_tick = skynet.now()
    configenginer:reload(key)
    local config_ms = elapsed_ms(config_tick)
    local runtime_reloaded = configreload.should_reload_activity_runtime(key)
    local runtime_tick = skynet.now()
    if configreload.should_reload_activity_runtime(key) then
        self.activity_notice = configenginer:get_config("activity_notice")
        self.activity_ruler = configenginer:get_config("activity_ruler")
    end
    local runtime_ms = runtime_reloaded and elapsed_ms(runtime_tick) or 0
    return {
        service = "activity",
        target = key,
        runtime = runtime_reloaded,
        cfgdb_ms = cfgdb_ms,
        config_ms = config_ms,
        runtime_ms = runtime_ms,
        duration_ms = elapsed_ms(start_tick),
    }
end

ybexchange_core.lua

function ybexchange_core:reload_config(target)
    local start_tick = skynet.now()
    local key, err = configreload.normalize_target(target)
    assert(key, err)
    assert(configreload.is_cfgdb_target(key), string.format("%s needs to be reloaded in scene services", key))
    assert(configenginer:is_supported_config(key), key)
    local cfgdb_tick = skynet.now()
    skynet.call(".CfgDB", "lua", "reload_config", key)
    local cfgdb_ms = elapsed_ms(cfgdb_tick)
    local config_tick = skynet.now()
    configenginer:reload(key)
    local config_ms = elapsed_ms(config_tick)
    return {
        service = "ybexchange",
        target = key,
        cfgdb_ms = cfgdb_ms,
        config_ms = config_ms,
        duration_ms = elapsed_ms(start_tick),
    }
end

如果的 service 外层入口和 scene.lua 一样,是通过 __index 动态转发到 activitycoreybexchange_core,那也不需要再单独补 dispatch。

11. 第七步:补齐运行时缓存修复

这一部分经常最容易漏。

很多人把热重载做完之后,命令能返回 OK,但游戏里看起来还是旧数据,根因往往就在“运行时缓存没清”。

11.1 坐骑配置

文件:

  • services/scene/obj/human.lua

关键代码:

function human.reload_char_mount_config()
    char_mount_reader.fs = {}
    char_mount_seats_by_id = nil
    char_mount_seats_by_model = nil
end

如果不清这几个缓存,char_mount 重载后很多玩家对象仍然会继续读旧座位表。

11.2 场景 grow point

文件:

  • lualib/growpointenginer.lua
  • lualib/typegrowpointmanager.lua

关键代码:

function growpointenginer:restore_existing_growpoint(type, x, y)
    local manager = self:ensure_type_manager(type)
    for i = 1, manager.type_count do
        local data = manager.data[i]
        if math.abs(data.x - x) < 0.01 and math.abs(data.y - y) < 0.01 then
            if not data.used then
                data.used = true
                manager:inc_current_count()
            end
            return true
        end
    end
    manager:add_data({
        x = x,
        y = y,
        type = type,
        used = true,
        retired = true,
    })
    manager:inc_count()
    manager:inc_current_count()
    return true
end

function growpointenginer:restore_existing_growpoints()
    local scene = self:get_scene()
    if scene == nil or scene.get_objs == nil then
        return
    end
    for _, obj in pairs(scene:get_objs()) do
        if obj and obj.get_obj_type and obj:get_obj_type() == "itembox" and obj.get_item_box_type then
            local item_box_type = obj:get_item_box_type()
            if item_box_type ~= define.ITEMBOX_TYPE.ITYPE_DROPBOX then
                local world_pos = obj:get_world_pos()
                if world_pos then
                    self:restore_existing_growpoint(item_box_type, world_pos.x, world_pos.y)
                end
            end
        end
    end
end

以及:

function typegrowpointmanager:create_grow_point_pos()
    if self.current_count >= self.max_appera_count then
        return false
    end
    for i = 1, self.max_appera_count do
        local data = self.data[i]
        if data and not data.retired and not data.used then
            data.used = true
            self.type_offset = self.type_offset + 1
            if self.type_offset == self.type_count then
                self.type_offset = 1
            end
            self:inc_current_count()
            return true, self.data[i].x, self.data[i].y
        elseif data then
            self.type_offset = self.type_offset + 1
            if self.type_offset == self.type_count then
                self.type_offset = 1
            end
        end
    end
    return false
end

function typegrowpointmanager:release_grow_point_pos(x, y)
    for i = 1, self.type_count do
        local data = self.data[i]
        if math.abs(data.x - x) < 0.01 and math.abs(data.y - y) < 0.01 then
            if data.used then
                data.used = false
                self:dec_current_count()
            end
            return true
        end
    end
    return false
end

这段逻辑解决的是:

  • 热重载后,当前场景里已经存在的采集点/掉落点不能“凭空丢失”
  • 已经失效的位置不能再次被随机分配

11.3 各种 engine 的本地缓存

只要引擎里缓存了逻辑对象、排序索引、座位表、映射表,就要在 loadall() 里先清空再重建。

例如:

function abilityenginer:loadall()
    self.abilitys = {}
    local abilitys = configenginer:get_config("ability")
    ...
end

function skillenginer:loadall()
    self.skill_logics = {}
    self.exterior_ride_config = {}
    self.xinfa_orders = {}
    self.skill_orders = {}
    self.skill_template_conf = configenginer:get_config("skill_template")
    self.skill_data_conf = configenginer:get_config("skill_data")
    ...
end

function impactenginer:loadall()
    self.logics = {}
    self.std_impact_config = configenginer:get_config("std_impact_config")
    ...
end

function talentenginer:loadall()
    self.logics = {}
    self.talent_config = configenginer:get_config("sect_desc")
    ...
end

判断标准很简单:

  • 如果这个模块启动时会做一次 init/loadall/register
  • 并且把配置转成了内存对象
  • 那热重载时它大概率也要走一遍同样的重建流程

12. 第八步:增加运维脚本

文件:

  • reload_config.exp

这一层的目标不是“让程序能热重载”。

程序前面几步已经能热重载了。

这一层的目标是:

  • 给运维一个稳定、简单、不容易输错的入口
  • 支持按 scope 调用不同服务
  • 支持 --list 查看当前支持的可重载配置
  • 把 console 输出整理成易读格式

完整代码如下:

#!/usr/bin/expect
set port [lindex $argv 0]
set target [lindex $argv 1]
set scope [lindex $argv 2]
set timeout 30
match_max 1048576
log_user 0

proc print_usage {} {
    puts "usage: ./reload_config.exp <port> <target|--list> ?scope?"
    puts "scope:"
    puts "  scene         -> .SceneManager .Copyscenemanager .Dynamicscenemanager"
    puts "  main_scene    -> .SceneManager"
    puts "  copy_scene    -> .Copyscenemanager"
    puts "  dynamic_scene -> .Dynamicscenemanager"
    puts "  activity      -> .Activitymanager"
    puts "  ybexchange    -> .Ybexchange"
    puts "  cfgdb         -> .CfgDB"
    puts "  all           -> scene + activity + ybexchange"
    puts "  .ServiceName  -> exact service alias"
    puts "  .A,.B,.C      -> exact service alias list"
    puts "examples:"
    puts "  ./reload_config.exp 6002 ability"
    puts "  ./reload_config.exp 6002 char_mount scene"
    puts "  ./reload_config.exp 6002 activity_notice activity"
    puts "  ./reload_config.exp 6002 config_info ybexchange"
    puts "  ./reload_config.exp 6004 scene_grow_point .SCENE_1297,.SCENE_1298,.SCENE_1299,.SCENE_1300,.Copyscenemanager"
    puts "  ./reload_config.exp 6002 --list"
}

proc normalize_output {text command marker} {
    regsub -all {\r\n?} $text "\n" text
    set lines {}
    foreach line [split $text "\n"] {
        set trimmed [string trim $line]
        if {$trimmed eq ""} {
            continue
        }
        if {$trimmed eq $command} {
            continue
        }
        if {$trimmed eq $marker} {
            continue
        }
        lappend lines $trimmed
    }
    return [join $lines "\n"]
}

proc format_list_output {output} {
    set items {}
    foreach line [split $output "\n"] {
        foreach {all idx name} [regexp -all -inline {([0-9]+):([A-Za-z0-9_]+)} $line] {
            lappend items [list [expr {$idx + 0}] $name]
        }
    }
    if {[llength $items] == 0} {
        return $output
    }
    set items [lsort -integer -index 0 $items]
    set lines {}
    foreach item $items {
        lappend lines [lindex $item 1]
    }
    return [join $lines "\n"]
}

proc format_call_output {output} {
    set lines {}
    foreach line [split $output "\n"] {
        set trimmed [string trim $line]
        if {$trimmed eq ""} {
            continue
        }
        if {[regexp {^n\s+1$} $trimmed]} {
            continue
        }
        if {[regexp {^1\s+(.+)$} $trimmed -> payload]} {
            set parts [regexp -all -inline {[A-Za-z_]+:[^ \t]+} $payload]
            if {[llength $parts] == 0} {
                set parts [split $payload "\t"]
            }
            foreach part $parts {
                set part [string trim $part]
                if {$part ne ""} {
                    lappend lines $part
                }
            }
            continue
        }
        lappend lines $trimmed
    }
    return [join $lines "\n"]
}

proc run_command {command {formatter ""} {show_empty 1}} {
    send -- "$command\n"
    expect {
        -re {<CMD OK>\r?\n} {
            set raw_output [normalize_output $expect_out(buffer) $command "<CMD OK>"]
            set output [format_call_output $raw_output]
            if {$formatter ne ""} {
                set output [$formatter $output]
            }
            if {$output eq ""} {
                set output $raw_output
            }
            if {$output ne ""} {
                send_user "$output\n"
            } elseif {$show_empty} {
                send_user "OK\n"
            }
        }
        -re {<CMD Error>\r?\n} {
            set raw_output [normalize_output $expect_out(buffer) $command "<CMD Error>"]
            set output [format_call_output $raw_output]
            if {$formatter ne ""} {
                set output [$formatter $output]
            }
            if {$output eq ""} {
                set output $raw_output
            }
            if {$output ne ""} {
                send_user "$output\n"
            } elseif {$show_empty} {
                send_user "ERROR\n"
            }
            exit 1
        }
        timeout {
            send_user "timeout: $command\n"
            exit 2
        }
        eof { exit 3 }
    }
}

proc normalize_value {value} {
    return [string tolower [string trim $value]]
}

proc is_scene_special_target {target} {
    set key [normalize_value $target]
    return [expr {
        $key eq "char_mount"
        || $key eq "char-mount"
        || $key eq "charmount.txt"
        || $key eq "scene_grow_point"
        || $key eq "scene-grow-point"
    }]
}

proc resolve_direct_services {scope} {
    set services {}
    foreach token [split $scope ","] {
        set token [string trim $token]
        if {$token ne ""} {
            lappend services $token
        }
    }
    return $services
}

proc resolve_services {scope target} {
    set raw_scope [string trim $scope]
    if {$raw_scope eq ""} {
        set raw_scope "scene"
    }

    if {[string index $raw_scope 0] eq "." || [string first "," $raw_scope] >= 0} {
        set services [resolve_direct_services $raw_scope]
        if {[llength $services] == 0} {
            puts stderr "empty service list"
            exit 64
        }
        return $services
    }

    set key [normalize_value $raw_scope]
    switch -- $key {
        "scene" {
            return [list .SceneManager .Copyscenemanager .Dynamicscenemanager]
        }
        "main_scene" -
        "scene_manager" {
            return [list .SceneManager]
        }
        "copy_scene" -
        "copy" {
            return [list .Copyscenemanager]
        }
        "dynamic_scene" -
        "dynamic" {
            return [list .Dynamicscenemanager]
        }
        "activity" {
            return [list .Activitymanager]
        }
        "ybexchange" -
        "exchange" {
            return [list .Ybexchange]
        }
        "cfgdb" {
            return [list .CfgDB]
        }
        "all" {
            if {[is_scene_special_target $target]} {
                return [list .SceneManager .Copyscenemanager .Dynamicscenemanager]
            }
            return [list .SceneManager .Copyscenemanager .Dynamicscenemanager .Activitymanager .Ybexchange]
        }
        default {
            puts stderr "unknown scope: $scope"
            print_usage
            exit 64
        }
    }
}

if {$port eq "--help" || $port eq "-h"} {
    print_usage
    exit 0
}

if {$port eq "" || $target eq ""} {
    print_usage
    exit 64
}

if {$target eq "--help" || $target eq "-h"} {
    print_usage
    exit 0
}

spawn -noecho ./nc.sh $port
expect "Welcome to skynet console"
run_command "clearcache" "" 0

if {$target eq "--list"} {
    set command {call .CfgDB "list_reloadable_configs"}
    run_command $command format_list_output
    close
    wait
    exit 0
}

foreach service [resolve_services $scope $target] {
    set command [format {call %s "reload_config","%s"} $service $target]
    run_command $command
}

close
wait

13. 使用方式

这套方案本质上和 reload_scripts 是同一类能力:

  • 都是“运维主动执行命令”
  • 都是“服务端收到命令后做一次重载”
  • 都不是文件监听式自动热更新

底层本质命令其实就是:

call .SceneManager "reload_config","shop_table"

只是人工直接敲 skynet console 容易输错,所以我们再包了一层 reload_config.exp

常用命令:

./reload_config.exp 6002 --list
./reload_config.exp 6002 shop_table
./reload_config.exp 6002 shop_table main_scene
./reload_config.exp 6002 activity_notice activity
./reload_config.exp 6002 config_info ybexchange
./reload_config.exp 6002 char_mount scene
./reload_config.exp 6004 scene_grow_point .SCENE_1297,.SCENE_1298,.SCENE_1299,.SCENE_1300,.Copyscenemanager

说明:

  • 不传 scope,默认走 scene
  • --list 当前列的是 .CfgDB 可重载配置
  • char_mountscene_grow_point 这类 scene special target 不一定出现在 --list
  • 如果要做“完全自定义”,可以直接传 .A,.B,.C 形式的服务别名列表

14. 联调与验收步骤

建议按下面顺序验收。

14.1 基础验收

  1. 先执行:
./reload_config.exp 6002 --list

确认能正确列出一行一个配置名。

  1. 再选一个风险较低的配置测试,例如:
./reload_config.exp 6002 shop_table

预期输出类似:

cfgdb_ms:240
duration_ms:15030
fail_count:0
fanout_ms:14780
local_config_ms:10
scope:scene_manager
success_count:171
target:shop_table
cfgdb_ms:230
duration_ms:340
fail_count:0
fanout_ms:50
local_config_ms:60
scope:copy_scene_manager
success_count:1
target:shop_table
cfgdb_ms:260
duration_ms:740
fail_count:0
fanout_ms:470
local_config_ms:10
scope:dynamic_scene_manager
success_count:7
target:shop_table

判断标准:

  • fail_count 必须是 0
  • success_count 要符合当前场景数量预期
  • target 要和传入的目标一致
  • cfgdb_mslocal_config_msfanout_ms 加起来和 duration_ms 大体匹配

14.2 特殊目标验收

坐骑:

./reload_config.exp 6002 char_mount scene

采集点:

./reload_config.exp 6004 scene_grow_point .SCENE_1297,.SCENE_1298,.SCENE_1299,.SCENE_1300,.Copyscenemanager

如果这些命令返回成功,但游戏内仍然看不到变化,优先排查:

  • 对应 runtime cache 是否清掉了
  • scene 是否真的执行到了 reload_scene_runtime
  • 目标是否被 configreload.normalize_target() 正确归一化

14.3 业务服务验收

活动:

./reload_config.exp 6002 activity_notice activity
./reload_config.exp 6002 activity_ruler activity

元宝商店或兑换:

./reload_config.exp 6002 config_info ybexchange

验收重点不是只有命令 OK,而是服务内缓存字段是否已经切到新配置。

15. 性能影响分析

结论先说:

  • 这套方案几乎没有常驻性能损耗
  • 性能开销只发生在主动执行重载命令的那一瞬间
  • 真正的瓶颈一般不是 .CfgDB,而是 manager 向大量 scene 串行 fanout

15.1 为什么平时几乎没影响

因为它不是:

  • 文件监听器
  • 定时轮询器
  • 自动比对 txt 变化的后台任务

平时只是多了一些:

  • reload 路由函数
  • 少量目标分类表
  • 少量额外方法

这些都是常驻内存级别的小成本。

15.2 重载时的开销来自哪里

一次 reload 的耗时,通常来自四段:

  1. .CfgDB 重新读文件
  2. configenginer:reload(target) 更新 sharetable 和本地引用
  3. manager 广播到所有 scene
  4. scene 侧重建运行时缓存

实测 shop_table 输出已经很说明问题:

  • 主场景 duration_ms:15030
  • 其中 fanout_ms:14780
  • 主场景成功数 171

这说明瓶颈非常明确,主要耗在“171 个 scene 逐个调用”这一段,不是 .CfgDB 本身慢。

15.3 稳定性如何

  • 热重载只在人工执行时触发,不是高频自动任务
  • fail_count 稳定为 0
  • 关键目标都做过实际业务验证
  • char_mountscene_grow_point 这类特殊目标做过缓存一致性验证

注意事项:

  • 避开高峰期第一次执行
  • 优先从 main_sceneactivityybexchange 等小范围开始灰度
  • 先重载低风险配置,再重载高影响配置

15.4 优化方向

如果以后场景数更多,可以考虑:

  • fanout 并行化或分批化
  • 对超大 target 做更细粒度广播
  • 给脚本加限流、日志落盘、告警

但这已经属于下阶段优化,不是必要的。

16. 新增一个可热重载目标的最小改造清单

以后再加新的可热重载目标,按这个顺序做就行:

  1. lualib/configreload.lua 里补目标归一化和分类
  2. 如果它来自 .CfgDB,就在 cfghelper.lua 里补 reader 和 reload_config(target) 支持
  3. 如果它要进 configenginer,就保证 configenginer:is_supported_config(name) 能识别它
  4. 在 scene 或业务服务里补 runtime rebuild 逻辑
  5. 如果有本地缓存、索引、逻辑对象,记得先清空再重建
  6. reload_config.exp 实测一遍,并观察耗时和失败数

可以把它理解成一句话:

先把“数据源”重载,再把“内存态”重建。

17. 常见坑

17.1 只调 sharetable.update() 但没重读 .CfgDB

这是最常见的假热重载。

表现就是命令执行成功,但数据还是旧的。

原因前面已经说过,原来的 loadall() 只会在本地没加载时才从 .CfgDB 取一次,所以必须补 configenginer:reload(name)

17.2 manager 广播时忘了传 skip_cfgdb_reload

这会导致每个 scene 都重新打一遍 .CfgDB,放大 IO 和 GC 成本。

17.3 运行时缓存没清

典型就是:

  • 坐骑座位表
  • skill/impact/talent/ability 的逻辑缓存
  • grow point 的占用状态和 retired 标记

17.4 --list 只列出 cfgdb 目标

这是当前脚本的设计行为,不是 bug。

如果想把 char_mountscene_grow_point 也列出来,可以在脚本里再拼一份手工 special target 列表。

17.5 返回 OK 不等于业务已经生效

一定要验证:

  • 服务内缓存字段是否已切新值
  • 游戏内实际表现是否变化
  • 是否存在旧对象继续引用旧缓存

18. 总结

这套热重载方案,本质上不是“任意 txt 自动重载”,而是“可控目标 + 显式命令 + 运行时重建”的热重载体系。

它的优点是:

  • 安全
  • 可灰度
  • 容易排错
  • 对现网常驻性能影响极小

它真正能上线的关键,不在于命令能不能执行,而在于下面这条链路有没有打通:

CfgDB 重读 -> sharetable 更新 -> configenginer 切新引用 -> 业务/scene 重建运行时缓存
您需要登录后才可以回帖 登录 | register

本版积分规则

QQ|雪舞知识库 ( 浙ICP备15015590号-1 | 萌ICP备20232229号|浙公网安备33048102000118号 )|天天打卡

GMT+8, 2026-6-24 05:46 , Processed in 0.064800 second(s), 26 queries .

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表