一、指令是意图,事件是事实
客户端发来的“释放技能”只是意图,服务器执行后产生的“命中目标、扣除法力、进入冷却”才是事实。把两者混为一谈,最常见的后果是客户端说“我打中了”,服务器却没有任何记录;或者服务器广播了伤害,但客户端从未提交过对应指令。
指令必须携带足够的信息让服务器复现判断:谁发的、第几帧执行、对谁释放、用什么技能、客户端的采样时间。服务器不能直接拿客户端自报的时间当执行顺序,也不能因为网络包先到就认为它先发生。指令进入队列,经过排序和校验,才允许触碰战斗状态。
本文讨论的是“从网关收到指令”到“逻辑帧取出指令”这一段。伤害公式、Buff 结算、死亡判定属于后续章节,队列本身不负责这些。
二、帧号、时间戳与排序
一条战斗指令至少包含下表中的字段。缺字段不是“以后再补”,而是会在某个边界上直接变成错误行为。
| 字段 | 作用 | 缺失后的典型问题 |
|---|---|---|
player_id | 归属玩家,去重与鉴权 | 无法判断指令来源,容易被伪造 |
command_id | 客户端单调递增的指令号 | 重发无法识别,可能双倍伤害 |
frame | 希望执行的逻辑帧 | 只能按到达顺序执行,乱序即错序 |
kind | 指令类型 | 处理器无法分派 |
target | 目标实体 | 只能打默认目标或空指针 |
client_time | 客户端采样时间 | 无法做延迟补偿与回放对照 |
expire_frame | 最晚可执行帧 | 网络恢复后执行早已过期的操作 |
client_time 只用于体验层:显示延迟、插值、延迟补偿的参考值。排序必须以服务器逻辑帧为准,因为客户端时钟可以改、可以漂移,也可以被恶意构造。服务器收到指令时记录“到达序号”,但到达序号只用于同帧内的稳定排序,不参与跨帧比较。
乱序到达是常态。玩家先发的第 3 帧指令可能后到,后发的第 5 帧指令可能先到。队列允许先缓存,等到逻辑帧推进时统一按 frame 排序取出。缓存需要上限:超过窗口仍未到期的指令要么补执行,要么丢弃;不能无限占用内存。
帧号的来源也必须统一。常见做法是由逻辑线程维护一个单调递增的 30 Hz 或 60 Hz 帧计数,所有区服、所有副本使用同一套推进规则。不要让客户端自报“我在第 100 帧”,也不要让每个副本各自从 0 开始计帧,否则跨副本迁移或录像合并时,帧号无法比较。
| 时间来源 | 可信度 | 正确用途 |
|---|---|---|
| 客户端采样时间 | 低 | 延迟显示、插值、体验补偿 |
| 网关到达时间 | 中 | 监控网络抖动,不参与战斗排序 |
| 服务器逻辑帧号 | 高 | 跨帧排序、超时判定、回放 |
| 客户端自报帧号 | 不可信 | 仅作为诊断字段记录 |
三、执行队列的职责边界
执行队列只做四件事:接收、去重、排序、按帧交付。它不应该计算伤害,不应该修改背包,也不应该直接广播。把这些职责塞进队列,会让测试必须同时构造网络、数据库和战斗状态,最终没人能说清一个 bug 出在哪一层。
每帧的流程可以概括为:
- 逻辑线程推进帧号到
F。 - 从队列取出所有
frame <= F的指令。 - 按帧号、优先级、到达序号排序。
- 逐条交给处理器;处理器做目标、距离、冷却等校验。
- 处理器产生事件,事件再进入广播与日志。
队列与处理器之间用值传递,不共享可变指针。处理器可以拒绝指令,但不能偷偷修改队列内部切片。这样队列的统计才有意义:接收多少、重复多少、过期多少、拒绝多少、真正执行多少。
四、去重与过期
去重键通常选 (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 遍历指令,结果不可复现,回放对不上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。