10.2 邮箱、顺序保证与背压
上一节把实体变成了「只收消息的循环」,代价是所有输入都汇进了一个队列。队列不可能无限长:一个被风筝的玩家、一个刷屏的聊天频道、一个卡住的寻路,都能在几秒内把某个实体的邮箱灌满。邮箱满了那一刻必须做决定——而「决定」正是这一节的全部内容。
三个问题要一起回答:邮箱多大(有界还是无界)、满了怎么办(拒绝策略)、以及一条链路上某个环节变慢时,压力怎么往回传。第三个问题是前两个的函数:只有当邮箱有界且阻塞,背压才会沿着调用链一路顶回源头。
本节给 aegis 写一个有界邮箱,实现四种拒绝策略,并真跑验证 FIFO 与背压传导。代码是 L1,go test 可直接跑。
有界邮箱与四种拒绝策略
邮箱本身很小,难点全在 Send 的分支上。下面这段是 L1,go test 可以直接跑:
// file: internal/actor/mailbox.go
// tier: L1
// verify: go test ./internal/actor/ -run 'TestBlockPolicyStallsSender|TestDropPolicyNeverStalls|TestDisconnectPolicySignalsOverflow|TestDegradeKeepsCritical'
package actor
import (
"errors"
"sync"
"sync/atomic"
)
// ErrClosed is returned when sending to a stopped mailbox.
var ErrClosed = errors.New("actor: mailbox closed")
// ErrMailboxFull is returned by the disconnect policy when the mailbox is full.
var ErrMailboxFull = errors.New("actor: mailbox full")
// RejectPolicy decides what a full mailbox does with one more message.
type RejectPolicy int
const (
// RejectBlock waits for room. This is backpressure: the sender stalls.
RejectBlock RejectPolicy = iota
// RejectDrop discards the message and counts it. The sender never stalls.
RejectDrop
// RejectDisconnect drops the message and asks for the link to be torn down.
RejectDisconnect
// RejectDegrade drops only non-critical messages; critical ones still wait.
RejectDegrade
)
// isCritical reports whether a command must survive degradation. Movement is
// droppable; entering the world and chat are not.
func isCritical(cmd uint16) bool {
switch cmd {
case CmdEnterWorldReq, CmdChatSend:
return true
default:
return false
}
}
// Mailbox is a bounded, single-consumer message queue. Any number of goroutines
// may call Send; exactly one goroutine owns the receive side via Recv.
type Mailbox struct {
ch chan Msg
quit chan struct{}
policy RejectPolicy
closed atomic.Bool
once sync.Once
drops atomic.Int64
discon atomic.Int64
}
// NewMailbox returns a mailbox with the given capacity and rejection policy.
func NewMailbox(capacity int, policy RejectPolicy) *Mailbox {
if capacity <= 0 {
capacity = 1
}
return &Mailbox{
ch: make(chan Msg, capacity),
quit: make(chan struct{}),
policy: policy,
}
}
// Cap returns the configured capacity.
func (m *Mailbox) Cap() int { return cap(m.ch) }
// Len returns the current queue depth.
func (m *Mailbox) Len() int { return len(m.ch) }
// Drops returns the number of messages rejected so far.
func (m *Mailbox) Drops() int64 { return m.drops.Load() }
// Disconnects returns how many times the disconnect policy tripped.
func (m *Mailbox) Disconnects() int64 { return m.discon.Load() }
// Send delivers one message under the mailbox's rejection policy.
func (m *Mailbox) Send(msg Msg) error {
if m.closed.Load() {
return ErrClosed
}
switch m.policy {
case RejectBlock:
select {
case m.ch <- msg:
return nil
case <-m.quit:
return ErrClosed
}
case RejectDrop:
select {
case m.ch <- msg:
return nil
default:
m.drops.Add(1)
return nil
}
case RejectDisconnect:
select {
case m.ch <- msg:
return nil
default:
m.drops.Add(1)
m.discon.Add(1)
return ErrMailboxFull
}
case RejectDegrade:
if isCritical(msg.Cmd) {
select {
case m.ch <- msg:
return nil
case <-m.quit:
return ErrClosed
}
}
select {
case m.ch <- msg:
return nil
default:
m.drops.Add(1)
return nil
}
}
return nil
}
// Recv takes the next message. ok is false only after Close and once the queue
// has been drained.
func (m *Mailbox) Recv() (msg Msg, ok bool) {
select {
case msg = <-m.ch:
return msg, true
case <-m.quit:
select {
case msg = <-m.ch:
return msg, true
default:
return Msg{}, false
}
}
}
// Close stops the mailbox. Idempotent. The receive channel is never closed, so
// a concurrent Send can never panic; it either lands in the buffer or sees quit.
func (m *Mailbox) Close() {
m.once.Do(func() {
m.closed.Store(true)
close(m.quit)
})
}
四种策略对应四种业务态度,不是四个技术选项:
| 策略 | 行为 | 适合谁 |
|---|---|---|
RejectBlock | 发送方等空位 | 关键实体、要求一条不丢;代价是压力回传(见下一节) |
RejectDrop | 直接丢并计数 | 可丢的位移/表现类消息,用 Drops() 报警 |
RejectDisconnect | 丢并返回 ErrMailboxFull | 网关判定「这个客户端已经跟不上了」,主动断开 |
RejectDegrade | 关键命令等位,非关键直接丢 | 大世界:CmdEnterWorldReq/CmdChatSend 保,CmdMoveReq 可丢 |
RejectDisconnect 的返回值不是错误处理,而是契约:网关拿到 ErrMailboxFull 就该走 7.2 的踢人路径,而不是重试。
顺序保证:同发送者到同接收者的 FIFO
「消息驱动」常被误解成「顺序无所谓」。恰恰相反,MMO 里顺序是硬需求:先 CmdEnterWorldReq 再 CmdMoveReq,先进入场景才能移动。弱化到可实现的形式,就是同发送者到同接收者的 FIFO——不同发送者之间允许交错,但任何一个发送者自己的消息必须按发出顺序被处理。
这个保证不来自邮箱,而来自两点:一是每个发送者在自己的 goroutine 里顺序调用 Send,二是邮箱本身是 FIFO 队列。测试用 4 个发送者各发 1000 条、序号递增,消费者逐条检查「同 From 的序号是否单调递增」:
// snippet: internal/actor/mailbox_test.go(节选)
// TestFIFOPerSender checks the ordering guarantee that matters: messages from
// the same sender to the same receiver arrive in the order they were sent,
// even while other senders interleave.
func TestFIFOPerSender(t *testing.T) {
mb := NewMailbox(4096, RejectBlock)
const senders, per = 4, 1000
var wg sync.WaitGroup
for s := 0; s < senders; s++ {
wg.Add(1)
go func(id EntityID) {
defer wg.Done()
for i := 0; i < per; i++ {
_ = mb.Send(Msg{Cmd: CmdMoveReq, Seq: uint32(i + 1), From: id})
}
}(EntityID(s + 1))
}
wg.Wait()
last := make(map[EntityID]uint32)
inversions := 0
for i := 0; i < senders*per; i++ {
msg, ok := mb.Recv()
if !ok {
t.Fatalf("mailbox closed after %d messages", i)
}
if prev, seen := last[msg.From]; seen && msg.Seq <= prev {
inversions++
}
last[msg.From] = msg.Seq
}
if inversions != 0 {
t.Fatalf("per-sender inversions = %d, want 0", inversions)
}
}
本机实测:4 发送者 × 1000 条,乱序数 0。注意这个保证的边界——它不承诺跨发送者的全序,也不承诺「消息进了邮箱就一定被处理」:RejectDrop 会打破后一条,所以任何依赖顺序的流程都必须跑在 RejectBlock 的邮箱上,或者自己带 Seq 做去重(第 11 章)。
背压如何在链路上传导
背压不是某个组件的能力,而是阻塞式 Send 在链路上的连锁反应。设想 A → B → C 三段,每段一个容量 1 的 RejectBlock 邮箱,C 的消费者被卡住(比如它在等一次慢查询)。C 的邮箱很快满 → B 转发给 C 的 Send 阻塞 → B 停止从自己邮箱取消息 → B 的邮箱满 → A 转发给 B 的 Send 阻塞 → A 的邮箱满 → 源头也被顶住了。
这条链上「在途消息数」有硬上界:cap(A) + cap(B) + cap(C) + 1(那个 1 是被 C 消费者取走、正卡在处理中的那条)。内存因此不会无限增长——这正是有界 + 阻塞相对无界队列的核心价值。
下面这段是 L1,go test 可以直接跑,它真起三段管道、真卡住尾巴,然后断言源头也被卡住:
// snippet: internal/actor/mailbox_test.go(节选)
// TestBackpressurePropagatesUpChain wires A -> B -> C with bounded mailboxes and
// a stalled tail. The head must stall too: that is how backpressure travels.
func TestBackpressurePropagatesUpChain(t *testing.T) {
a := NewMailbox(1, RejectBlock)
b := NewMailbox(1, RejectBlock)
c := NewMailbox(1, RejectBlock)
gate := make(chan struct{})
forward := func(from, to *Mailbox) {
go func() {
for {
msg, ok := from.Recv()
if !ok {
return
}
if err := to.Send(msg); err != nil {
return
}
}
}()
}
forward(a, b)
forward(b, c)
var applied atomic.Int64
go func() {
for {
_, ok := c.Recv()
if !ok {
return
}
<-gate // the tail is slow
applied.Add(1)
}
}()
var headSent atomic.Int64
go func() {
for i := 0; i < 1000; i++ {
if err := a.Send(Msg{Cmd: CmdMoveReq}); err != nil {
return
}
headSent.Add(1)
}
}()
time.Sleep(50 * time.Millisecond)
if got := headSent.Load(); got > 8 {
t.Fatalf("head sent %d while the tail was stalled; backpressure did not propagate", got)
}
// 放开 gate、等 1000 条全部落地、再关闭三段邮箱(断言略)。
}
本机实测(Go 1.26,macOS arm64,go test ./internal/actor/ -race -count=1):
| 断言 | 结果 |
|---|---|
| 尾巴卡住 50ms 内源头发出条数 | 4(等于上界 1+1+1+1,未超过阈值 8) |
| 放开尾巴后 1000 条全部落地 | tail applied 1000 |
RejectDrop 同场景 | 源头不阻塞,Drops() = 8(容量 2 收 10 条) |
-race | 无数据竞争,12 个测试全绿 |
两个反直觉的结论。其一,背压的代价是「一个慢实体拖慢它的所有上游」——在大世界里这是不可接受的,所以真实系统会在链路上加「熔断点」(例如网关对单个玩家的发送做丢弃或降级),让慢玩家只影响自己。其二,丢弃策略不是「更差」,而是另一种正确性:位移消息丢一条、下一条补上位置,玩家几乎无感;而 CmdEnterWorldReq 丢一条就是进不去游戏。所以四种策略往往同时存在于一个进程里,按消息类型选,而不是按邮箱选。
本节小结
- 邮箱必须有界;无界队列只是把内存问题推迟到更晚爆发。
- 四种拒绝策略对应四种业务态度:阻塞(不丢,压力回传)、丢弃(可丢,靠计数报警)、断开(跟不上就踢)、降级(关键的保、非关键的丢)。
- FIFO 只保证到「同发送者到同接收者」;跨发送者无全序,且丢弃策略会打破它,需要顺序的流程要自己带
Seq。 - 背压沿
阻塞式 Send的链路一路回传,在途消息数有硬上界Σcap + 1;代价是慢实体拖慢上游,需要熔断点。
下一节把两个世界放在一起:Go 的 channel 邮箱,与 Erlang/Skynet 那种「无界队列 + 单线程消费」的 Actor,到底差在哪。
阅读导航:上一节:10.1 消息驱动的实体 · 下一节:10.3 对照:Go channel 邮箱与 Erlang/Skynet 式 Actor 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。