《MMO 服务器开发》6.1 指令投递与执行队列

客户端在多个连接线程里收包,战斗却必须在一个场景线程里按固定顺序结算。本节从零写 aegis 的按帧指令队列:每实体单调序号怎么挡住重试与乱序、排空顺序为什么必须确定、每帧预算怎么把突发流量摊平,并用真跑的 L1 测试与本机实测的投递成本把这几条边界钉死。

6.1 指令投递与执行队列

战斗管线的第一段不是伤害公式,而是指令怎么从连接线程走到场景线程。卷一的网关层已经把「一个客户端一条连接、一个连接一个 goroutine」讲完了,那些 goroutine 的工作是收包、解码、路由;它们并发运行,谁先谁后由调度器说了算。但战斗结算不能接受这种不确定性:两个玩家同一帧互相释放技能,谁先掉血、谁先死、掉落算谁的,答案必须每次都一样。

于是就有了这一节的边界:收包在 N 个 goroutine 里并行,执行在 1 个场景循环里串行。两者之间需要一条队列,把并发的、可能乱序的、可能重发的客户端意图,收敛成一条确定的、每帧有预算的执行序列。

这条队列要同时满足三件互相拉扯的事:去重(客户端重试和网关重发会让同一条命令出现两次)、保序(同一玩家的移动和施法不能颠倒)、确定(两个进程吃同样的输入,必须产出同样的执行顺序,否则回放、断线重连、跨服对账全都对不上)。本节就把这条队列从零写出来。

与站内实战文章的分工

站内 game/server/practice/ 专题里有几篇直接讲战斗管线的文章,它们负责「线上怎么设计、怎么取舍、怎么排障」,本节负责「从零写出可运行的那一层」。三者的分工如下:

站内既有文章它负责的问题本节负责的部分
/game-server-combat-command-pipeline-architecture/战斗指令从网关到场景的整条管线的分层与背压只把「投递 + 排空」这一个数据结构从零写出来
/game-server-real-time-battle-architecture-design/实时战斗的帧模型、广播与状态同步的整体取舍不做广播,只保证输入侧的确定顺序
/battle-settlement-service-prevent-duplicate-rewards/结算侧如何用状态机与流水避免重复发奖队列只做第一道去重,持久化唯一约束见 6.3

一句话:要看架构取舍去读那三篇,要一份能 go test 通过的队列实现读本节。

收包线程为什么不能直接打伤害

最省事的写法是在收包 goroutine 里直接改血量。它在单机测试里能过,上线后一定炸,原因有三个。

第一,数据竞争。血量、Buff 表、冷却计时都是场景共享状态,多个连接 goroutine 同时读写就是数据竞争,go test -race 会直接报错。你可以加锁,但加锁之后每次伤害计算都要持一把全局锁,延迟曲线立刻变差,而且锁的获取顺序不确定——先拿到锁的那个玩家先掉血,顺序还是随机的。

第二,顺序不可控。玩家 A 在同一帧发出「移动」和「施法」,这两个包来自同一个 TCP 连接,顺序是确定的;但玩家 B 的包来自另一条连接,A 和 B 之间谁先,取决于两个 goroutine 被唤醒的顺序。同一个技能命中判定,这次 A 先死,下次 B 先死,回放对不上。

第三,重发没有地方挡。卷一的幂等窗口是网络层的兜底,但队列本身就是最便宜的第一道闸:只要按每实体单调序号过滤,重试和乱序包在进入管线之前就被丢掉了,根本不用惊动结算逻辑。

所以正确的形状是:连接 goroutine 只做一件事——把解出来的命令塞进队列,然后立刻返回去收下一个包;场景循环每帧从队列里排空一批命令,按确定的顺序执行。队列是两者之间唯一的交接面。

从零写一个按帧排空的指令队列

下面这段是 L1,go test 可以直接跑。它给出完整可编译的 command.go:每实体一条 FIFO,Submit 用单调序号挡住重试,DrainTick 按「实体 id 升序 + 实体内部提交顺序」排空,并支持每帧预算。

// file: internal/combat/command.go
// tier: L1
// verify: go test ./internal/combat/ -run 'TestQueue' -v

package combat

import (
	"sort"
	"sync"
)

// EntityID identifies a runtime entity inside one scene/instance. The high 16
// bits carry the scene/instance number and the low 48 bits an in-scene serial;
// the layout itself is not used here, only the total order it gives us.
type EntityID uint64

// Combat-domain command ids, shared by every chapter of volume 2. They mirror
// the wire command numbers, so a decoded frame maps straight onto a Command.
const (
	CmdSkillCastReq  uint16 = 0x0401
	CmdSkillCastResp uint16 = 0x0402
	CmdDamageNotify  uint16 = 0x0403
	CmdBuffSync      uint16 = 0x0404
)

// Command is one gameplay command a client asked for, already decoded from the
// wire and waiting for the scene loop to execute it.
type Command struct {
	Entity  EntityID
	Cmd     uint16
	Seq     uint32
	Payload []byte
}

// Queue is a per-scene command queue. Client commands arrive on many connection
// goroutines but are executed by the single scene loop, so the queue is the one
// place where those goroutines hand work over. It keeps one FIFO per entity and
// refuses any command whose Seq is not strictly increasing for that entity, the
// cheapest possible guard against retries and reordering.
type Queue struct {
	mu        sync.Mutex
	perEntity map[EntityID][]Command
	lastSeq   map[EntityID]uint32
	executed  uint64
	dropped   uint64
}

// NewQueue returns an empty command queue.
func NewQueue() *Queue {
	return &Queue{
		perEntity: make(map[EntityID][]Command),
		lastSeq:   make(map[EntityID]uint32),
	}
}

// Submit enqueues c and reports whether it was accepted. A command is dropped
// when its Seq is not strictly greater than the last accepted Seq for the same
// entity: that rejects a duplicate retry and an out-of-order straggler with a
// single comparison, before it ever reaches the combat pipeline.
func (q *Queue) Submit(c Command) bool {
	q.mu.Lock()
	defer q.mu.Unlock()
	if c.Seq <= q.lastSeq[c.Entity] {
		q.dropped++
		return false
	}
	q.lastSeq[c.Entity] = c.Seq
	q.perEntity[c.Entity] = append(q.perEntity[c.Entity], c)
	return true
}

// DrainTick removes and returns at most budget commands in a deterministic
// order and leaves the rest queued. budget <= 0 means "no cap".
//
// The order is: entities by ascending id, then each entity's commands in
// submission order (which Submit keeps increasing in Seq). Two servers fed the
// same submissions therefore execute the exact same sequence, which is what
// makes replays and cross-server comparisons meaningful.
func (q *Queue) DrainTick(budget int) []Command {
	q.mu.Lock()
	defer q.mu.Unlock()
	ids := make([]EntityID, 0, len(q.perEntity))
	for id, qq := range q.perEntity {
		if len(qq) > 0 {
			ids = append(ids, id)
		}
	}
	sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })

	var out []Command
	for _, id := range ids {
		qq := q.perEntity[id]
		for len(qq) > 0 {
			if budget > 0 && len(out) >= budget {
				q.perEntity[id] = qq
				q.executed += uint64(len(out))
				return out
			}
			out = append(out, qq[0])
			qq = qq[1:]
		}
		delete(q.perEntity, id)
	}
	q.executed += uint64(len(out))
	return out
}

// Drain is DrainTick without a per-tick budget.
func (q *Queue) Drain() []Command { return q.DrainTick(0) }

// Pending reports how many commands are still waiting.
func (q *Queue) Pending() int {
	q.mu.Lock()
	defer q.mu.Unlock()
	n := 0
	for _, qq := range q.perEntity {
		n += len(qq)
	}
	return n
}

// Stats returns the lifetime executed and dropped counters.
func (q *Queue) Stats() (executed, dropped uint64) {
	q.mu.Lock()
	defer q.mu.Unlock()
	return q.executed, q.dropped
}

三个设计点值得单独说。第一,Seq 是每实体的,不是全局的。同一个玩家连点两次「施法」,序号是 5 和 6;另一个玩家的序号也在从 1 开始数。去重只在自己这条流里比较,跨玩家的序号没有可比性。第二,Submit 里的比较是 <= 而不是 !=。这样既挡住重复(等于上一个),也挡住迟到的旧包(小于上一个),一个分支解决两类问题。第三,Payload 是引用拷贝。解码时分配的字节切片被原样存进队列,所以解码缓冲不能在 Submit 返回后被复用——这是卷一帧解码器约定里的同一条规矩。

这里要划清一条界线:队列的序号去重和卷一 11.1 的幂等窗口是两件事,不能互相替代。窗口按时间记「最近处理过哪些请求」,跨连接、跨实体,防的是网关重发和客户端超时重试;队列按每实体序号记「这个实体的命令到第几条了」,防的是同一连接的乱序和重放。前者会随时间遗忘,后者会随序号单调推进而永久拒绝旧序号。真正的「绝不重复发奖」在 6.3 由业务指纹兜底。

确定性、每帧预算与实测结论

排空顺序必须确定,而 Go 的 map 遍历顺序是随机的,所以 DrainTick 先把实体 id 排好序再取命令。下面这段测试就是钉死这一点的——同一个队列构造五次,Drain 出来的顺序必须逐字相同:

// snippet: internal/combat/command_test.go(节选)

func TestQueueDrainIsDeterministic(t *testing.T) {
	build := func() *Queue {
		q := NewQueue()
		// Submit out of entity order on purpose.
		q.Submit(Command{Entity: 30, Cmd: CmdSkillCastReq, Seq: 1})
		q.Submit(Command{Entity: 10, Cmd: CmdSkillCastReq, Seq: 1})
		q.Submit(Command{Entity: 30, Cmd: CmdDamageNotify, Seq: 2})
		q.Submit(Command{Entity: 20, Cmd: CmdBuffSync, Seq: 1})
		return q
	}
	want := []struct {
		e EntityID
		s uint32
	}{{10, 1}, {20, 1}, {30, 1}, {30, 2}}
	for run := 0; run < 5; run++ {
		got := build().Drain()
		if len(got) != len(want) {
			t.Fatalf("run %d: len = %d, want %d", run, len(got), len(want))
		}
		for i, w := range want {
			if got[i].Entity != w.e || got[i].Seq != w.s {
				t.Fatalf("run %d: [%d] = (%d,%d), want (%d,%d)",
					run, i, got[i].Entity, got[i].Seq, w.e, w.s)
			}
		}
	}
}

budget 参数解决的是另一个问题:突发流量。一次公会战可能有几千条施法命令在同一帧涌进队列,如果每帧无条件排空,这一帧的结算时间会飙升,整个场景卡顿。给一个每帧预算,超出的部分留到下一帧,就把尖峰摊成了平缓的曲线——代价是命令延迟略增,但这是可接受的,因为战斗本身的节奏是几十毫秒一帧。

本机(Go 1.26,macOS arm64,Apple M1 Pro)实测如下,同机相对量级,绝对数值不可移植:

指标实测值说明
Submit + 分摊 Drain204 ns/op,147 B/op,1 allocs/op每 64 条命令排空一次
TestQueueRejectsStaleSeq 丢弃计数4 条中丢 2 条重复 + 乱序各一条
TestQueueDrainBudget 首帧6 条中取 3 条预算生效,其余留队

两个结论。其一,单条命令的投递成本在两百纳秒量级,比一次跨线程唤醒(微秒级)便宜得多,所以「收包线程塞队列、场景线程排空」这个拆分本身几乎不要钱,贵的是它挡下来的那些结算错误。其二,确定性是可以被测试钉住的——TestQueueDrainIsDeterministic 不依赖任何随机源,构造五次拿到五次一样的顺序,这条性质一旦成立,回放、断线重连补发、跨服对账才有了共同的基础。

还有一条容易忽略的性质:DrainTick 里排空后 delete 掉的实体,下次有新命令时会重新出现。这意味着队列的内存与当前活跃实体数成正比,而不是与历史出现过多少实体成正比——一个打了三小时、走过上千个怪的玩家,只要当前没有待执行命令,就不占队列内存。这是「每实体 FIFO 用完即弃」这个结构选择带来的、和卷一幂等窗口同源的收益:上界可算。

一个常见的反对意见是「那把 lastSeq 也一起删了不就更省?」——不能删。lastSeq 是挡住旧序号重放的唯一依据,一旦随着实体清空而丢失,一个刚被排空的玩家再来一条 Seq=1 的老命令就会被当成新命令放行。所以 perEntity 可以随排空清掉,lastSeq 必须留到会话结束。

下一节处理命令排空之后的事:拿到 CmdSkillCastReq 之后,技能怎么校验、冷却怎么记、Buff 怎么叠加。

阅读导航:上一节:目录 · 下一节:6.2 技能与 Buff 建模 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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