从重启到热更:战斗脚本在线更新的三道防线
上一篇写 gopher-lua 集成时,热更新还停留在”设计思路”阶段——改脚本得重启服务。现在 slg-go 的战斗脚本热更新已经落地了:一个 HTTP POST 就能把新 Lua 脚本推到线上,带版本号、审计日志、一键回滚。
但上线过程踩了三个坑,每个都差点翻车。
第一道防线:版本管理 + 审计日志
热更新最怕的不是”更新失败”,是”更新了但不知道什么时候更新的、谁更新的、能不能回滚”。
BattleEngine 用一个 sync.RWMutex 保护脚本状态,每次 reload 都记录审计日志:
1 | // internal/battle/engine/engine.go |
关键设计点:
- 版本号不能重复:如果传入的 version 和当前一样,直接拒绝
- operator 字段:记录是谁触发的更新(运维/CI/API)
- PreviousVersion:审计日志链,可以追溯每次变更
回滚也很简单——RollbackScript(version, operator) 从 scriptVersions 字典里找到对应目录,重新加载:
1 | func (e *BattleEngine) RollbackScript(version string, operator string) (*ScriptAuditLog, error) { |
第二道防线:HTTP API 暴露
热更新不能只给内部用,需要一个安全的 HTTP 接口让运维/CI 调用。Scheduler 注册了两个端点:
1 | // internal/battle/scheduler/scheduler.go |
handleScriptReload 接收 JSON 请求体:
1 | type scriptReloadRequest struct { |
调用方式:
1 | # 热更新到新脚本目录 |
响应包含当前版本信息和完整的审计日志。
第三道防线:进程级 Drain + 迁移
战斗脚本热更不需要停进程,但如果 Logic 服务本身要重启(比如 Go 代码变更),就需要优雅退役——先把玩家迁移到新实例,再停旧进程。
Logic 服务的关闭流程分五步:
1 | // cmd/logic/main.go — 信号触发后的清理流程 |
迁移目标通过环境变量 MIGRATE_TARGET_STATE_ID 指定,默认是 StateID + 1。超时时间通过 DRAIN_TIMEOUT_SEC 控制,默认 30 秒。
三道防线的协作关系
graph TD
A["运维触发"] -->|"POST /battle/script/reload"| B["版本管理 + 审计"]
B --> C{"脚本目录有效?"}
C -->|"是"| D["更新 scriptCurrent"]
C -->|"否"| E["返回错误"]
D --> F["新请求用新脚本"]
G["CI/CD 触发"] -->|"SIGTERM"| H["GracefulStop"]
H --> I["SetDraining"]
I --> J["MigrateBatch"]
J --> K["BatchFlush"]
K --> L["进程退出"]
脚本热更走第一层(HTTP API),进程重启走第三层(drain + 迁移)。两者独立,互不影响。
踩坑:scriptMu 的粒度
最初 ReloadScriptWithVersion 用的是 RLock(读锁),想着”reload 只是改个指针,不需要写锁”。结果两个 reload 请求并发时,同时修改 scriptCurrent,数据竞争。
改成 Lock(写锁)后解决。CurrentScript 和 ScriptAuditLogs 用 RLock 就够了——它们只读不写。
热更新的核心不是”怎么替换脚本”,是”怎么安全地替换脚本”。版本管理、审计日志、优雅退役,三道防线缺一不可。