一、只广播输入,为什么还需要同步策略
帧同步的带宽优势来自“只传输入”。但输入包会丢、会乱序、会迟到;不同客户端收到的帧集合不一样,执行顺序自然也不一样。同步策略要解决的不是“怎么发”,而是“收到不完整的数据时,所有客户端如何做出一致决定”。
一致性优先于实时性。宁可让所有客户端一起等一帧,也不能让一部分客户端跳过缺失帧继续跑:前者只是卡顿,后者会分叉,之后任何状态校验都对不上。
二、关键帧与增量:两种广播粒度
服务器可以每帧广播完整状态快照,也可以只广播输入,再周期性插入关键帧。关键帧是“从这一帧开始,你可以无条件相信这个状态”的锚点,增量是两次关键帧之间的输入或状态差分。
| 方式 | 带宽 | 恢复能力 | 适用场景 |
|---|---|---|---|
| 每帧全量状态 | 高,随实体数增长 | 最强,丢包只需等下一帧 | 小房间、强一致要求 |
| 只发输入 | 低 | 依赖完整输入历史 | 大房间、确定性逻辑 |
| 输入 + 周期关键帧 | 中 | 可从最近关键帧恢复 | 大多数实时战斗 |
| 状态增量 + 关键帧 | 中低 | 需要基线,基线丢了要等关键帧 | 实体多、状态变化稀疏 |
关键帧间隔是延迟与带宽的取舍。间隔太短,带宽接近全量;太长,丢包后的恢复等待变长,重连也要重放更多帧。常见做法是按“最大可接受恢复时间”反推间隔,而不是拍一个固定秒数。
关键帧还应带一个单调递增的版本号。客户端收到旧关键帧时,靠版本号丢弃,而不是靠到达顺序判断;否则乱序的关键帧会把状态倒退回去。
三、输入冗余发送:最近三帧一起带
UDP 下单个输入包丢失是常态。让每个输入包携带最近 3 帧的输入,接收方按帧号去重,可以把“连续丢 3 个包才丢一帧输入”变成小概率事件。代价是包体变大,冗余帧数要与丢包率匹配。
冗余不是越多越好。带 10 帧会让每个包膨胀数倍,在移动网络上行更容易触发分片,反而提高丢包。3 帧是一个常见起点,实际应按链路质量调整,并对冗余字段做压缩。
服务器侧要做两件事:按帧号检测缺口,缺帧时向该玩家请求补发;超过等待上限仍补不齐,就把该玩家标记为“输入缺失”,由战斗规则决定冻结、托管还是跳过。绝不能默认用空输入补齐,那等于替玩家做了一个他没做过的操作。
补发请求也要限流。每个缺口都立刻发一次请求,在丢包率高时会产生请求风暴,进一步挤占上行带宽。常见做法是按缺口合并请求、设置最小重试间隔,并在连续失败后降级为等待关键帧。
四、追帧:加速、跳帧还是等待
客户端落后于服务器时,需要把积压的帧消化掉。三种策略各有代价:
| 策略 | 做法 | 优点 | 风险 |
|---|---|---|---|
| 加速播放 | 缩短每帧渲染间隔,逻辑帧不变 | 画面连续 | 加速有上限,长时间追不上 |
| 跳帧 | 直接丢弃部分未执行帧 | 追得快 | 丢输入会导致状态分叉 |
| 等待 | 暂停本地推进,等服务器帧 | 实现简单 | 玩家操作延迟明显 |
| 混合 | 小幅加速,超过阈值再请求快照 | 平衡体验与一致 | 阈值和退避要调 |
追帧速度必须有上限。把渲染间隔压到 1 毫秒看似能瞬间追上,实际会让输入采样和动画全部失真,还会把 CPU 打满。合理做法是限制加速倍率,超过“最大追帧时长”就停止本地推进,向服务器请求关键帧或快照。
跳帧只允许跳“确定不会再执行”的帧。已经本地预测执行过的帧不能跳,否则回滚时输入日志对不上。很多不同步事故不是网络问题,而是追帧逻辑擅自丢了几帧输入。
五、Jitter buffer:深度就是延迟
抖动缓冲按帧号排序,向上层交付连续帧。深度太小,正常抖动就会触发缺口等待;深度太大,所有输入都被推迟,玩家感到明显延迟。
深度应覆盖“典型抖动 + 少量余量”,而不是覆盖最坏情况。最坏情况靠超时跳过兜底,否则一个极端玩家会拖慢所有人。缓冲区同时要有容量上限:只按时间等待、不限制条数,乱序包持续到达时内存会无限增长。
缺口处理要区分“暂时没到”和“永远不会到”。前者等待,后者在达到阈值后跳过并记录帧号。跳过的帧必须进入一致的决策:要么所有客户端都跳过同一帧,要么由服务器明确广播“该帧作废”。各自本地决定跳过,等于制造分叉。
缓冲深度是按玩家动态调整,还是全局固定?全局固定实现简单,但一个高抖动玩家会拖慢整场;按玩家调整更公平,却要保证跳过决策仍然一致。折中方案是缓冲深度按玩家设置,跳过阈值由服务器统一广播,客户端只负责执行。
六、失败场景:丢帧直接跳过导致全场不同步
假设第 120 帧包含一次暴击判定,服务器因为丢包没收到某玩家的输入,客户端 A 收到了,客户端 B 没收到。如果 B 的缓冲直接跳过 120 帧继续执行,A 和 B 从这一帧开始伤害结果不同。之后每一帧都在放大误差,十分钟后两个客户端看到的是两场不同的战斗。
更隐蔽的是“重复帧被执行两次”。乱序重传让同一帧到达两次,如果接收方不做帧号去重,技能触发两次、金币扣两次。状态校验可能只发现总量不对,却很难定位到重复帧。
七、Go 实现:有界 JitterBuffer
JitterBuffer 用 next 记录下一交付帧,Push 拒绝重复和已交付帧,Pop 在缺口未达 maxDepth 时返回 false,达到上限后跳到最小可用帧并记录被跳过的帧号。
// file: ch08/lockstep/sync.go
// tier: L1
// verify: go test ./ch08/lockstep/
package lockstep
import "errors"
var (
ErrJitterDuplicate = errors.New("jitter: duplicate or stale frame")
ErrJitterFull = errors.New("jitter: buffer full")
)
// JitterBuffer 按帧号重排输入。缺口在缓冲达到 maxDepth 帧后强制跳过,
// 避免一个永久丢失的帧让整场战斗停住。
type JitterBuffer[T any] struct {
next Frame
maxDepth int
items map[Frame]T
skipped []Frame
}
// NewJitterBuffer 从 start 帧开始交付,maxDepth 同时是缺口等待阈值和容量上限。
func NewJitterBuffer[T any](start Frame, maxDepth int) *JitterBuffer[T] {
if maxDepth < 1 {
maxDepth = 1
}
return &JitterBuffer[T]{next: start, maxDepth: maxDepth, items: make(map[Frame]T)}
}
// Push 写入一帧。重复帧、已经交付过的帧返回 ErrJitterDuplicate;缓冲满返回 ErrJitterFull。
func (j *JitterBuffer[T]) Push(f Frame, v T) error {
if f != j.next && !f.After(j.next) {
return ErrJitterDuplicate
}
if _, ok := j.items[f]; ok {
return ErrJitterDuplicate
}
if len(j.items) >= j.maxDepth {
return ErrJitterFull
}
j.items[f] = v
return nil
}
// Pop 返回下一可交付帧。缺口尚未达到 maxDepth 时返回 ok=false,调用方应继续等待。
func (j *JitterBuffer[T]) Pop() (Frame, T, bool) {
var zero T
if v, ok := j.items[j.next]; ok {
f := j.next
delete(j.items, f)
j.next++
return f, v, true
}
if len(j.items) == 0 || len(j.items) < j.maxDepth {
return 0, zero, false
}
f := j.min()
for gap := j.next; gap != f; gap++ {
j.skipped = append(j.skipped, gap)
}
v := j.items[f]
delete(j.items, f)
j.next = f + 1
return f, v, true
}
// Skipped 返回因缺口超限被跳过的帧号副本,供指标和排障使用。
func (j *JitterBuffer[T]) Skipped() []Frame {
out := make([]Frame, len(j.skipped))
copy(out, j.skipped)
return out
}
// Buffered 返回尚未交付的帧数。
func (j *JitterBuffer[T]) Buffered() int { return len(j.items) }
func (j *JitterBuffer[T]) min() Frame {
var m Frame
first := true
for f := range j.items {
if first || f.Sub(m) < 0 {
m = f
first = false
}
}
return m
}
maxDepth 同时是等待阈值和容量上限:缓冲区不会无限增长,缺口也不会无限等待。跳过行为通过 Skipped 暴露给指标系统,便于判断是偶发丢包还是链路持续异常。
八、测试:乱序、重复、缺口等待上限
测试覆盖乱序到达后按序交付、重复帧拒绝、缺口在阈值内等待、达到阈值后强制跳过,以及缓冲满时拒绝新帧。
// file: ch08/lockstep/sync_test.go
// tier: L1
// verify: go test ./ch08/lockstep/
package lockstep
import (
"errors"
"testing"
)
func mustPush[T any](t *testing.T, j *JitterBuffer[T], f Frame, v T) {
t.Helper()
if err := j.Push(f, v); err != nil {
t.Fatalf("push frame %d: %v", f, err)
}
}
func TestJitterBufferReorders(t *testing.T) {
j := NewJitterBuffer[string](1, 8)
mustPush(t, j, 3, "c")
mustPush(t, j, 1, "a")
mustPush(t, j, 2, "b")
for _, want := range []struct {
f Frame
v string
}{{1, "a"}, {2, "b"}, {3, "c"}} {
f, v, ok := j.Pop()
if !ok || f != want.f || v != want.v {
t.Fatalf("pop = %d,%q,%v; want %d,%q,true", f, v, ok, want.f, want.v)
}
}
if _, _, ok := j.Pop(); ok {
t.Fatal("empty buffer should not deliver")
}
}
func TestJitterBufferRejectsDuplicates(t *testing.T) {
j := NewJitterBuffer[int](10, 4)
mustPush(t, j, 10, 1)
if err := j.Push(10, 2); !errors.Is(err, ErrJitterDuplicate) {
t.Fatalf("want ErrJitterDuplicate, got %v", err)
}
if f, v, ok := j.Pop(); !ok || f != 10 || v != 1 {
t.Fatalf("pop = %d,%d,%v; want 10,1,true", f, v, ok)
}
if err := j.Push(10, 3); !errors.Is(err, ErrJitterDuplicate) {
t.Fatalf("delivered frame accepted: %v", err)
}
}
func TestJitterBufferGapWaitAndSkip(t *testing.T) {
j := NewJitterBuffer[int](1, 3)
mustPush(t, j, 1, 1)
mustPush(t, j, 3, 3)
mustPush(t, j, 4, 4)
if f, v, ok := j.Pop(); !ok || f != 1 || v != 1 {
t.Fatalf("pop = %d,%d,%v; want 1,1,true", f, v, ok)
}
if _, _, ok := j.Pop(); ok {
t.Fatal("gap 2 must wait while below maxDepth")
}
mustPush(t, j, 5, 5)
f, v, ok := j.Pop()
if !ok || f != 3 || v != 3 {
t.Fatalf("pop = %d,%d,%v; want forced 3,3,true", f, v, ok)
}
if got := j.Skipped(); len(got) != 1 || got[0] != 2 {
t.Fatalf("skipped = %v, want [2]", got)
}
}
func TestJitterBufferGapFilledAndBounds(t *testing.T) {
j := NewJitterBuffer[int](1, 4)
mustPush(t, j, 1, 1)
mustPush(t, j, 3, 3)
if _, _, ok := j.Pop(); !ok {
t.Fatal("frame 1 should deliver")
}
if _, _, ok := j.Pop(); ok {
t.Fatal("frame 2 still missing")
}
mustPush(t, j, 2, 2)
for _, want := range []int{2, 3} {
f, v, ok := j.Pop()
if !ok || f != Frame(want) || v != want {
t.Fatalf("pop = %d,%d,%v; want %d", f, v, ok, want)
}
}
full := NewJitterBuffer[int](1, 2)
mustPush(t, full, 1, 1)
mustPush(t, full, 3, 3)
if err := full.Push(4, 4); !errors.Is(err, ErrJitterFull) {
t.Fatalf("want ErrJitterFull, got %v", err)
}
}
注意缺口测试的推送顺序:先让缓冲低于阈值,确认 Pop 返回 false;再补一帧让缓冲达到阈值,确认下一次 Pop 跳过缺口。只测“最终能交付”会漏掉等待逻辑,而等待逻辑正是延迟的来源。
九、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch08/lockstep && go vet ./ch08/lockstep/ && go test ./ch08/lockstep/ -count=1
ok gspractice/ch08/lockstep 0.371s
小结
同步策略的核心是让所有客户端对“缺帧时怎么办”做出一致决定。输入冗余降低丢帧概率,关键帧提供恢复锚点,追帧要有速度上限,抖动缓冲要有深度和容量双重边界。缺口可以等待,但等待必须有上限;跳过可以发生,但必须被记录并保持一致。
常见错误与边界
- 缺帧时用空输入补齐,替玩家执行了不存在的操作。
- 追帧不设上限,渲染间隔被压到失真,CPU 同时被打满。
- 跳帧跳过已预测执行的帧,回滚时输入日志对不上。
- 抖动缓冲只按时间等待、不限制条数,内存随乱序包增长。
- 重复帧不去重,技能和扣费被执行两次。
- 每个客户端自行决定跳过哪一帧,状态从该帧开始分叉。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。