《游戏服务端编程实践》17.2 服务端脚本热更(Lua/WASM)

从进程内 Lua/WASM、独立脚本进程和配置驱动三种服务端脚本热更方式切入,比较状态、隔离与故障边界,说明稳定 API、panic 隔离、协作式超时、加载校验、原子切换与引用计数卸载的完整流程,并给出一个版本化 handler 注册表实现,测试在途请求使用旧版本、新请求使用新版本以及旧版本最后卸载。

一、三种热更方式的边界

脚本热更解决的是“改逻辑不重启进程”,但不同承载方式对状态和故障的影响完全不同。选型时先问两个问题:脚本能否直接访问进程内存?脚本崩溃会不会带走整个服务?

方式状态归属隔离性适用场景主要风险
进程内 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 被脚本当成内部结构使用,宿主一次小重构就让所有旧脚本失效。
  • 只校验脚本文本,不校验依赖和来源,热更加载了被替换的包,等于绕过了发布流程。
  • 把热更当成免测试通道,跳过灰度直接全量,出问题时又没有可回滚的旧版本。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist