一、三种热更方式的边界
脚本热更解决的是“改逻辑不重启进程”,但不同承载方式对状态和故障的影响完全不同。选型时先问两个问题:脚本能否直接访问进程内存?脚本崩溃会不会带走整个服务?
| 方式 | 状态归属 | 隔离性 | 适用场景 | 主要风险 |
|---|---|---|---|---|
| 进程内 Lua/WASM | 脚本虚拟机与宿主进程共享生命周期 | 弱,panic 或死循环可能拖住宿主 | 活动、任务、掉落等高频小逻辑 | 状态泄漏、阻塞调用、版本混用 |
| 独立脚本进程 | 状态在脚本进程内,通过 RPC 访问 | 强,进程崩溃可重启 | 玩法副本、独立活动服 | 多一次网络跳转,状态与超时更复杂 |
| 配置驱动 | 状态仍由宿主管理,脚本只提供参数 | 最强,本质是数据更新 | 数值、开关、掉落表 | 表达力有限,复杂逻辑会变成配置地狱 |
进程内脚本的切换成本最低,但“旧版本立即释放”最危险:一个正在执行的请求还持有旧虚拟机的栈或闭包,卸载后继续访问就会崩溃。独立脚本进程可以用进程级隔离换掉这类问题,代价是请求要跨网络,超时、重试和状态一致性都要重新设计。配置驱动最安全,但它不适合承载带分支和副作用的玩法逻辑。
二、接口稳定性:脚本只能调用稳定 API
热更的前提是旧脚本和新脚本都能在同一个宿主上运行。宿主暴露给脚本的 API 必须版本化:新增字段可以,删除字段、改变参数含义、改变错误语义都不行。脚本侧只依赖稳定 API,宿主内部实现可以自由重构;一旦脚本直接读取宿主内部结构,热更就变成“连宿主一起换”。
稳定 API 至少要做到三点。第一,入口签名固定,例如“请求上下文 + 只读参数,返回结果或错误”。第二,宿主对象不把可变指针交给脚本,避免脚本缓存后在下一版本继续使用。第三,错误用稳定错误码表达,不要把宿主内部错误字符串泄露给脚本做分支判断。脚本升级和宿主升级可以独立进行,靠的就是这层契约。
三、panic、超时与隔离
脚本 panic 必须被宿主捕获。没有 recover 时,一个空指针或数组越界会沿着调用栈把整个服务进程带走;有 recover 时,宿主可以记录脚本版本、请求类型和堆栈,返回稳定错误,并决定是否熔断该版本。panic 之后不要假设脚本状态仍然一致,最稳妥的策略是把这次请求标记为失败,让上层重试或降级。
超时是协作式的。宿主可以给每次调用设置 deadline,但只有脚本主动检查 ctx.Done() 才能在截止时间返回;不检查的脚本会继续占用 goroutine,直到自己结束。因此脚本 API 必须把 context 传进去,并在循环、等待和批量处理的关键位置检查取消。宿主侧还要限制单次调用的 CPU 时间或指令数,WASM 可以用 fuel/epoch 中断,Lua 可以在调试钩子里检查配额。
四、热更时序:加载、校验、切换、排空、卸载
一次安全的脚本热更至少包含五步。第一步,加载新脚本并编译,编译失败直接拒绝,不影响当前版本。第二步,校验脚本来源、哈希和依赖,确保加载的是发布系统签发的版本。第三步,原子切换当前版本指针,新请求从这一刻开始使用新版本。第四步,让在途请求继续持有旧版本引用并完成执行。第五步,引用归零后卸载旧版本,释放虚拟机、闭包和缓存。
| 阶段 | 失败时的处理 | 不能做的事 |
|---|---|---|
| 加载 | 保留旧版本,记录编译错误 | 不要先停掉旧版本再编译 |
| 校验 | 拒绝新版本并告警 | 不要“先跑起来再补校验” |
| 切换 | 指针切换本身不应失败;失败则保持旧指针 | 不要在切换时清空玩家状态 |
| 排空 | 给在途请求设置最大等待时间 | 不要立即释放旧版本 |
| 卸载 | 引用归零后释放,记录卸载耗时 | 不要在仍有引用时关闭虚拟机 |
这里最容易被忽略的是“排空”。切换只改变新请求的去向,已经在执行的请求仍然需要旧版本。引用计数的作用不是统计有多少请求,而是回答“现在能不能安全卸载”。只要计数不为零,旧版本就必须保持可用。
五、失败场景:切换后立刻卸载旧版本
某次活动脚本更新,运维在管理后台点击“启用新版本”,宿主立即把旧版本对象置空并关闭 Lua 虚拟机。此时一个正在执行的活动结算请求还持有旧虚拟机的栈,继续访问闭包时触发非法内存访问,进程崩溃。重启后请求重放,又遇到同样的时序,形成反复崩溃。
这类问题的根因是把“切换”和“卸载”当成一个动作。正确顺序是:切换只改当前指针;旧版本进入 retired 状态;每个请求进入时增加引用,结束时减少引用;最后一个引用释放时才关闭虚拟机。管理后台的“启用”只负责切换,卸载由引用计数驱动,不由人工按钮驱动。
六、可运行实现:版本化 handler 与引用计数
下面的注册表把加载和激活分开:Load 只编译并登记版本,Activate 才改变新请求的去向。Invoke 在读取当前版本时持有读锁并增加引用,因此不会出现“刚拿到指针就被卸载”的窗口;Retire 把版本标记为退役,引用归零后关闭 Unloaded channel。call 还会 recover 脚本 panic,避免单个脚本错误拖垮宿主。
// file: ch17/hotswap/hotswap.go
// tier: L1
// verify: go test ./ch17/hotswap/
// Package hotswap 实现版本化 handler 的原子切换与引用计数卸载。
package hotswap
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
)
var (
ErrEmptyVersion = errors.New("hotswap: empty version")
ErrEmptyScript = errors.New("hotswap: empty script")
ErrDuplicateVersion = errors.New("hotswap: duplicate version")
ErrUnknownVersion = errors.New("hotswap: unknown version")
ErrNoActiveVersion = errors.New("hotswap: no active version")
ErrNoLoader = errors.New("hotswap: loader is nil")
ErrLoaderPanic = errors.New("hotswap: loader panic")
ErrScriptPanic = errors.New("hotswap: script panic")
ErrTimeout = errors.New("hotswap: script timeout")
)
// Request 是传给脚本的最小请求单元。Payload 由调用方保证只读,
// 脚本不得修改它,否则会影响同一请求的后续处理。
type Request struct {
Path string
Payload []byte
}
// Response 是脚本返回的结果。Version 字段由 handler 填写,方便
// 在灰度期间确认某次响应来自哪个版本。
type Response struct {
Version string
Body []byte
}
// Handler 是一个已加载的脚本版本。同一个 handler 会被多个请求
// 并发调用,脚本自身必须保证并发安全。
type Handler func(context.Context, Request) (Response, error)
// Loader 把脚本文本编译为 Handler。编译失败时不返回 Handler。
// Loader 内部 panic 会被捕获并转成 ErrLoaderPanic。
type Loader func(script []byte) (Handler, error)
// Version 是一个已加载但未必激活的脚本版本。
type Version struct {
ID string
Script []byte
handler Handler
loadedAt time.Time
refs atomic.Int64
retired atomic.Bool
unloadOnce sync.Once
unloaded chan struct{}
}
// Retired 表示该版本已不再接受新请求。
func (v *Version) Retired() bool {
return v.retired.Load()
}
// Unloaded 在版本正式卸载后关闭。retire 时若仍有在途请求,
// 该 channel 会等到最后一个请求释放引用才关闭。
func (v *Version) Unloaded() <-chan struct{} {
return v.unloaded
}
func (v *Version) finalize() {
v.unloadOnce.Do(func() {
close(v.unloaded)
})
}
// Option 调整 Registry 的行为。
type Option func(*Registry)
// WithTimeout 为每次脚本调用设置超时。超时是协作式的:handler 必须
// 观察 ctx.Done() 才能及时返回;不观察的 handler 仍会继续占用 goroutine。
func WithTimeout(d time.Duration) Option {
return func(r *Registry) {
if d > 0 {
r.timeout = d
}
}
}
// Registry 保存已加载的版本,并以原子方式切换当前版本。
type Registry struct {
mu sync.RWMutex
loader Loader
timeout time.Duration
current *Version
versions map[string]*Version
}
// New 创建注册表。loader 负责把脚本编译成 handler,不能为 nil。
func New(loader Loader, opts ...Option) *Registry {
r := &Registry{
loader: loader,
versions: make(map[string]*Version),
}
for _, opt := range opts {
opt(r)
}
return r
}
// Load 编译脚本并登记版本,但不会让它成为当前版本。
func (r *Registry) Load(id string, script []byte) (*Version, error) {
if id == "" {
return nil, ErrEmptyVersion
}
if len(script) == 0 {
return nil, ErrEmptyScript
}
if r.loader == nil {
return nil, ErrNoLoader
}
handler, err := safeLoad(r.loader, script)
if err != nil {
return nil, err
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.versions[id]; ok {
return nil, fmt.Errorf("%w: %s", ErrDuplicateVersion, id)
}
owned := append([]byte(nil), script...)
v := &Version{
ID: id,
Script: owned,
handler: handler,
loadedAt: time.Now(),
unloaded: make(chan struct{}),
}
r.versions[id] = v
return v, nil
}
// Activate 把已加载且未退役的版本设为当前版本。切换只改一个指针,
// 已经在执行的请求继续持有旧版本引用。
func (r *Registry) Activate(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
v, ok := r.versions[id]
if !ok {
return fmt.Errorf("%w: %s", ErrUnknownVersion, id)
}
if v.retired.Load() {
return fmt.Errorf("%w: retired %s", ErrUnknownVersion, id)
}
r.current = v
return nil
}
// Invoke 获取当前版本的一个引用并执行 handler。引用在请求结束时
// 释放;如果版本已退役且引用归零,则在这里完成卸载。
func (r *Registry) Invoke(ctx context.Context, req Request) (Response, error) {
r.mu.RLock()
v := r.current
if v == nil {
r.mu.RUnlock()
return Response{}, ErrNoActiveVersion
}
v.refs.Add(1)
r.mu.RUnlock()
defer r.release(v)
if r.timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, r.timeout)
defer cancel()
}
resp, err := call(ctx, v, req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return Response{}, fmt.Errorf("%w: %s", ErrTimeout, v.ID)
}
return Response{}, err
}
if r.timeout > 0 && ctx.Err() != nil {
return Response{}, fmt.Errorf("%w: %s", ErrTimeout, v.ID)
}
return resp, nil
}
// Retire 停止版本接收新请求。若没有在途请求,立即卸载;否则等待
// 最后一个请求释放引用。重复调用是幂等的。
func (r *Registry) Retire(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
v, ok := r.versions[id]
if !ok {
return fmt.Errorf("%w: %s", ErrUnknownVersion, id)
}
if v.retired.Swap(true) {
return nil
}
if r.current == v {
r.current = nil
}
if v.refs.Load() == 0 {
v.finalize()
}
return nil
}
// Current 返回当前激活版本的 ID。
func (r *Registry) Current() (string, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
if r.current == nil {
return "", false
}
return r.current.ID, true
}
// InFlight 返回指定版本的在途请求数。
func (r *Registry) InFlight(id string) int64 {
r.mu.RLock()
defer r.mu.RUnlock()
v, ok := r.versions[id]
if !ok {
return 0
}
return v.refs.Load()
}
func (r *Registry) release(v *Version) {
if v.refs.Add(-1) != 0 {
return
}
r.mu.Lock()
if v.retired.Load() {
v.finalize()
}
r.mu.Unlock()
}
func call(ctx context.Context, v *Version, req Request) (resp Response, err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("%w: %s: %v", ErrScriptPanic, v.ID, p)
}
}()
return v.handler(ctx, req)
}
func safeLoad(loader Loader, script []byte) (handler Handler, err error) {
defer func() {
if p := recover(); p != nil {
handler = nil
err = fmt.Errorf("%w: %v", ErrLoaderPanic, p)
}
}()
return loader(script)
}
实现里有三个关键点。第一,引用增加发生在读锁内,退役和卸载发生在写锁内,二者不会交错到“引用还没加、版本已经卸载”的状态。第二,Retire 是幂等的,重复调用不会重复关闭 channel;真正的关闭由 sync.Once 保证。第三,超时通过 context 注入,Invoke 在 handler 返回后还会检查一次 ctx.Err(),防止 handler 忽略取消却返回了成功结果。
测试覆盖四个时序:激活前调用返回“无活动版本”、切换后新请求使用新版本、在途请求阻塞时旧版本不卸载、在途请求结束后旧版本才卸载。另有 panic 隔离、协作式超时和并发切换的用例。运行方式:
cd .local/checks/gs-practice-lab
gofmt -l ch17/hotswap
go vet ./ch17/hotswap/
go test ./ch17/hotswap/ -count=1
七、和 17.3 的衔接
热更流程再严谨,也不能假设新版本永远正确。编译通过、测试通过、灰度指标正常,仍然可能在上线后暴露数据兼容或逻辑错误。下一篇 /game-server-in-action-17-3/ 会讨论回滚窗口、版本保留、数据向前兼容,以及如何用签名和保留策略防止降级攻击。
小结
服务端脚本热更的核心不是“动态加载”,而是“旧版本在安全时刻才消失”。接口稳定决定新旧版本能否共存,panic 隔离和超时决定单个脚本错误的影响范围,原子切换与引用计数决定在途请求会不会踩到已释放的虚拟机。把这三件事拆开实现、分别测试,热更才是一个可运维的能力,而不是一次冒险操作。
常见错误与边界
- 切换时清空玩家状态或缓存,新脚本虽然加载成功,玩家却丢失了进行中的任务和临时 buff。
- 脚本没有超时,一个死循环占满 P,其他房间的 tick 一起被拖慢;宿主还以为只是“脚本有点慢”。
- 旧版本立即释放,在途请求继续访问闭包或虚拟机栈,表现为随机崩溃,且难以稳定复现。
- 稳定 API 被脚本当成内部结构使用,宿主一次小重构就让所有旧脚本失效。
- 只校验脚本文本,不校验依赖和来源,热更加载了被替换的包,等于绕过了发布流程。
- 把热更当成免测试通道,跳过灰度直接全量,出问题时又没有可回滚的旧版本。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。