上一篇写 gopher-lua 集成时,热更新还停留在”设计思路”阶段——改脚本得重启服务。现在 slg-go 的战斗脚本热更新已经落地了:一个 HTTP POST 就能把新 Lua 脚本推到线上,带版本号、审计日志、一键回滚。

但上线过程踩了三个坑,每个都差点翻车。

第一道防线:版本管理 + 审计日志

热更新最怕的不是”更新失败”,是”更新了但不知道什么时候更新的、谁更新的、能不能回滚”。

BattleEngine 用一个 sync.RWMutex 保护脚本状态,每次 reload 都记录审计日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// internal/battle/engine/engine.go
func (e *BattleEngine) ReloadScriptWithVersion(scriptPath string, version string, operator string) (*ScriptAuditLog, error) {
e.scriptMu.Lock()
defer e.scriptMu.Unlock()

// 版本号自动生成(毫秒时间戳)
if version == "" {
version = fmt.Sprintf("v%d", time.Now().UnixMilli())
}
if version == e.scriptCurrent.Version {
return nil, fmt.Errorf("script version already active: %s", version)
}

previous := e.scriptCurrent.Version
e.scriptCurrent = ScriptInfo{
Version: version,
ScriptDir: targetDir,
UpdatedAt: time.Now(),
}
e.scriptVersions[version] = targetDir

log := ScriptAuditLog{
Action: "reload",
Version: version,
PreviousVersion: previous,
ScriptDir: targetDir,
Operator: operator,
}
e.scriptAudits = append(e.scriptAudits, log)
return &log, nil
}

关键设计点:

  • 版本号不能重复:如果传入的 version 和当前一样,直接拒绝
  • operator 字段:记录是谁触发的更新(运维/CI/API)
  • PreviousVersion:审计日志链,可以追溯每次变更

回滚也很简单——RollbackScript(version, operator) 从 scriptVersions 字典里找到对应目录,重新加载:

1
2
3
4
5
6
7
8
9
10
func (e *BattleEngine) RollbackScript(version string, operator string) (*ScriptAuditLog, error) {
e.scriptMu.Lock()
defer e.scriptMu.Unlock()

targetDir, ok := e.scriptVersions[version]
if !ok {
return nil, fmt.Errorf("script version not found: %s", version)
}
// ... 验证目录存在,更新 scriptCurrent,记录审计日志
}

第二道防线:HTTP API 暴露

热更新不能只给内部用,需要一个安全的 HTTP 接口让运维/CI 调用。Scheduler 注册了两个端点:

1
2
3
// internal/battle/scheduler/scheduler.go
mux.HandleFunc("/battle/script/reload", s.handleScriptReload)
mux.HandleFunc("/battle/script/rollback", s.handleScriptRollback)

handleScriptReload 接收 JSON 请求体:

1
2
3
4
5
type scriptReloadRequest struct {
ScriptDir string `json:"script_dir"` // 新脚本目录
Version string `json:"version"` // 版本号(可选)
Operator string `json:"operator"` // 操作人
}

调用方式:

1
2
3
4
5
6
7
# 热更新到新脚本目录
curl -X POST http://localhost:8080/battle/script/reload \
-d '{"script_dir":"/opt/scripts/v2","version":"v2.0","operator":"ci-pipeline"}'

# 回滚到上一个版本
curl -X POST http://localhost:8080/battle/script/rollback \
-d '{"version":"v1.0","operator":"ops-rollback"}'

响应包含当前版本信息和完整的审计日志。

第三道防线:进程级 Drain + 迁移

战斗脚本热更不需要停进程,但如果 Logic 服务本身要重启(比如 Go 代码变更),就需要优雅退役——先把玩家迁移到新实例,再停旧进程。

Logic 服务的关闭流程分五步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// cmd/logic/main.go — 信号触发后的清理流程
// Step 1: 停止接受新请求
grpcServer.GracefulStop() // 5 秒超时后强制 Stop()

// Step 2: 标记为 draining,从 Nacos 注册表移除
lifecycle.SetDraining(true)
logicmetrics.SetReady(false)
time.Sleep(5 * time.Second) // 等待负载均衡器摘除

// Step 3: 玩家迁移
targetStateID := cfg.Server.StateID + 1
migrated, err := playerSvc.MigrateBatch(migrateCtx, targetStateID, 0, transferClient)

// Step 4: 刷脏数据
flushed, failedIDs := playerSvc.BatchFlush(context.Background())

// Step 5: 关闭 Redis 连接等资源

迁移目标通过环境变量 MIGRATE_TARGET_STATE_ID 指定,默认是 StateID + 1。超时时间通过 DRAIN_TIMEOUT_SEC 控制,默认 30 秒。

三道防线的协作关系

脚本热更走第一层(HTTP API),进程重启走第三层(drain + 迁移)。两者独立,互不影响。

踩坑:scriptMu 的粒度

最初 ReloadScriptWithVersion 用的是 RLock(读锁),想着”reload 只是改个指针,不需要写锁”。结果两个 reload 请求并发时,同时修改 scriptCurrent,数据竞争。

改成 Lock(写锁)后解决。CurrentScript 和 ScriptAuditLogs 用 RLock 就够了——它们只读不写。

热更新的核心不是”怎么替换脚本”,是”怎么安全地替换脚本”。版本管理、审计日志、优雅退役,三道防线缺一不可。