《游戏服务端编程实践》8.3.2 同步策略与丢帧补偿

帧同步只发输入也会遇到丢包、乱序和抖动。本篇比较关键帧与增量、输入冗余、追帧与等待策略,分析抖动缓冲深度对延迟的影响,给出按帧号重排并在缺口超限时强制跳过的 JitterBuffer 实现,覆盖乱序、重复和缓冲上限测试,最后列出常见错误与边界。

一、只广播输入,为什么还需要同步策略

帧同步的带宽优势来自“只传输入”。但输入包会丢、会乱序、会迟到;不同客户端收到的帧集合不一样,执行顺序自然也不一样。同步策略要解决的不是“怎么发”,而是“收到不完整的数据时,所有客户端如何做出一致决定”。

一致性优先于实时性。宁可让所有客户端一起等一帧,也不能让一部分客户端跳过缺失帧继续跑:前者只是卡顿,后者会分叉,之后任何状态校验都对不上。

二、关键帧与增量:两种广播粒度

服务器可以每帧广播完整状态快照,也可以只广播输入,再周期性插入关键帧。关键帧是“从这一帧开始,你可以无条件相信这个状态”的锚点,增量是两次关键帧之间的输入或状态差分。

方式带宽恢复能力适用场景
每帧全量状态高,随实体数增长最强,丢包只需等下一帧小房间、强一致要求
只发输入低依赖完整输入历史大房间、确定性逻辑
输入 + 周期关键帧中可从最近关键帧恢复大多数实时战斗
状态增量 + 关键帧中低需要基线,基线丢了要等关键帧实体多、状态变化稀疏

关键帧间隔是延迟与带宽的取舍。间隔太短,带宽接近全量;太长,丢包后的恢复等待变长,重连也要重放更多帧。常见做法是按“最大可接受恢复时间”反推间隔,而不是拍一个固定秒数。

关键帧还应带一个单调递增的版本号。客户端收到旧关键帧时,靠版本号丢弃,而不是靠到达顺序判断;否则乱序的关键帧会把状态倒退回去。

三、输入冗余发送:最近三帧一起带

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 同时被打满。
  • 跳帧跳过已预测执行的帧,回滚时输入日志对不上。
  • 抖动缓冲只按时间等待、不限制条数,内存随乱序包增长。
  • 重复帧不去重,技能和扣费被执行两次。
  • 每个客户端自行决定跳过哪一帧,状态从该帧开始分叉。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist