《MMO 服务器开发》10.2 邮箱、顺序保证与背压

消息驱动把锁消掉了,却把问题推给了邮箱:满了到底怎么办?本节给 aegis 写一个有界邮箱,实现阻塞、丢弃、断开、降级四种拒绝策略,用 4 发送者 × 1000 条真跑证明同发送者到同接收者的 FIFO,并用一条 A→B→C 的链路实测背压如何一路顶回源头。

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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标