《游戏服务端编程实践》8.2.1 指令投递与执行队列

从客户端指令与服务端事件的边界出发,拆解帧号排序、同帧优先级、指令去重、过期丢弃与非法指令的可观测拒绝,并给出带校验钩子的 CommandQueue 实现和乱序到达、重复投递、过期指令的测试用例,同时说明队列与网络层、战斗处理器的职责边界以及去重窗口的内存上限取舍。

一、指令是意图,事件是事实

客户端发来的“释放技能”只是意图,服务器执行后产生的“命中目标、扣除法力、进入冷却”才是事实。把两者混为一谈,最常见的后果是客户端说“我打中了”,服务器却没有任何记录;或者服务器广播了伤害,但客户端从未提交过对应指令。

指令必须携带足够的信息让服务器复现判断:谁发的、第几帧执行、对谁释放、用什么技能、客户端的采样时间。服务器不能直接拿客户端自报的时间当执行顺序,也不能因为网络包先到就认为它先发生。指令进入队列,经过排序和校验,才允许触碰战斗状态。

本文讨论的是“从网关收到指令”到“逻辑帧取出指令”这一段。伤害公式、Buff 结算、死亡判定属于后续章节,队列本身不负责这些。

二、帧号、时间戳与排序

一条战斗指令至少包含下表中的字段。缺字段不是“以后再补”,而是会在某个边界上直接变成错误行为。

字段作用缺失后的典型问题
player_id归属玩家,去重与鉴权无法判断指令来源,容易被伪造
command_id客户端单调递增的指令号重发无法识别,可能双倍伤害
frame希望执行的逻辑帧只能按到达顺序执行,乱序即错序
kind指令类型处理器无法分派
target目标实体只能打默认目标或空指针
client_time客户端采样时间无法做延迟补偿与回放对照
expire_frame最晚可执行帧网络恢复后执行早已过期的操作

client_time 只用于体验层:显示延迟、插值、延迟补偿的参考值。排序必须以服务器逻辑帧为准,因为客户端时钟可以改、可以漂移,也可以被恶意构造。服务器收到指令时记录“到达序号”,但到达序号只用于同帧内的稳定排序,不参与跨帧比较。

乱序到达是常态。玩家先发的第 3 帧指令可能后到,后发的第 5 帧指令可能先到。队列允许先缓存,等到逻辑帧推进时统一按 frame 排序取出。缓存需要上限:超过窗口仍未到期的指令要么补执行,要么丢弃;不能无限占用内存。

帧号的来源也必须统一。常见做法是由逻辑线程维护一个单调递增的 30 Hz 或 60 Hz 帧计数,所有区服、所有副本使用同一套推进规则。不要让客户端自报“我在第 100 帧”,也不要让每个副本各自从 0 开始计帧,否则跨副本迁移或录像合并时,帧号无法比较。

时间来源可信度正确用途
客户端采样时间低延迟显示、插值、体验补偿
网关到达时间中监控网络抖动,不参与战斗排序
服务器逻辑帧号高跨帧排序、超时判定、回放
客户端自报帧号不可信仅作为诊断字段记录

三、执行队列的职责边界

执行队列只做四件事:接收、去重、排序、按帧交付。它不应该计算伤害,不应该修改背包,也不应该直接广播。把这些职责塞进队列,会让测试必须同时构造网络、数据库和战斗状态,最终没人能说清一个 bug 出在哪一层。

每帧的流程可以概括为:

  1. 逻辑线程推进帧号到 F。
  2. 从队列取出所有 frame <= F 的指令。
  3. 按帧号、优先级、到达序号排序。
  4. 逐条交给处理器;处理器做目标、距离、冷却等校验。
  5. 处理器产生事件,事件再进入广播与日志。

队列与处理器之间用值传递,不共享可变指针。处理器可以拒绝指令,但不能偷偷修改队列内部切片。这样队列的统计才有意义:接收多少、重复多少、过期多少、拒绝多少、真正执行多少。

四、去重与过期

去重键通常选 (player_id, command_id),而不是单独的 command_id。不同玩家各自从 1 开始编号,只用指令号会互相误杀。客户端超时重发、网关重试、断线重连补发,都会产生同一条指令的多个副本;如果每个副本都执行一次,就会出现双倍伤害、双倍消耗、双倍冷却。

去重窗口是工程折中。服务端不可能永远记住所有历史指令号,通常只保留最近若干帧的键。窗口内的重复直接计数并丢弃;窗口外的旧指令号可以重新使用,但这时依赖的是客户端的单调递增约定和会话生命周期。窗口越大,内存越多,抗重放越强;窗口越小,越容易被慢速重放钻空子。

过期与去重是两回事。过期解决“指令到得太晚,已经没有执行意义”;去重解决“同一条指令被送了多次”。一条过期指令仍然要计入过期统计,但不应该进入拒绝列表,因为它不是非法指令,只是错过了时间窗口。

五、同帧顺序与优先级

同一帧内可能有多条指令:移动、普攻、技能、使用物品。它们的执行顺序会影响结果,例如先加攻击再结算伤害,和先结算伤害再加攻击,数值不同。

队列采用两级排序:先按 frame 升序,再按 priority 降序,最后按到达序号升序。优先级由服务器根据指令类型分配,不能由客户端直接填写;到达序号由队列在 Push 时分配,保证同一优先级下先到先执行。排序必须是稳定的,否则同一批指令在不同机器上可能得到不同结果。

一个可用的优先级分配如下,数值只表示相对顺序,不是协议字段:

指令类型优先级分配理由
离场、死亡确认100先移除实体,避免后续指令引用失效目标
控制技能80控制结果会影响后续指令能否执行
治疗与驱散60在伤害前结算,避免“死了才奶到”
普通攻击50常规伤害结算
移动10只改变位置,通常不参与同帧伤害
表情、聊天0纯展示,必要时可延迟到下一帧

优先级不是越高越好。把所有技能都设成 100,等于没有优先级;真正需要保证的是同一类指令内部的稳定性,以及少数“必须先结算”的例外。

不要用 map 保存待执行指令再遍历。Go 的 map 遍历顺序不确定,同一帧内两条互相影响的指令可能今天这样、明天那样。切片加显式排序,代价是插入时多一次比较,收益是行为可复现、测试可断言、回放可对齐。

六、非法指令:目标不存在、距离不够、冷却中

非法指令至少有三类:目标不存在或已死亡、距离超出技能范围、技能仍在冷却。它们都依赖“执行这一刻”的世界状态,所以不能只在 Push 时校验。玩家在第 10 帧提交时目标还活着,第 12 帧执行时目标可能已经被别人击杀。

队列提供 SetValidator 钩子,在出队时调用。校验失败返回错误,队列把指令和原因放进拒绝列表,并增加拒绝计数。拒绝必须是可观测的:只写一行 debug 日志等于没有观测,线上既无法统计作弊尝试,也无法定位“玩家说技能没放出来”的工单。

拒绝与丢弃的区别要明确。非法指令要告诉客户端“为什么没执行”,但不必回传完整世界状态;过期指令可以静默丢弃,因为客户端本来就不该再等它。两者的统计口径分开,才能判断是网络问题还是逻辑问题。

结果判定时机是否回传客户端统计口径
重复指令入队时否duplicate
字段非法入队时是,协议错误rejected
指令过期出队前否expired
目标不存在出队时是,附带原因码rejected
距离不足出队时是,附带原因码rejected
冷却中出队时是,附带剩余帧rejected

回传失败原因时不要附带完整实体快照,否则等于给外挂提供免费探测接口。原因码加少量必要数值即可,例如剩余冷却帧、当前距离档位。

七、失败场景:边收边执行

最常见的错误是网关每收到一个包,就在网络 goroutine 里直接调用战斗函数。两个 goroutine 同时修改同一个玩家的血量,如果没有锁,就会出现丢失更新;如果加了粗粒度锁,又会出现顺序不可控。更隐蔽的是重发:客户端因为 200 毫秒没收到回包而重发技能,两个 goroutine 都通过了“冷却已好”的检查,结果扣了一次蓝、放了两次技能。

正确做法是把网络收包与逻辑执行解耦:网络层只做解码、基本字段校验和入队;逻辑线程按帧取出指令,串行执行。所有会修改战斗状态的代码都跑在同一个逻辑线程上,队列成为唯一的入口。这样既避免了锁竞争,也让“第几帧发生了什么”可以被完整记录。

边收边执行还会破坏回放。回放需要按输入顺序重演,如果线上执行顺序取决于 goroutine 调度,录像里的帧号就无法还原当时的真实顺序。先入队、再按帧排序,是回放能够成立的前提。

八、Go 实现

下面的 CommandQueue 把去重、过期、校验和排序放在同一个结构里。Push 只做入队与基础字段检查,PopDue 才做依赖世界状态的校验,并把拒绝原因保留下来。

// file: ch08/skill/queue.go
// tier: L1
// verify: go test ./ch08/skill/

package skill

import (
	"errors"
	"sort"
)

var (
	ErrInvalidCommand = errors.New("invalid command")
	ErrQueueClosed    = errors.New("command queue closed")
)

// Command 是客户端意图,不是结果。Frame 表示希望在第几帧执行,
// ExpireFrame 为 0 表示不主动过期。
type Command struct {
	PlayerID    uint64
	ID          uint64
	Frame       uint64
	Kind        string
	Target      uint64
	Priority    int
	ExpireFrame uint64
}

type Rejection struct {
	Command Command
	Reason  error
}

type QueueStats struct {
	Accepted  int
	Duplicate int
	Expired   int
	Rejected  int
	Popped    int
}

type commandKey struct {
	player uint64
	id     uint64
}

type pendingCommand struct {
	Command
	seq uint64
}

// CommandQueue 只负责排序、去重、过期和拒绝记录,不执行战斗逻辑。
type CommandQueue struct {
	dedupeWindow uint64
	validate     func(Command) error

	seq     uint64
	pending []pendingCommand
	seen    map[commandKey]uint64
	stats   QueueStats
	rejects []Rejection
	closed  bool
}

func NewCommandQueue(dedupeWindow uint64) *CommandQueue {
	if dedupeWindow == 0 {
		dedupeWindow = 120
	}
	return &CommandQueue{dedupeWindow: dedupeWindow, seen: make(map[commandKey]uint64)}
}

// SetValidator 在出队时校验目标、距离、冷却等依赖当前世界状态的条件。
func (q *CommandQueue) SetValidator(fn func(Command) error) { q.validate = fn }

func (q *CommandQueue) Close() { q.closed = true }

func (q *CommandQueue) Len() int { return len(q.pending) }

func (q *CommandQueue) Stats() QueueStats { return q.stats }

func (q *CommandQueue) Rejections() []Rejection {
	return append([]Rejection(nil), q.rejects...)
}

func (q *CommandQueue) Push(cmd Command) error {
	if q.closed {
		return ErrQueueClosed
	}
	if cmd.PlayerID == 0 || cmd.ID == 0 || cmd.Kind == "" {
		q.reject(cmd, ErrInvalidCommand)
		return ErrInvalidCommand
	}
	if cmd.ExpireFrame != 0 && cmd.ExpireFrame < cmd.Frame {
		q.reject(cmd, ErrInvalidCommand)
		return ErrInvalidCommand
	}
	q.pruneSeen(cmd.Frame)
	key := commandKey{player: cmd.PlayerID, id: cmd.ID}
	if _, ok := q.seen[key]; ok {
		q.stats.Duplicate++
		return nil
	}
	q.seen[key] = cmd.Frame
	q.seq++
	q.pending = append(q.pending, pendingCommand{Command: cmd, seq: q.seq})
	q.stats.Accepted++
	return nil
}

// PopDue 取出 Frame <= frame 的全部指令,按帧号、优先级、到达顺序排序。
// 同帧内 Priority 大的先执行;优先级相同则按 Push 的先后执行。
func (q *CommandQueue) PopDue(frame uint64) []Command {
	kept := q.pending[:0]
	due := make([]pendingCommand, 0, len(q.pending))
	for _, p := range q.pending {
		if p.Frame > frame {
			kept = append(kept, p)
			continue
		}
		if p.ExpireFrame != 0 && frame > p.ExpireFrame {
			q.stats.Expired++
			continue
		}
		if q.validate != nil {
			if err := q.validate(p.Command); err != nil {
				q.reject(p.Command, err)
				continue
			}
		}
		due = append(due, p)
	}
	q.pending = kept
	sort.Slice(due, func(i, j int) bool {
		if due[i].Frame != due[j].Frame {
			return due[i].Frame < due[j].Frame
		}
		if due[i].Priority != due[j].Priority {
			return due[i].Priority > due[j].Priority
		}
		return due[i].seq < due[j].seq
	})
	q.pruneSeen(frame)
	out := make([]Command, len(due))
	for i := range due {
		out[i] = due[i].Command
	}
	q.stats.Popped += len(out)
	return out
}

// pruneSeen 把去重窗口外的指令号释放掉,避免 seen 无限增长。
func (q *CommandQueue) pruneSeen(frame uint64) {
	for key, f := range q.seen {
		if f+q.dedupeWindow < frame {
			delete(q.seen, key)
		}
	}
}

func (q *CommandQueue) reject(cmd Command, err error) {
	q.stats.Rejected++
	q.rejects = append(q.rejects, Rejection{Command: cmd, Reason: err})
}

几个实现细节值得注意。seen 记录的是指令号到帧号的映射,pruneSeen 在入队和出队时清理窗口外的键,避免长期运行后无限增长。PopDue 使用 sort.Slice 显式排序,不依赖 map 或切片原有顺序。过期指令在排序前就被移除,因此不会占用同一帧的优先级位置。

九、测试与运行

测试覆盖五类行为:乱序到达时按帧号执行、同帧内优先级高于到达顺序、同一玩家重复指令只执行一次、过期指令被丢弃、校验失败进入拒绝列表。另有一个窗口测试确认旧指令号在窗口外可以重新使用。

cd .local/checks/gs-practice-lab && gofmt -l ch08/skill && go vet ./ch08/skill/ && go test ./ch08/skill/ -count=1
ok  	gspractice/ch08/skill	0.293s

测试通过只说明队列的排序和计数符合预期,不代表战斗数值正确。数值正确性需要另建伤害与 Buff 的测试,队列的职责到“把正确的指令按正确的顺序交出去”为止。

小结

指令投递的核心不是“把包收下来”,而是把不可信、可能乱序、可能重复的客户端意图,转换成一个确定、可观测、可回放的执行序列。帧号决定跨帧顺序,优先级与到达序号决定同帧顺序,去重键防止重复执行,过期窗口限制迟到指令的影响,出队校验负责把非法操作挡在战斗状态之外。

常见错误与边界

  • 网络 goroutine 直接执行战斗逻辑,靠锁保护,顺序仍由调度决定。
  • 只按到达顺序执行,忽略帧号,乱序包导致后发先至。
  • 去重只用 command_id,不同玩家的同号指令互相误杀。
  • 去重表永不清理,长时间运行后内存持续上涨。
  • 把目标、距离、冷却校验放在入队时,执行时世界已经变化。
  • 非法指令静默丢弃,线上无法区分“没收到”“被拒绝”“执行失败”。
  • 在同一帧内用 map 遍历指令,结果不可复现,回放对不上。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist