13.2 服务器回滚与和解
13.1 的 Predictor 会一直往前跑,缓冲区里的未确认输入也一直在涨。真正让它收敛的是服务端回来的那条权威消息——里面带着服务端算出的位置和它处理到的确认号。客户端收到之后要做的事,就是把「本地擅自往前跑的那一段」撤销掉,再基于权威状态重算。这套动作叫和解(reconcile),撤销那一步叫回滚(rollback)。
这一节要讲清三件事:和解的精确步骤、回滚的代价到底有没有上界、以及校正回来之后什么时候该「平滑地挪过去」、什么时候该「直接瞬移」。
和解的三步:裁剪、复位、重放
收到权威状态和确认号之后,和解按固定顺序做三件事:
- 裁剪:把缓冲区里所有
Seq <= ack的输入删掉。这些输入的效果已经体现在服务端下发的serverPos里了,如果留着重放就会重复计算。 - 复位:把本地预测位置直接设成
serverPos。注意这里是覆盖,不是插值——逻辑层必须是精确的权威状态,平滑是渲染层的事(13.3 讲)。 - 重放:把裁剪后剩下的输入,按顺序在
serverPos上重新跑一遍。跑完得到的位置,才是「在权威基础上、叠加了尚未确认的操作」的正确预测。
重放用的还是同一个 Step。这就是 13.1 坚持「纯函数」的原因:如果客户端有自己的移动逻辑分支(比如本地做了惯性、服务端没做),重放出来的结果永远对不上。
用一个具体例子走一遍。假设客户端发了 10 条输入,服务端确认到第 6 条,且服务端完全同意前 6 条的结果:
| 步骤 | Pending 内容 | 本地位置 |
|---|---|---|
| 收到 resp 前 | 1..10 | 50(10 步 × 5) |
裁剪 Seq<=6 | 7,8,9,10 | 50 |
| 复位到 serverPos | 7,8,9,10 | 30(服务端算出的 6 步 × 5) |
| 重放 7..10 | 7,8,9,10 | 50(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——是服务端给的。服务端这一侧要做的事同样简单,但必须严格:
- 按连接维护一个输入队列,
CmdMoveReq到达就入队,按inputSeq排序、去重(inputSeq <= 已消费的直接丢弃,防止重放攻击和乱序)。 - 每个世界 tick 消费一条(或按服务器 tick 率与客户端出包率的比例消费多条),用
Step更新权威位置。 - 回
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。它越「迟钝」、越可预测,客户端的和解就越简单。
和解实现里的三个易错点
- 裁剪用
>而不是>=:确认号ack表示「服务端已经处理到第 ack 条」,所以Seq == ack也要丢。写成>=会把 ack 本身留下,重放时多算一步,位置每次和解都多走一格。 - 复位必须在重放之前:顺序反了(先重放再复位)等于把重放的结果又覆盖掉,和解完全失效,而且不会报错,只会表现为「预测一直不准」。
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 插值与平滑 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。