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 + 分摊 Drain | 204 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 建模 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。