《游戏服务端架构·品类、状态与拓扑》6.1 客户端预测与服务端裁决

从《裂隙战场》一次 180ms 往返延迟下的橡皮筋回拉讲起,拆开客户端预测、服务端确认、按 tick 历史重放与偏差统计的完整链路;给出整数坐标、序号去重、陈旧输入拒绝、RTT 分档降级四类约束下的 Go 实现,并用 6 组测试覆盖干净回放、服务端限速、预测窗口与乱序待重放。

一次回拉,暴露了“谁说了算”

《裂隙战场》是 5v5 竞技产品,房间服按 30Hz 推进。跨区玩家反馈:松开方向键后角色还会往前滑半步,然后被“拽”回原位;技能动画已经播完,伤害却延迟一帧才出现。录制回放显示,客户端一直在按本地输入移动,服务端按自己的判定修正,两边对“现在在哪”没有共识。

这不是网络库的问题。即使把传输换成可靠 UDP、把 tick 提到 60Hz,只要客户端先动、服务端后判,就一定存在一段“客户端已经相信、服务端还没确认”的时间。架构要回答的是:这段时间里,哪些状态允许客户端先写,写错了怎么收回,收回的代价由谁承担。

《泡泡工坊》的约束完全不同。它是一款休闲小游戏,房间生命周期短,玩家对几十毫秒的回拉不敏感,但团队只有两名后端。给它套用竞技级的预测回滚,只会增加故障面。所以本章给出两档权威模型,而不是一个“最佳实践”。

三种权威模型与它们的代价

模型客户端能写什么服务端职责适用场景主要代价
客户端权威位置、命中、结算输入转发与落库单机、弱联网、信任房主作弊零成本,无法做竞技
纯状态同步什么都不写计算并广播全量状态回合制、SLG、棋牌输入延迟直接可见
预测-回滚-重放只写表现层与自身预测裁决、确认、纠正5v5、FPS、动作实现复杂,回拉需要设计

《群岛纪元》的大地图行军采用异步结算,玩家下发指令后等待服务端回执,属于纯状态同步的变体:慢一点可以接受,算错不可接受。《裂隙战场》必须选第三档,因为 100ms 以上的输入延迟会直接毁掉手感。

选第三档不等于把所有状态都交给预测。下面这条边界比算法更重要。

表现层预测与状态层裁决

把客户端状态分成三类,每类的写入权限不同:

类别例子客户端权限服务端权限
权威状态生命、伤害、命中、冷却、货币、掉落、比赛结果只读唯一写入者
预测状态自身位置、朝向、动画相位、本地特效可先写,可被覆盖最终裁决
本地状态相机、音量、按键映射、UI 展开完全自写不关心

边界只有一句话:凡是能变成经济收益或比赛结果的状态,客户端永远不能成为第一写入者。 位置之所以可以预测,是因为它会被服务端覆盖,而且覆盖的代价只是视觉回拉;伤害不行,因为一次错误的伤害确认会改变整局结果,事后回滚要补偿两边的玩家体验。

这条边界也解释了为什么“客户端算伤害、服务端抽查”是危险的。抽查率再高,未抽中的那部分仍然是客户端说了算;而攻击者只需要找到不抽中的路径。

预测、确认、重放:一条可验证的链路

完整链路有四个角色:输入序号、服务端确认、客户端历史、偏差统计。

客户端                         服务端
  |-- Input{seq=7,tick=120} ----->|
  |   本地预测:位置 += move      |  校验 tick/seq,按规则裁决
  |<-- Ack{tick=120,state} -------|
  |   丢弃 tick<=120 的待确认输入
  |   以 Ack.state 为基准,重放 tick>120 的输入
  |   记录本次偏差,超过阈值计为硬纠正

四个关键决定:

第一,输入带单调 Seq 和世界 Tick。Seq 只用于去重,Tick 才是排序依据;乱序到达时按 Tick 重放,不能按到达顺序。

第二,服务端只接受更新的 Tick 和 Seq。旧包直接拒绝并计数,不能“宽容地再算一次”,否则一次重放攻击就能让角色多走几步。

第三,确认包携带该 tick 的完整状态,而不是增量。丢一个确认包后,客户端可以用下一个确认包直接对齐,不需要服务端保留历史。

第四,客户端保存“每个 tick 预测后的状态”。收到确认时,比较的是该 tick 当时的预测与权威状态,而不是当前预测。否则,客户端对未来的合法预测会被误判成偏差,纠正统计完全失真。

偏差、阈值与降级

偏差用曼哈顿距离累计,单位是世界坐标的最小格。它不追求物理精确,只回答“这次纠正玩家能不能看出来”。

偏差处理玩家感知
0无纠正无
1 到阈值软纠正,平滑插值到权威位置轻微滑动
超过阈值硬纠正,直接吸附并记事件明显回拉,需要告警

阈值不是常数。角色速度、碰撞半径、相机距离都会影响可感知程度,应作为房间配置而不是全局常量。

高延迟时继续激进预测,会让偏差和重放成本同时上升。降级策略按 RTT 分档,示例值如下:

RTT预测窗口效果
≤ 60ms8 tick接近本地手感
61–120ms4 tick减少超前预测
> 120ms1 tick只预测当前输入,优先一致

窗口只限制“最多领先确认多少 tick”,不改变已经发出的输入。超出窗口的输入仍要进入缓冲区,等确认到达后按顺序重放;丢弃它们会造成输入丢失,比回拉更糟。

代码:authority 包

下面的实现只做四件事:服务端拒绝陈旧输入并裁决、客户端按窗口预测、按 tick 历史计算偏差、确认后重放未确认输入。坐标使用整数,避免浮点重放顺序带来的伪偏差。

// file: internal/authority/authority.go
// tier: L1
// verify: go test ./internal/authority/
package authority

import (
	"errors"
	"sort"
	"time"
)

// Vec is a position in integer world units. Integers keep replay stable:
// floating point addition is not associative, so replay order could drift.
type Vec struct{ X, Y int }

func (v Vec) Add(o Vec) Vec  { return Vec{X: v.X + o.X, Y: v.Y + o.Y} }
func (v Vec) Sub(o Vec) Vec  { return Vec{X: v.X - o.X, Y: v.Y - o.Y} }
func (v Vec) Manhattan() int { return abs(v.X) + abs(v.Y) }

func abs(v int) int {
	if v < 0 {
		return -v
	}
	return v
}

// Input is one client command. Seq is for deduplication; Tick orders it.
type Input struct {
	Seq  uint32
	Tick uint64
	Move Vec
}

var (
	ErrStaleTick = errors.New("authority: tick not newer than last applied")
	ErrStaleSeq  = errors.New("authority: sequence not newer than last applied")
)

// Ack carries the full server state at Tick, never a delta, so a client that
// lost a packet can resynchronise without replaying server history.
type Ack struct {
	Tick  uint64
	Seq   uint32
	State Vec
}

// Server owns the only writable copy of the character state.
type Server struct {
	state    Vec
	tick     uint64
	seq      uint32
	applied  int
	rejected int

	// Limit clamps one axis of a submitted move; zero means unlimited. It
	// stands in for any server rule that overrides client intent.
	Limit int
}

func NewServer(start Vec) *Server { return &Server{state: start} }

func (s *Server) Submit(in Input) (Ack, error) {
	if in.Tick <= s.tick {
		s.rejected++
		return Ack{}, ErrStaleTick
	}
	if in.Seq <= s.seq {
		s.rejected++
		return Ack{}, ErrStaleSeq
	}
	move := in.Move
	if s.Limit > 0 {
		move = Vec{X: clamp(move.X, s.Limit), Y: clamp(move.Y, s.Limit)}
	}
	s.tick = in.Tick
	s.seq = in.Seq
	s.state = s.state.Add(move)
	s.applied++
	return Ack{Tick: in.Tick, Seq: in.Seq, State: s.state}, nil
}

func (s *Server) State() Vec    { return s.state }
func (s *Server) Applied() int  { return s.applied }
func (s *Server) Rejected() int { return s.rejected }

// Aggression controls how far ahead of the last acknowledgement a client may
// predict. Window is measured in ticks.
type Aggression struct{ Window int }

// Tune lowers prediction aggressiveness as RTT grows. The thresholds are
// deployment policy, not protocol constants.
func Tune(rtt time.Duration) Aggression {
	switch {
	case rtt <= 60*time.Millisecond:
		return Aggression{Window: 8}
	case rtt <= 120*time.Millisecond:
		return Aggression{Window: 4}
	default:
		return Aggression{Window: 1}
	}
}

type Stats struct {
	Acks            int
	Replays         int
	Corrections     int
	HardCorrections int
	MaxDeviation    int
}

// Replay describes what happened when one acknowledgement was applied.
type Replay struct {
	Before        Vec
	Authoritative Vec
	Deviation     int
	Corrected     bool
	Replayed      []Input
}

// Client keeps a speculative copy plus per-tick prediction history. History is
// what makes deviation meaningful: comparing the current prediction with an old
// ack would charge the client for honest future movement.
type Client struct {
	predicted Vec
	ackedTick uint64
	buffer    map[uint32]Input
	history   map[uint64]Vec
	agg       Aggression
	tolerance int
	stats     Stats
}

func NewClient(start Vec, tolerance int) *Client {
	if tolerance < 1 {
		tolerance = 1
	}
	return &Client{
		predicted: start,
		buffer:    make(map[uint32]Input),
		history:   make(map[uint64]Vec),
		agg:       Tune(0),
		tolerance: tolerance,
	}
}

func (c *Client) SetAggression(a Aggression) { c.agg = a }

// Predict buffers an input and applies it locally only inside the prediction
// window. Out-of-window inputs wait for an ack and are applied during replay.
func (c *Client) Predict(in Input) (Vec, bool) {
	if _, ok := c.buffer[in.Seq]; !ok {
		c.buffer[in.Seq] = in
	}
	if in.Tick <= c.ackedTick || in.Tick > c.ackedTick+uint64(c.agg.Window) {
		return c.predicted, false
	}
	c.predicted = c.predicted.Add(in.Move)
	c.history[in.Tick] = c.predicted
	return c.predicted, true
}

// Reconcile compares the stored prediction at the acknowledged tick with the
// server state, snaps to the server, then replays every still-pending input.
func (c *Client) Reconcile(ack Ack) Replay {
	base, ok := c.history[ack.Tick]
	if !ok {
		base = ack.State
	}
	dev := base.Sub(ack.State).Manhattan()
	c.stats.Acks++
	if dev > c.stats.MaxDeviation {
		c.stats.MaxDeviation = dev
	}

	c.ackedTick = ack.Tick
	for seq, in := range c.buffer {
		if in.Tick <= ack.Tick {
			delete(c.buffer, seq)
		}
	}
	for tick := range c.history {
		if tick <= ack.Tick {
			delete(c.history, tick)
		}
	}

	pending := c.pending()
	c.predicted = ack.State
	for _, in := range pending {
		c.predicted = c.predicted.Add(in.Move)
		c.history[in.Tick] = c.predicted
	}

	r := Replay{Before: base, Authoritative: ack.State, Deviation: dev, Replayed: pending}
	if dev > 0 {
		c.stats.Corrections++
		r.Corrected = true
		if dev > c.tolerance {
			c.stats.HardCorrections++
		}
	}
	c.stats.Replays += len(pending)
	return r
}

func (c *Client) pending() []Input {
	out := make([]Input, 0, len(c.buffer))
	for _, in := range c.buffer {
		out = append(out, in)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Tick != out[j].Tick {
			return out[i].Tick < out[j].Tick
		}
		return out[i].Seq < out[j].Seq
	})
	return out
}

func (c *Client) Predicted() Vec { return c.predicted }
func (c *Client) Pending() int   { return len(c.buffer) }
func (c *Client) Stats() Stats   { return c.stats }

func clamp(v, limit int) int {
	if v > limit {
		return limit
	}
	if v < -limit {
		return -limit
	}
	return v
}

Reconcile 先从 history 取出被确认 tick 的预测值;没有预测过的 tick 退回使用确认状态本身,偏差记为 0。重放使用缓冲区里的原始输入且不再检查预测窗口,因为这些输入已经发出、只是尚未确认。Limit 代表服务端规则对客户端意图的覆盖,例如速度上限或击退修正,它让测试可以在不引入战斗系统的前提下制造一次真实的权威偏差。

测试覆盖了什么

lab 里的 internal/authority 有 6 个测试函数,其中 4 个是表驱动:

测试覆盖的失败模式
TestServerRejectsStaleInput同 tick、旧 tick、旧 seq 三类陈旧输入
TestPendingReplayOrder待确认输入按 tick 升序重放,与到达顺序无关
TestReconcileDeviationTable干净确认、服务端限速、阈值内软纠正
TestTuneLadder三档 RTT 对应的预测窗口边界
TestReconcileDropsAckedAndReplaysFuture确认后丢弃已确认输入并重放未来输入
TestAggressionWindowLimitsPrediction高延迟下窗口外的输入不进入本地预测

本机运行结果:

$ go test -count=1 -v ./internal/authority/
--- PASS: TestServerRejectsStaleInput (0.00s)
--- PASS: TestReconcileDropsAckedAndReplaysFuture (0.00s)
--- PASS: TestReconcileDeviationTable (0.00s)
--- PASS: TestPendingReplayOrder (0.00s)
--- PASS: TestAggressionWindowLimitsPrediction (0.00s)
--- PASS: TestTuneLadder (0.00s)
PASS
ok  	gsarchlab/internal/authority	0.287s

失败模式与兜底

确认包持续丢失或迟迟不到。 缓冲区和历史表都会增长。必须给待确认输入设上限,超过后停止预测并等待重同步;历史只保留可能被确认的 tick,并与缓冲区使用同一水位删除。

重放成本超过帧预算。 一次确认可能触发几十个 tick 的重放。重放必须在客户端帧预算内完成,超出时应降低预测窗口,而不是在主线程里补算。

服务端规则变更。 限速、碰撞、击退规则一改,所有在线客户端的预测都会偏差。规则变更应与版本号绑定,客户端在版本不匹配时退回纯状态同步。

常见错误与边界

第一,把经济结算放进预测链路。位置可以回拉,货币不能;一旦客户端先写货币,服务端的所有校验都变成事后追认。

第二,用“当前预测”与旧确认比较。这样每次确认都会产生一笔虚假偏差,硬纠正率随预测窗口线性上升。

第三,用浮点坐标做重放。浮点加法不满足结合律,输入顺序一变,结果就不同;用整数或定点数,重放才是可复现的。

第四,相信客户端上报的 tick。客户端可以把 tick 改大来跳过校验;tick 必须由服务端世界时钟推进,客户端只能回显。

第五,把回拉当成 bug 全部抹平。回拉是权威模型的正常成本,完全抹平意味着客户端重新获得裁决权。

小结

预测-回滚-重放的本质不是“让客户端算得更快”,而是把客户端的不确定性限制在一段可收回的时间里。输入序号负责去重,tick 负责排序,确认包负责建立唯一事实,历史表负责让偏差可解释,阈值与降级负责把回拉的代价控制在玩家可接受的范围。

internal/authority 把这套链路压缩成一个可测试的包。它不包含战斗规则,也不包含网络传输,因为这两部分应该由房间服和网络库负责;它只回答“客户端先写的那部分,如何在服务端确认后被正确地收回”。

下一节换一个角度:既然服务端是唯一裁决者,反作弊应该放在这条链路的哪一层,为什么没有任何单层能“杜绝作弊”。

阅读导航:上一节:5.3 会话、重连、顶号与连接迁移 · 下一节:6.2 反作弊应该放在架构的哪一层 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查