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 热重载”
很多项目第一次做热重载时,直觉是:
- 重新读文件
- 更新一份配置表
- 结束
但真实服务端里通常不够,因为配置往往有三层状态:
- 配置源文件
.CfgDB 或中间缓存层
- 各个服务内的运行时缓存
如果只改第 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 步:
- 新增一个目标路由模块
configreload.lua
- 在
configenginer.lua 增加强制重载接口
- 在
cfghelper.lua 增加 .CfgDB reload_config 和 list_reloadable_configs
- 在 scene core 里增加
reload_config(target),并重建运行时缓存
- 在 scene manager / copy scene manager / dynamic scene manager 增加广播入口
- 在
activity / ybexchange 这些非 scene 服务增加重载入口
- 补齐能力、技能、冲击、天赋、坐骑、采集点的特殊缓存刷新
- 增加运维脚本
reload_config.exp
5. 第一步:新增目标路由模块
文件:
职责:
- 归一化目标名
- 定义别名
- 区分
.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_mount 和 scene_grow_point 不走 .CfgDB
- 业务侧永远只收敛到一个
target
- 后续新增配置时,只需要补 alias 和 runtime 分类
6. 第二步:给 configenginer 增加强制 reload
文件:
核心问题:
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
这一层是真正把“配置重读”变成“运行时生效”的关键。
如果只刷新 .CfgDB 和 configenginer,很多 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_point 和 char_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
这一层负责三件事:
- 只在 manager 本地重载一次
.CfgDB 和 configenginer
- 再把
reload_config 广播给所有 scene
- 汇总成功数、失败数、耗时,供运维判断是否正常
执行链路如下:
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 动态转发到 activitycore 或 ybexchange_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. 第八步:增加运维脚本
文件:
这一层的目标不是“让程序能热重载”。
程序前面几步已经能热重载了。
这一层的目标是:
- 给运维一个稳定、简单、不容易输错的入口
- 支持按 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_mount、scene_grow_point 这类 scene special target 不一定出现在 --list
- 如果要做“完全自定义”,可以直接传
.A,.B,.C 形式的服务别名列表
14. 联调与验收步骤
建议按下面顺序验收。
14.1 基础验收
- 先执行:
./reload_config.exp 6002 --list
确认能正确列出一行一个配置名。
- 再选一个风险较低的配置测试,例如:
./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_ms、local_config_ms、fanout_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 的耗时,通常来自四段:
.CfgDB 重新读文件
configenginer:reload(target) 更新 sharetable 和本地引用
- manager 广播到所有 scene
- scene 侧重建运行时缓存
实测 shop_table 输出已经很说明问题:
- 主场景
duration_ms:15030
- 其中
fanout_ms:14780
- 主场景成功数
171
这说明瓶颈非常明确,主要耗在“171 个 scene 逐个调用”这一段,不是 .CfgDB 本身慢。
15.3 稳定性如何
- 热重载只在人工执行时触发,不是高频自动任务
fail_count 稳定为 0
- 关键目标都做过实际业务验证
- 对
char_mount、scene_grow_point 这类特殊目标做过缓存一致性验证
注意事项:
- 避开高峰期第一次执行
- 优先从
main_scene、activity、ybexchange 等小范围开始灰度
- 先重载低风险配置,再重载高影响配置
15.4 优化方向
如果以后场景数更多,可以考虑:
- fanout 并行化或分批化
- 对超大 target 做更细粒度广播
- 给脚本加限流、日志落盘、告警
但这已经属于下阶段优化,不是必要的。
16. 新增一个可热重载目标的最小改造清单
以后再加新的可热重载目标,按这个顺序做就行:
- 在
lualib/configreload.lua 里补目标归一化和分类
- 如果它来自
.CfgDB,就在 cfghelper.lua 里补 reader 和 reload_config(target) 支持
- 如果它要进
configenginer,就保证 configenginer:is_supported_config(name) 能识别它
- 在 scene 或业务服务里补 runtime rebuild 逻辑
- 如果有本地缓存、索引、逻辑对象,记得先清空再重建
- 用
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_mount、scene_grow_point 也列出来,可以在脚本里再拼一份手工 special target 列表。
17.5 返回 OK 不等于业务已经生效
一定要验证:
- 服务内缓存字段是否已切新值
- 游戏内实际表现是否变化
- 是否存在旧对象继续引用旧缓存
18. 总结
这套热重载方案,本质上不是“任意 txt 自动重载”,而是“可控目标 + 显式命令 + 运行时重建”的热重载体系。
它的优点是:
它真正能上线的关键,不在于命令能不能执行,而在于下面这条链路有没有打通:
CfgDB 重读 -> sharetable 更新 -> configenginer 切新引用 -> 业务/scene 重建运行时缓存