gopher-lua:脚本引擎的集成、并发和热更新
slg-go 用 gopher-lua 做了两件事:战力计算和等级计算。目前还没有实现运行时热更新——改脚本还是得重启服务。这篇文章记录我们怎么集成 gopher-lua、踩了哪些坑、以及热更新要做的话该怎么设计。
为什么选 Lua?
| 语言 | 嵌入难度 | 性能 | 热更新潜力 | 游戏行业生态 |
|---|---|---|---|---|
| Lua | 低(gopher-lua) | 中 | 原生支持 | 标准 |
| JavaScript | 中(goja) | 中高 | 需额外机制 | 非主流 |
| Python | 高(嵌入 CPython) | 低 | 受 GIL 限制 | 非主流 |
Lua 天生为嵌入设计,gopher-lua 是纯 Go 实现,不依赖 CGO,交叉编译无障碍。go.mod 里直接 go get github.com/yuin/gopher-lua 就行。
实际的集成方式
项目的 Lua 集成集中在两个计算器:player_power_lua.go 和 player_level_lua.go。结构一样,以战力计算器为例:
1 | // internal/logic/app/player_power_lua.go |
初始化时用 sync.Once 加载脚本内容到内存,后续不再读文件:
1 | func NewLuaPlayerPowerCalculator(scriptDir string) *LuaPlayerPowerCalculator { |
LState 不是线程安全的——用 sync.Pool 解决
这是踩的第一个坑。gopher-lua 的 LState 不是并发安全的,一个 LState 同一时刻只能被一个 goroutine 使用。
最开始想过加 sync.RWMutex,RLock 让多个请求同时读。但 LState 不支持并发读——10 个 goroutine 同时 RLock 成功,然后同时操作同一个 LState,直接 panic。
最终方案是 sync.Pool:
1 | func (c *LuaPlayerPowerCalculator) acquireState() *lua.LState { |
每次调用从池里取一个 LState,用完放回去。多个请求并发时各自用各自的 LState,互不干扰。Pool 为空时 New 函数自动创建新实例。
计算流程
1 | func (c *LuaPlayerPowerCalculator) CalcPlayerPower(...) (int64, error) { |
注意是 DoString 不是 DoFile。脚本内容在 sync.Once 时已经读到内存,每次调用直接执行字符串,省掉了重复的文件 IO。
优先用 Rust,Lua 做兜底
项目的战力计算有个有趣的架构——优先用 Rust(通过 CGO 调用),Rust 不可用时 fallback 到 Lua:
1 | // internal/logic/app/game_app.go |
Rust 版本性能更好(gopher-lua 约是 C Lua 的 1/10~1/5),Lua 版本是兜底方案。这种双引擎设计和 game-bass 的 ELO 计算类似——Go 内置兜底,脚本引擎做灵活扩展。
热更新:还没做,但可以聊聊怎么设计
目前改脚本需要重启服务。对于战力/等级计算这种低频场景,重启是可以接受的。但如果未来要把更多逻辑放到 Lua(比如战斗公式、建筑规则),就需要热更新了。
热更新面临的问题
问题 1:LState 的并发安全
热更新的瞬间可能有请求正在用旧 LState。直接 Close 会崩。需要一个机制让旧实例”优雅退役”——等正在执行的请求跑完再销毁。
设计思路:VM 池 + 引用计数。
1 | type LuaPool struct { |
每次 acquire 时 refCnt+1,release 时 refCnt-1。Reload 时 atomic.Swap 切换到新池,旧池标记 closed,等 refCnt 归零后 Close 所有 VM。
问题 2:脚本里的全局状态
Lua 脚本里的模块级变量(local counter = 0)热更新后会重置。如果某个逻辑依赖这个计数器,更新后会出 Bug。
解决方案:Lua 只放无状态的计算逻辑,有状态的数据留在 Go 端管理。
1 | Lua 负责的(无状态,安全热更新): |
问题 3:脚本语法错误
热更新时新脚本如果有语法错误,直接加载会炸。需要先在临时 VM 里验证语法,通过后再替换。
1 | func validateScript(script string) error { |
如果要做,设计大概是这样
1 | 1. /api/admin/reload 接口接收新脚本 |
目前还没做,因为战力/等级计算的改动频率不高,重启成本可接受。但如果将来把战斗公式也放 Lua,这就是必须解决的问题。
性能考量
gopher-lua 是纯 Go 实现的 Lua 5.1 解释器,性能约为 C Lua 的 1/10~1/5。对战力/等级计算来说足够——每秒最多几十次调用,单次计算几十行代码,瓶颈在数据库 IO 不在脚本计算。
如果未来性能不够:
- 热点函数用 Go/Rust 实现,Lua 只做参数调整
- 缓存计算结果
- 升级到 LuaJIT(需要 CGO,和交叉编译冲突)
目前 Lua 在项目里是”锦上添花”的角色——Rust 做主力,Lua 做兜底。这个定位决定了热更新暂时不是刚需。但随着脚本化的逻辑越来越多,热更新迟早要做。
更新记录
2026-06-04:战斗脚本热更新已落地,详见 从重启到热更:战斗脚本在线更新的三道防线。
目前还没有实现运行时热更新——改脚本还是得重启服务(热更新已实现,支持版本管理、审计日志、一键回滚)热更新:还没做,但可以聊聊怎么设计(实际实现见新博文,HTTP API + scriptMu 写锁保护)(实际接口为/api/admin/reload接口POST /battle/script/reload,由 Scheduler 注册)VM 池 + 引用计数的设计思路(实际方案更简洁:scriptMu 写锁保护 scriptCurrent 指针切换,LState 池用 sync.Pool 复用)