为什么我们的 SLG 后端最终选了 Actor:goroutine 共享内存的 3 个血泪坑
我们是一个 SLG 游戏后端团队。SLG 的特点大家应该都懂:一个服里几十万玩家,每个玩家有城堡、部队、联盟数据;同一份玩家数据,同时被玩家请求、定时任务、世界地图行军、跨服活动、GM 命令五个来源读写。最初我们选择 Go,就是冲着 goroutine 和 channel 的并发模型去的。
一年后,我们在一场线上事故复盘会上达成了一个共识:问题从来不是"goroutine 用错了",而是"多 goroutine 共享同一份状态"这件事本身。
这篇文章不讲 actor 理论,只讲把我们逼到 actor 的三个血泪坑,以及我们最后落地的 80 行核心实现。文末有彩蛋:坑不会消失,只会换一副面孔。
背景:我们的并发源长什么样
先看一张图(文字版):玩家的 PlayerData 位于正中,四周有五个来源在并发读写它:
- 玩家请求 goroutine(升级建筑、出征、消费资源)
- 定时任务 goroutine(资源产出结算、在线奖励)
- 世界地图行军结算(跨模块回调)
- 跨服活动同步(KVK / 世界 BOSS 战报)
- GM 命令(发资源、踢人)
所有模块都要碰 PlayerData。最初的架构很简单粗暴:谁用谁加锁。

血泪坑 1:数据竞争------"加锁"听起来简单,做对很难
我们第一个线上事故,是一个"看起来完全没问题"的定时推送任务。
go
// 定时任务:每 5 秒把玩家资源推给前端
func (s *Server) PushLoop() {
for range time.Tick(5 * time.Second) {
for _, p := range s.players {
// 只是"读",觉得不用加锁?
client.Send(p.ID, p.Wood, p.Food)
}
}
}
读为什么也要加锁?因为 p.Wood 此刻可能正被另一个 goroutine 写:
go
// 玩家请求:升级建筑扣资源
func (s *Server) UpgradeBuilding(pid int64, cost int64) {
p := s.players[pid]
p.mu.Lock()
if p.Wood < cost {
p.mu.Unlock()
return
}
p.Wood -= cost
p.mu.Unlock() // 解锁了!
// 解锁之后,p.Wood 又在本 goroutine 里被读、被写日志......
}
一次读不加锁,一次写"解锁后还碰数据",race 就出现了。线上症状是:极少数玩家资源数量偶尔对不上账,回档后恢复,过几天又来。单测永远不挂,压测 100% 复现不了,只有高峰期偶发。
最后靠 go run -race 全量压测 + 抓了好几天 goroutine 堆栈才定位到。这个 bug 从出现到修复,前后耗掉两个同事一周。
代价不只是脏数据,是"不可复现"四个字带来的恐惧:你永远不知道线上还有多少个这样的角落。
血泪坑 2:锁的放大与死锁------锁是会传染的
第二个坑更致命。随着模块变多,玩家操作开始跨模块调用:升级建筑要加联盟贡献、要写排行榜、要同步到拍卖行。于是锁开始"传染"。
go
// goroutine A:玩家模块
p.mu.Lock()
rank.AddScore(p.ID, 10) // 内部会 lock rank.mu
p.mu.Unlock()
// goroutine B:排行榜模块
rank.mu.Lock()
player.SyncFromRank(p.ID) // 内部会 lock p.mu ------ 死锁!
rank.mu.Unlock()
经典的 AB-BA 死锁:A 持 p.mu 等 rank.mu,B 持 rank.mu 等 p.mu。线上表现是全服部分玩家操作卡死,CPU 不涨、连接不断,就是没响应。
排查方式是 dump 全部 goroutine,看谁在等谁:
css
goroutine 1201 [semacquire]:
sync.runtime_SemacquireMutex(0xc000...)
sync.(*Mutex).Lock(...)
server.(*PlayerService).SyncFromRank(...)
server.(*RankService).AddScore.func1(...)
理顺几十把锁的获取顺序,比写业务代码难得多。我们试过"全局统一锁顺序"规范,写了 20 多页文档,新同学照样踩。锁的数量随模块数量平方级增长,心智负担根本守不住。
血泪坑 3:并发不可测------偶发 bug 是团队最大的隐性成本
第三个坑不是某一次事故,而是常态:并发代码的单测覆盖率是假的。
- race 和死锁只在特定时序出现,单测覆盖不到,只能靠压测和线上环境"撞"出来;
- 老手加锁没错,新手改一行"看似无关"的代码就可能引入概率性问题;
- 每次版本发布前,都要安排一次"全服人肉压测",因为自动化根本给不了安全感。
我们最终意识到:继续在"共享 + 锁"的框架里修修补补,只是在给一座地基歪了的楼加固。 真正该做的是消灭"共享"本身。
转折:为什么选 Actor,而不是"更细的锁"或"无锁队列"
当时我们认真评估过三条路:
| 方案 | 优点 | 我们否决的原因 |
|---|---|---|
| 更细粒度的锁 / 读写锁 | 改动小 | 锁顺序问题依然存在,复杂度转移 |
| 无锁队列 + CAS | 吞吐高 | 正确性极难保证,团队没人 hold 住 |
| Actor 模型 | 状态单一归属、消息驱动、串行处理、零共享 | ------ |
Actor 的核心思想其实一句话:一份状态只允许一个 goroutine 碰,其他 goroutine 想操作它,只能通过"投递消息"排队进来。
这恰好是 Go 的主场:goroutine 轻量,channel 天然就是 mailbox。我们不需要引第三方 actor 框架,用原生 goroutine + channel 就能实现一个够用的 ActorSystem。
关键洞察是:我们不是要消灭 goroutine,是要消灭"多 goroutine 共享同一份内存"。 每个玩家实体变成一个小 actor,它的数据只在它自己的 goroutine 里被访问------锁,一整个从玩家模块消失了。
我们的 ActorSystem:80 行核心实现
先看骨架(生产级代码,已简化异常处理和日志):
go
type PlayerActor struct {
data *PlayerData // 只允许 loop goroutine 访问,无锁!
mailbox chan func() // FIFO 消息队列
quit chan struct{}
wg sync.WaitGroup
once sync.Once
}
func NewPlayerActor(p *PlayerData, bufSize int) *PlayerActor {
a := &PlayerActor{
data: p,
mailbox: make(chan func(), bufSize),
quit: make(chan struct{}),
}
a.wg.Add(1)
go a.loop()
return a
}
// loop:唯一持有 data 的 goroutine,串行执行所有消息
func (a *PlayerActor) loop() {
defer a.wg.Done()
for {
select {
case fn := <-a.mailbox:
fn() // 在这里可以放心读写 a.data
case <-a.quit:
return
}
}
}
// Send:投递一个操作,非阻塞(带退出保护)
func (a *PlayerActor) Send(fn func()) {
select {
case a.mailbox <- fn:
case <-a.quit:
}
}
// Ask:同步等待结果(外部 goroutine 用),带超时
func (a *PlayerActor) Ask(fn func()) error {
done := make(chan struct{})
a.Send(func() {
defer close(done)
fn()
})
select {
case <-done:
return nil
case <-time.After(5 * time.Second):
return ErrActorTimeout
}
}
// Stop:优雅停机
// 注意:生产环境需要先拒绝新消息、再 drain 完 mailbox 存量消息
func (a *PlayerActor) Stop() {
a.once.Do(func() { close(a.quit) })
a.wg.Wait()
}
用法示例------升级建筑,PlayerData 里一个 sync.Mutex 都没有了:
go
func (s *Server) UpgradeBuilding(pid int64) error {
actor := s.actors[pid] // 玩家 actor 常驻内存,按 pid 索引
return actor.Ask(func() {
p := actor.data
if p.Wood < cost {
return
}
p.Wood -= cost
p.Level++
// 写日志、推客户端、触发任务......全在这里,天然串行
})
}
三个血泪坑是怎么消失的:
- 坑 1(数据竞争) :
data只有一个 goroutine 碰,读就是写,写就是读,没有"两个 goroutine 同时碰"这回事; - 坑 2(死锁):actor 内部没有锁,跨模块调用变成"投递消息"------消息只是排队,不会持有任何锁去等别人;
- 坑 3(不可测):单测只需要驱动 actor 按顺序吃消息,时序完全可控,概率性 bug 从源头消失。
落地后的收益,和换了副面孔的坑
先说收益(定性,量化数据见文末占位):
- 玩家模块的
sync.Mutex全部删除,代码可读性显著提升; - race / 死锁类线上事故归零,发布前不再需要全服人肉压测;
- 新同学理解"数据属于哪个 actor"就能上手,20 页锁顺序文档直接废弃。
但 actor 不是银弹,我们立刻踩到它的新坑,下一篇会展开:
- Actor 内做了阻塞 IO(比如同步查 Redis、调外部服务):整个玩家 goroutine 卡死,所有操作排队等待------比锁死锁更隐蔽;
- 背压 :mailbox 塞满后
Send的取舍(丢弃 / 阻塞 / 拒绝连接)必须显式设计; - 跨节点 actor:玩家在 A 服、活动在 B 服,消息怎么路由、重放、超时。
我们当前的解法是"全局协程池分流阻塞 IO":actor 的 goroutine 只跑纯内存逻辑,凡是要阻塞 IO 的活,投递给一个固定容量的全局协程池执行,用 request-id 把结果回投给 actor。
总结:什么时候该用 Actor
一句话决策标准:
- 该用:大量实体(玩家、怪物、建筑)各自持有状态、且被多个并发源访问------游戏后端是典型场景;
- 慎用:强一致性的跨实体事务(如转账扣双份)、超低延迟的纯计算路径------actor 的串行化和消息投递会放大这类场景的延迟;
- 记住 :actor 解决的是"共享",没解决"阻塞"。Actor 内禁止阻塞 IO,这是新人最容易踩的雷。
下一篇预告:《玩家 Actor 的生命周期与阻塞 IO:全局协程池分流实战》,讲 actor 的上线创建/下线销毁、优雅停机,以及怎么用协程池把"换了个姿势的坑"填平。
js
<html style="margin:0;padding:0;"> <div style="background-color:transparent;box-sizing:border-box;font-family:'Roboto','PingFang SC','Segoe UI',Arial,sans-serif;color:#1A1B1C;"> <div style="max-width:840px;margin:0 auto;box-sizing:border-box;padding:4px 2px;"> <div style="font-size:15px;font-weight:600;margin:0 0 2px;">阻塞 IO 分流:协程池干活,结果回投 actor(示意图)</div> <div style="font-size:12px;color:#6B7280;margin:0 0 12px;">核心纪律:actor 只跑纯内存逻辑,永远不在 actor 内阻塞、不在 actor 内开 goroutine</div> <svg viewBox="0 0 780 260" width="100%" style="display:block;max-width:780px;margin:0 auto;"> <defs> <marker id="arrB" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto"> <path d="M0,0 L7,3 L0,6 Z" fill="#3D6E8F"/> </marker> </defs> <!-- 1. 来源 → actor --> <rect x="18" y="30" width="112" height="42" rx="9" fill="#fff" stroke="#6B7280" stroke-width="1.2"/> <text x="74" y="48" text-anchor="middle" font-size="11.5" fill="#1A1B1C">玩家请求</text> <text x="74" y="63" text-anchor="middle" font-size="11.5" fill="#1A1B1C">/ 定时任务</text> <line x1="74" y1="72" x2="112" y2="102" stroke="#3D6E8F" stroke-width="1.6" marker-end="url(#arrB)"/> <text x="60" y="92" font-size="10.5" fill="#6B7280">Send 消息</text> <!-- 2. actor --> <circle cx="170" cy="150" r="62" fill="rgba(163,213,232,0.16)" stroke="#3D7A9E" stroke-width="1.8"/> <text x="170" y="138" text-anchor="middle" font-size="13" font-weight="600" fill="#1A1B1C">PlayerActor</text> <text x="170" y="155" text-anchor="middle" font-size="11" fill="#3D6E8F">单 goroutine</text> <text x="170" y="170" text-anchor="middle" font-size="11" fill="#1A1B1C">data 独占无锁</text> <text x="170" y="88" text-anchor="middle" font-size="10.5" fill="#3D6E8F">纯内存,永不阻塞</text> <!-- 3. actor → 协程池 --> <line x1="232" y1="115" x2="330" y2="95" stroke="#3D6E8F" stroke-width="1.6" marker-end="url(#arrB)"/> <text x="268" y="100" font-size="10.5" fill="#6B7280">DoBlocking 投递</text> <!-- 4. 全局协程池 --> <rect x="330" y="58" width="168" height="180" rx="14" fill="rgba(158,172,234,0.12)" stroke="#7B88C9" stroke-width="1.6"/> <text x="414" y="80" text-anchor="middle" font-size="12.5" font-weight="600" fill="#1A1B1C">全局协程池</text> <text x="414" y="95" text-anchor="middle" font-size="10.5" fill="#6B7280">固定 worker × N</text> <rect x="348" y="104" width="132" height="34" rx="8" fill="#fff" stroke="#9EACEA" stroke-width="1.2"/> <text x="414" y="126" text-anchor="middle" font-size="11" fill="#1A1B1C">worker 1</text> <rect x="348" y="146" width="132" height="34" rx="8" fill="#fff" stroke="#9EACEA" stroke-width="1.2"/> <text x="414" y="168" text-anchor="middle" font-size="11" fill="#1A1B1C">worker 2</text> <rect x="348" y="188" width="132" height="34" rx="8" fill="#fff" stroke="#9EACEA" stroke-width="1.2"/> <text x="414" y="210" text-anchor="middle" font-size="11" fill="#6B7280">worker ...</text> <text x="414" y="230" text-anchor="middle" font-size="10.5" fill="#6B7280">在这里可放心阻塞</text> <!-- 5. 协程池 → DB --> <line x1="498" y1="140" x2="608" y2="140" stroke="#3D6E8F" stroke-width="1.6" marker-end="url(#arrB)"/> <text x="553" y="130" text-anchor="middle" font-size="10.5" fill="#6B7280">查 DB / Redis</text> <rect x="608" y="116" width="150" height="48" rx="9" fill="rgba(225,185,143,0.20)" stroke="#C08A4E" stroke-width="1.4"/> <text x="683" y="136" text-anchor="middle" font-size="12" fill="#1A1B1C">MySQL / Redis</text> <text x="683" y="152" text-anchor="middle" font-size="10.5" fill="#8A5A2E">允许阻塞的外部依赖</text> <!-- 6. 结果回投 --> <line x1="420" y1="238" x2="210" y2="205" stroke="#3D6E8F" stroke-width="1.6" marker-end="url(#arrB)"/> <text x="330" y="246" text-anchor="middle" font-size="10.5" fill="#6B7280">结果回投 a.Send → onDone 仍在 actor goroutine 内执行</text> </svg> <div style="margin-top:10px;padding:10px 12px;border-radius:12px;background:linear-gradient(135deg, rgba(163,213,232,0.18), rgba(163,213,232,0.06));font-size:13px;line-height:1.6;box-sizing:border-box;"> <b>一句话</b>:阻塞发生在池子里,actor 只是"等一条回投消息",等待期间其他操作照常串行------这就是"actor 消灭锁但不消灭阻塞"的正确姿势。 </div> </div> </div> </html>