《MMO 服务器开发》13.2 服务器回滚与和解

预测让客户端先走一步,但权威永远在服务端:一旦服务端的答案和本地不一致,客户端必须回滚到权威状态、把未确认的输入重放一遍。本节把 13.1 的输入缓冲接到和解逻辑上,讲清裁剪-复位-重放三步、用一条测试给回滚的代价定上界(重放条数只跟 RTT 有关),并给出和解误差阈值的取法与「小误差平滑、大误差瞬移」的判据。

13.2 服务器回滚与和解

13.1 的 Predictor 会一直往前跑,缓冲区里的未确认输入也一直在涨。真正让它收敛的是服务端回来的那条权威消息——里面带着服务端算出的位置和它处理到的确认号。客户端收到之后要做的事,就是把「本地擅自往前跑的那一段」撤销掉,再基于权威状态重算。这套动作叫和解(reconcile),撤销那一步叫回滚(rollback)。

这一节要讲清三件事:和解的精确步骤、回滚的代价到底有没有上界、以及校正回来之后什么时候该「平滑地挪过去」、什么时候该「直接瞬移」。

和解的三步:裁剪、复位、重放

收到权威状态和确认号之后,和解按固定顺序做三件事:

  1. 裁剪:把缓冲区里所有 Seq <= ack 的输入删掉。这些输入的效果已经体现在服务端下发的 serverPos 里了,如果留着重放就会重复计算。
  2. 复位:把本地预测位置直接设成 serverPos。注意这里是覆盖,不是插值——逻辑层必须是精确的权威状态,平滑是渲染层的事(13.3 讲)。
  3. 重放:把裁剪后剩下的输入,按顺序在 serverPos 上重新跑一遍。跑完得到的位置,才是「在权威基础上、叠加了尚未确认的操作」的正确预测。

重放用的还是同一个 Step。这就是 13.1 坚持「纯函数」的原因:如果客户端有自己的移动逻辑分支(比如本地做了惯性、服务端没做),重放出来的结果永远对不上。

用一个具体例子走一遍。假设客户端发了 10 条输入,服务端确认到第 6 条,且服务端完全同意前 6 条的结果:

步骤Pending 内容本地位置
收到 resp 前1..1050(10 步 × 5)
裁剪 Seq<=67,8,9,1050
复位到 serverPos7,8,9,1030(服务端算出的 6 步 × 5)
重放 7..107,8,9,1050(30 + 4 步 × 5)

重放完位置又回到 50,和服务端同意时的预期完全一致,err = 0。这个例子说明一个反直觉的事实:和解不是「改位置」,而是「保证位置正确」。大多数时候它算完的结果和预测的一样,玩家感觉不到它的存在——它只在服务端真的不同意时才产生可见的修正。

从零实现:Reconcile

下面这段是 L1,go test 可以直接跑。它在 Predictor 上加了 Reconcile 方法,返回三个值:修正后的位置、重放的条数、以及本次校正的位移幅度:

// file: internal/predict/reconcile.go
// tier: L1
// verify: go test ./internal/predict/ -run TestReconcile -v

package predict

import "math"

// Reconcile is the "server corrected me" step. The server has just sent its
// authoritative position and the highest input Seq it applied. Reconcile:
//
//  1. drops every input the server has already accounted for,
//  2. resets the predicted position to the server's authority,
//  3. replays the inputs that are still in flight.
//
// It returns the corrected position, how many inputs it replayed (the cost of
// the rollback), and how far the correction moved the client (the divergence
// that the smoothing layer has to hide).
func (p *Predictor) Reconcile(serverPos Vec2, ackedSeq uint32) (pos Vec2, replayed int, err float64) {
	before := p.Pos

	// 1. Drop every acked input. Reusing the backing array keeps this allocation
	// free on the common case where only a few inputs are dropped per update.
	keep := p.Pending[:0]
	for _, in := range p.Pending {
		if in.Seq > ackedSeq {
			keep = append(keep, in)
		}
	}
	p.Pending = keep

	// 2. Reset to the authority and replay what is still pending. The replay set
	// is bounded by the round-trip time in ticks, not by how long the client has
	// been running -- this is the cost ceiling of rollback.
	pos = serverPos
	for _, in := range p.Pending {
		pos = Step(pos, in)
		replayed++
	}
	p.Pos = pos

	// 3. The correction magnitude, before smoothing. A large error means the
	// server disagreed about something more than a tick of movement.
	err = dist(before, pos)
	return pos, replayed, err
}

func dist(a, b Vec2) float64 {
	dx := a.X - b.X
	dy := a.Y - b.Y
	return math.Sqrt(dx*dx + dy*dy)
}

keep := p.Pending[:0] 这一行值得单独说。它复用底层数组来就地压缩缓冲区,所以常规情况(每次只丢掉几条)下和解是零分配的。这个包运行在客户端每 tick 一次的路径上,每秒几十次、每次几条,如果每次都新建切片,GC 压力会很明显。

Reconcile 只做「完全复位」这一种和解。还有一种更温和的变体叫增量和解:不回退到 serverPos,而是把 serverPos 和本地预测的差值直接加到当前位置上。它省掉了重放,但误差会被「记住」,如果服务端的偏差是持续性的(比如服务端的地图比客户端大一圈),增量和解会让误差一直累积。默认用完全复位,增量和解只在输入是「绝对量」而非「相对量」时才安全。

回滚的代价上界

和解里唯一可能「贵」的是重放循环。它的迭代次数等于 len(p.Pending),也就是在途输入数。这个数由 RTT 决定:RTT 是几个 tick,服务端就落后几个 tick,缓冲区里就大约有几条输入。

换句话说,回滚的代价和「客户端已经连了多久」无关,只和网络延迟有关。一个连了 8 小时的玩家,和一个刚进游戏的玩家,只要延迟一样,每次和解重放的条数就一样。这是预测-校正方案能长期稳定运行的关键——如果代价随连接时长增长,迟早会失控。

下面这条测试把这条上界跑出来。它让服务端固定落后 rttTicks 个 tick,跑 2000 步,然后断言重放条数精确等于 rttTicks:

// snippet: internal/predict/reconcile_test.go(节选)

// TestRollbackCostTracksRTT bounds the cost of rollback: the replay set size is
// set by how many inputs are in flight, i.e. the round-trip time in ticks, and
// does not grow with how long the client has been connected.
func TestRollbackCostTracksRTT(t *testing.T) {
	for _, rttTicks := range []int{1, 3, 8, 16} {
		p := &Predictor{}
		var server Vec2
		const total = 2000
		for i := 0; i < total; i++ {
			in := Input{Seq: p.NextSeq(), MoveX: 1, MoveY: 0}
			p.ApplyLocalInput(in)
			// The server is rttTicks behind: it acks the input that arrived
			// rttTicks steps ago.
			if i-rttTicks >= 0 {
				server = Step(server, Input{Seq: uint32(i - rttTicks + 1), MoveX: 1, MoveY: 0})
			}
		}
		_, replayed, _ := p.Reconcile(server, uint32(total-rttTicks))
		if replayed != rttTicks {
			t.Fatalf("rtt=%d: replayed=%d, want %d", rttTicks, replayed, rttTicks)
		}
	}
}

本机实测:rttTicks 取 1、3、8、16 时,重放条数分别为 1、3、8、16,与在途输入数严格相等。换成数字:60Hz 下 RTT 100ms 约等于 6 个 tick,一次和解重放 6 次 Step——一个纯浮点加法的函数,6 次调用的成本是纳秒量级,远低于一帧的预算。所以回滚本身不是性能问题;真出问题的是它触发的校正,也就是接下来要说的误差阈值。

顺带说一个「什么时候不该回滚」的判据:如果服务端和客户端的模拟步长不一样(服务端 10Hz、客户端 60Hz),重放就对不齐,此时应该让客户端每 tick 合并多个输入再喂给 Step,而不是把服务端也改成 60Hz——服务端 tick 率是全局成本,不该为一个玩家的手感让步。

服务端那一半:输入队列与每 tick 消费

前面都在讲客户端怎么和解,但和解的输入——serverPos 和 ackSeq——是服务端给的。服务端这一侧要做的事同样简单,但必须严格:

  1. 按连接维护一个输入队列,CmdMoveReq 到达就入队,按 inputSeq 排序、去重(inputSeq <= 已消费 的直接丢弃,防止重放攻击和乱序)。
  2. 每个世界 tick 消费一条(或按服务器 tick 率与客户端出包率的比例消费多条),用 Step 更新权威位置。
  3. 回 CmdMoveResp,带上 ackSeq = 已消费的最大 inputSeq 和当前权威位置。

这里有一个反直觉的取舍:服务端不应该为某个玩家「追帧」——收到一堆积压输入就一次性全算完。这会让该玩家在服务端瞬移,还可能被利用来刷移动距离。正确做法是固定每 tick 的消费上限,积压的输入排队,服务端用「慢慢追上你」的姿态消化;如果队列太长,直接丢弃最旧的并回一个较大的 ack,让客户端知道「这一段我不认」。

顺带说重连。玩家断线重连后 inputSeq 从哪继续是个坑:如果客户端重置序号,服务端可能把新输入当成「旧序号重放」丢掉。稳妥做法是把 inputSeq 和会话绑定,重连时由服务端下发「下一个期望序号」,客户端从这个号继续——这和第 6 章的重连票据是同一套机制的两个侧面。

一个完整的往返,用时间轴串起来:

t=0ms    客户端: 采样 in#7 -> 本地预测 X=35 -> 发 CmdMoveReq(inputSeq=7)
t=20ms   服务端: 收到,入队
t=33ms   服务端 tick: 消费 in#7,权威 X=35,回 CmdMoveResp(ack=7, pos=35)
t=50ms   客户端: 收到 resp,Reconcile(pos=35, ack=7)
                 Pending 裁到 in#8..,在 35 上重放,得到新的预测位置

从 t=0 到 t=50 这 50ms 里,玩家一直在按输入,本地一直画在预测位置——这就是「先走一步」省下来的手感。而 t=50 那一刻的和解,把本地状态拉回到权威基线上,保证它不会越飘越远。

和解误差阈值

Reconcile 的第三个返回值 err 是本次校正的位移幅度——「客户端原来画在哪」和「和解后应该在哪」之间的距离。它不是给你丢弃的,而是给渲染层做决策用的:

  • err 很小(小于一个 tick 的位移,比如 5 个单位以内):说明只是网络抖动导致的轻微偏差,下一帧平滑地挪过去就行,玩家看不出来。
  • err 中等(几个 tick 的位移):平滑的时间要拉长一点,避免「橡皮筋」式的回拉感。
  • err 很大(远超几个 tick,比如突然几百个单位):这不是抖动,是服务端否决了客户端的预测——常见于反外挂的位移校验、穿墙修正、或者传送。这时不能再平滑,必须直接瞬移,否则角色会以肉眼可见的速度「滑」过一段不该走的路径。

阈值不能拍脑袋定,应该从「一 tick 走多远」推导。本节的 Speed = 5.0,60Hz 下一 tick 走 5 个单位,那么:

err 区间含义渲染处理
< 5(< 1 tick)抖动立即或极短平滑
5 ~ 25(1~5 tick)轻微不同步平滑,约 5~10 帧吃完
> 25(> 5 tick)服务端否决瞬移 + 可选的特效提示

动作 MMO 里这个上限要压得更紧,回合制可以放宽。关键是阈值必须是可配置的常量,而不是写死在和解逻辑里——同一套代码,不同的阈值就是不同的手感。

还有一个反直觉的点:不要为了让 err 变小而调预测逻辑。err 的大小主要由网络质量决定,不由预测精度决定。强行「预测得更准」通常是靠猜,猜对了 err 小,猜错了 err 更大,得不偿失。预测只负责「先走一步」,把误差抹平是渲染层的活。

输入丢了怎么办

客户端发的 CmdMoveReq 可能在半路丢掉(UDP 场景下很常见)。服务端的行为直接决定预测是否稳定:

  • 如果服务端只是「没收到第 7 条,继续处理第 8 条」:位置在服务端少走一步,之后每次 ack 都落后客户端一步,err 持续不为零,玩家被反复轻微校正。
  • 正确做法是让服务端也做缺失检测:发现 inputSeq 跳号时,服务端应该停下来等重传,或者明确回一个「第 7 条我没收到」。前者要求输入可靠传输(KCP,第 5 章),后者要求客户端能重发未确认的输入。

本节为了聚焦和解逻辑,假设输入可靠到达。真实项目里,移动输入通常走可靠通道——丢了就重传,反正就几字节;因为「输入丢失」造成的持续偏差,比「重传延迟」更难处理。

什么时候干脆别预测

预测有成本:多一份客户端逻辑、多一套和解路径、多一类「预测错了」的边界。下面这些情况,老实等服务端反而更好:

场景为什么别预测
操作频率低、判定复杂(开箱、交易、进副本)玩家本就能接受一次短等待,预测不划算且猜错更别扭
结果依赖随机数或他人(抽卡、暴击、组队怪血量)客户端没有权威随机数,预测等于瞎猜
强反外挂的竞技移动用「权威 + 插值」硬扛延迟,把可被利用的预测面压到最小

判断依据是收益 vs 复杂度:预测省下的是「一个 RTT 的手感」,如果这个操作本身不是高频、不是手感敏感,那这笔账就不划算。移动、射击、技能释放这类高频动作值得预测;其余大多数交互不值得。

一句话总结服务端这一半的职责:服务端不预测、不追帧、只按 tick 消费输入并如实回 ack。它越「迟钝」、越可预测,客户端的和解就越简单。

和解实现里的三个易错点

  1. 裁剪用 > 而不是 >=:确认号 ack 表示「服务端已经处理到第 ack 条」,所以 Seq == ack 也要丢。写成 >= 会把 ack 本身留下,重放时多算一步,位置每次和解都多走一格。
  2. 复位必须在重放之前:顺序反了(先重放再复位)等于把重放的结果又覆盖掉,和解完全失效,而且不会报错,只会表现为「预测一直不准」。
  3. err 要在复位前后算清楚:before 取的是旧的预测位置,不是复位后的位置。如果写成在复位之后取 before,err 恒为 0,平滑层永远收不到该有的校正量,玩家会看到位置硬跳。

这三条都不复杂,但都属于「写错了也能编译、也能跑,只是行为不对」的类型——所以本卷强调每段代码都要有断言把它钉住。

关键数字速查

量值来源
一次和解重放条数等于在途输入数TestRollbackCostTracksRTT
60Hz、RTT 100ms重放约 6 次 Step本机实测
抖动阈值< 1 tick 位移本节 Speed = 5.0
平滑区间1~5 tick 位移本节经验值
否决阈值> 5 tick 位移本节经验值

本节小结

  • 和解三步:裁剪已确认输入、复位到权威状态、重放未确认输入。
  • 大多数时候和解的结果与预测一致(err = 0),它只在服务端不同意时才产生可见修正。
  • 重放条数等于在途输入数,只跟 RTT 有关,不随连接时长增长——这是回滚的代价上界。
  • 回滚本身是纳秒级开销;真正要花心思的是校正:小误差平滑、大误差瞬移。
  • 误差阈值按「一 tick 走多远」推导,且必须可配置。
  • err 由网络质量主导,别试图靠「猜得更准」去压缩它。

阅读导航:上一节:13.1 输入缓冲与本地预测 · 下一节:13.3 插值与平滑 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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