《游戏服务端架构·品类、状态与拓扑》7.3 观战、回放与赛事架构

从《裂隙战场》一场赛事回放对不上比分讲起,比较事件日志与快照两种回放载体,讨论观战延迟、回放体积、可验证性与赛事隔离,说明比赛服、观战服、数据导出和断线重开如何分工,并给出 internal/replay 的关键帧、重建校验、体积估算与边界处理实现。

一场对不上比分的回放

《裂隙战场》的一场线上赛事结束后,运营导出的回放和裁判记录对不上:回放里最后一波团战的击杀顺序与比分板不同,时间轴还整体偏移了两秒。比赛结果已经公布,重导一份“看起来对”的回放并不能解决问题,因为没有人能证明哪一份是真的。

排查发现,回放工具读取的是观战服录制的事件流,而比分板读取的是比赛服的结算结果。两套系统都在“记录比赛”,却使用了不同的时间基准和不同的事件过滤规则。问题不在某一个 bug,而在架构上没有一个唯一可信的回放源。

观战、回放和赛事系统经常被当成“比赛服旁边的小功能”。实际上它们是一组独立的一致性需求:观众看到的内容必须可延迟、可裁剪、可重放、可验证,而比赛本身不能因为观众而改变。

观战延迟:防作弊与防剧透

实时观战有两个不能回避的问题。

第一是防作弊。如果观战流实时广播全部位置和视野,选手只要在比赛设备上开一个观战页面,就能获得全图信息。任何“请选手不要看”的规则都不如架构上不给他这条流。

第二是防剧透。赛事直播需要解说、导播和精彩镜头,观众看到的画面通常比比赛现场晚。延迟让导播有时间切换镜头、加字幕、回放刚才的操作,也避免观众通过弹幕或社交网络把信息传回选手。

常见的做法是给观战流一个固定延迟,例如 30 到 120 秒(示例值),具体取决于赛事制作流程。延迟不是简单地在客户端缓冲:观战服需要维护自己的时间轴,按延迟后的比赛时钟消费事件,并在导播切换、暂停或重连时保持时间轴稳定。

比赛服时间轴  ----t----t----t----t----t---->
观战服时间轴  ----------t----t----t----t-->  (固定延迟)
导播输出      --------------t----t----t-->  (可再叠加制作延迟)

这条链路里,观战服是只读消费者。它不能向比赛服写入任何东西,也不能因为观众数量增加而影响比赛 tick。观战扩容、断流和重连都应该发生在观战侧,比赛服只负责把权威事件交给观战服。

延迟也不是越大越安全。延迟越大,导播与解说的临场感越差,观众互动越难做;延迟越小,防作弊和防剧透越弱。这个值应该由赛事规则和制作流程共同决定,并写进架构决策,而不是由客户端随便配置。

事件日志与快照:两种回放载体

回放系统首先要选载体。常见方案有两类,实际产品通常是两者的混合。

维度事件日志快照序列
体积小,只存状态变化大,每帧或每关键帧存完整状态
保真度依赖模拟规则,规则变了回放会变自包含,按存储的状态直接还原
可验证性可重放并比对哈希可校验每帧校验和
重建成本需要按顺序应用事件可直接跳到任意快照
版本兼容需要固定规则版本需要固定状态 schema
适合场景完整对局、赛事复核精彩镜头、快速跳转、跨版本展示

纯事件日志的优点是体积小、可解释性强:每个事件都对应一次权威状态变化,裁判能看懂“谁在什么时候做了什么”;这也是 7.1 MOBA 的房间、tick 与帧同步 里输入日志能直接用于复核的原因。缺点是回放依赖当时的模拟规则;如果下个版本改了技能数值,旧回放重放出来的结果可能不同。

纯快照的优点是自包含、跳转快,适合精彩镜头和跨版本展示;快照本身的捕获、校验和恢复边界见 4.1 内存态、持久态与快照 。缺点是体积大,而且“看到的状态”不等于“发生的原因”,裁判很难从快照反推一次击杀的完整因果链。

《裂隙战场》选择的是“事件日志加周期关键帧”:

  • 比赛服把权威事件按 tick 追加到日志。
  • 每隔固定 tick 数写一个关键帧,保存当前状态和事件序号。
  • 回放时从最近的关键帧开始,向后应用事件,既不用从开局重放,也不依赖每一帧的完整快照。
  • 关键帧和事件流都带摘要,发布前做一次完整重建校验。

这条路线把“小体积”和“可跳转”折中到一起,代价是必须认真维护事件格式和关键帧策略。关键帧太密,体积接近快照;太稀,跳转和重连要重放很久。间隔应该按对局时长、事件速率和可接受的重建时间来定。

回放体积与可验证性

回放体积不能靠感觉。至少要能回答:一场 25 分钟的比赛,事件流多大,关键帧多大,压缩后大概什么量级。

一个可解释的估算方式是固定每条事件的编码宽度:

事件 = kind(1) + actor(4) + tick(8) + delta(8) = 21 字节
关键帧 = tick(8) + count(4) + crc(4) + 每个 actor(4 + 8)

以 30Hz、每 tick 平均 10 个事件为例(示例值),事件流约 30 × 10 × 21 = 6300 字节/秒,25 分钟约 9.45 MB 未压缩(十进制);加上关键帧和索引,实际文件会更大。这个数字只用于容量规划,真实体积必须用录制的对局测量,并说明是否压缩。

可验证性是回放和普通视频最大的区别。视频只要“看起来对”,回放必须能回答“这份文件有没有被改过、能不能重建出同样的结果”。可行的做法包括:

  • 每个关键帧保存状态摘要,重建时逐帧比对。
  • 事件流使用滚动哈希或链式摘要,任何插入、删除、重排都会破坏后续摘要。
  • 发布时附带清单,记录事件总数、首末 tick、关键帧数量和最终摘要。
  • 对赛事回放做外部签名,签名覆盖清单而不是整个大文件,便于分发和校验。

这里有一个必须承认的边界:如果日志在最后一个关键帧之后被截断,仅靠关键帧校验发现不了。需要在清单里记录期望的事件总数或最终 tick,或者对完整文件做签名。不要把“关键帧都通过了”误当成“整份回放完整”。

赛事隔离:比赛服、观战服与数据导出

赛事系统和普通对局系统的第一个区别是:任何观众侧故障都不能影响比赛。

组件职责隔离要求
比赛服运行对局、判定胜负、产生权威事件独立资源池,不接受观战直连
观战服消费事件、维护延迟时间轴、服务观众只读,可独立扩容和重启
导播台切换视角、叠加图文、输出直播流通过观战服取数,不直连比赛服
数据导出生成回放、统计和裁判材料赛后异步执行,不阻塞结算
赛事控制暂停、重开、判罚独立权限通道,审计所有操作

隔离要落到网络和权限上,而不只是部署图。比赛服不暴露观战端口;观战服使用只读凭证;导播台没有比赛服的管理权限;数据导出在赛后异步执行,失败可以重试,不能拖住结算。

断线重开是赛事里最容易被低估的流程。比赛服崩溃后,是继续当前对局、从关键帧恢复,还是整局重开?三种选择对选手和观众的影响完全不同。架构上至少要准备:

  • 比赛服定期把关键帧和事件流写到持久存储。
  • 平台能按对局 ID 找到最后可信的关键帧和事件序号。
  • 重开流程有明确的时间上限和判罚规则,并由赛事控制台记录。
  • 观战服能从新的比赛时间轴重新同步,而不是把两段时间轴拼在一起。

这些不是“运维细节”,而是赛事产品能不能办下去的底线。

可运行片段:事件日志、关键帧与重建校验

下面的 internal/replay 实现了事件日志、周期关键帧、按 tick 重建、关键帧校验和体积估算。它用一个确定性的 actor 累加状态代替真实战斗状态,重点展示“日志 + 关键帧”的结构和验证边界。

// file: internal/replay/log.go
// tier: L1
// verify: go test ./internal/replay/
package replay

import (
	"encoding/binary"
	"errors"
	"fmt"
	"hash/crc32"
	"sort"
)

var (
	ErrKeyframeMismatch = errors.New("replay: keyframe does not match event log")
	ErrFutureTick       = errors.New("replay: target tick beyond last event")
	ErrNonMonotonic     = errors.New("replay: event tick went backwards")
)

// Event kinds are part of the on-disk format. Never renumber them: a replay
// written by last season's build must still decode this season.
const (
	KindSpawn  uint8 = 1
	KindMove   uint8 = 2
	KindDamage uint8 = 3
	KindScore  uint8 = 4
)

// Fixed encoding sizes live next to the reader and writer so the size estimate
// and the actual framing cannot drift apart.
const (
	EventBytes     = 1 + 4 + 8 + 8 // kind, actor, tick, delta
	KeyframeHeader = 8 + 4 + 4     // tick, entry count, crc32
	KeyframeEntry  = 4 + 8         // actor, accumulated value
)

// Event is one authoritative state change. Replays store events, not frames:
// the same input can be re-simulated, but an event that already passed the
// server's arbitration is what the observer actually saw.
type Event struct {
	Tick  uint64
	Kind  uint8
	Actor uint32
	Delta int64
}

// State is deliberately small and deterministic: a per-actor accumulator whose
// digest depends only on sorted actor IDs, never on map iteration order.
type State struct {
	Tick  uint64
	Score map[uint32]int64
}

func newState() State { return State{Score: make(map[uint32]int64)} }

// Digest covers tick and the sorted actor/value pairs. Two rebuilds that agree
// on this value agree on everything the replay format claims to preserve.
func (s State) Digest() uint32 {
	h := crc32.NewIEEE()
	var b [8]byte
	binary.BigEndian.PutUint64(b[:], s.Tick)
	h.Write(b[:])
	ids := make([]uint32, 0, len(s.Score))
	for id := range s.Score {
		ids = append(ids, id)
	}
	sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
	for _, id := range ids {
		binary.BigEndian.PutUint32(b[:4], id)
		h.Write(b[:4])
		binary.BigEndian.PutUint64(b[:], uint64(s.Score[id]))
		h.Write(b[:])
	}
	return h.Sum32()
}

func (s *State) apply(e Event) {
	s.Tick = e.Tick
	s.Score[e.Actor] += e.Delta
}

func cloneState(s State) State {
	out := State{Tick: s.Tick, Score: make(map[uint32]int64, len(s.Score))}
	for k, v := range s.Score {
		out.Score[k] = v
	}
	return out
}

// Keyframe is a periodic checkpoint. Events is the number of log entries
// already folded into State, which is how a rebuild knows where to resume.
type Keyframe struct {
	Tick   uint64
	State  State
	Events int
	Digest uint32
}

// Log is an append-only event stream with periodic keyframes.
type Log struct {
	KeyEvery uint64
	Events   []Event
	Keys     []Keyframe
	cur      State
}

// NewLog builds a log that snapshots state at least every keyEvery ticks.
func NewLog(keyEvery uint64) *Log {
	if keyEvery == 0 {
		keyEvery = 1
	}
	return &Log{KeyEvery: keyEvery, cur: newState()}
}

// Append records one event and emits a keyframe when the configured interval
// has elapsed. Backwards ticks are rejected because they would make the
// smallest-resume-index assumption false.
func (l *Log) Append(e Event) error {
	if n := len(l.Events); n > 0 && e.Tick < l.Events[n-1].Tick {
		return fmt.Errorf("%w: tick=%d previous=%d", ErrNonMonotonic, e.Tick, l.Events[n-1].Tick)
	}
	l.cur.apply(e)
	l.Events = append(l.Events, e)
	if len(l.Keys) == 0 || e.Tick-l.Keys[len(l.Keys)-1].Tick >= l.KeyEvery {
		l.Keys = append(l.Keys, Keyframe{
			Tick:   e.Tick,
			State:  cloneState(l.cur),
			Events: len(l.Events),
			Digest: l.cur.Digest(),
		})
	}
	return nil
}

// StateAt rebuilds the world at target by loading the newest keyframe at or
// before it and replaying only the events in between. The target tick is
// stamped onto the result so an idle interval still has a stable digest.
func (l *Log) StateAt(target uint64) (State, error) {
	if len(l.Events) == 0 {
		return State{}, fmt.Errorf("%w: log is empty", ErrFutureTick)
	}
	last := l.Events[len(l.Events)-1].Tick
	if target > last {
		return State{}, fmt.Errorf("%w: target=%d last=%d", ErrFutureTick, target, last)
	}
	base := newState()
	start := 0
	for i := len(l.Keys) - 1; i >= 0; i-- {
		if l.Keys[i].Tick <= target {
			base = cloneState(l.Keys[i].State)
			start = l.Keys[i].Events
			break
		}
	}
	for _, e := range l.Events[start:] {
		if e.Tick > target {
			break
		}
		base.apply(e)
	}
	base.Tick = target
	return base, nil
}

// Verify replays the log from zero and checks every keyframe against the
// recomputed digest. A tampered keyframe, a reordered event, or a truncated log
// must fail here, before the replay is published or used for ranking.
func (l *Log) Verify() error {
	s := newState()
	seq := 0
	next := 0
	for _, e := range l.Events {
		s.apply(e)
		seq++
		for next < len(l.Keys) && l.Keys[next].Events == seq {
			kf := l.Keys[next]
			computed := s.Digest()
			if kf.Digest != computed || kf.State.Digest() != computed {
				return fmt.Errorf("%w: tick=%d stored=%08x state=%08x computed=%08x",
					ErrKeyframeMismatch, kf.Tick, kf.Digest, kf.State.Digest(), computed)
			}
			next++
		}
	}
	if next != len(l.Keys) {
		return fmt.Errorf("%w: %d keyframes not covered by %d events",
			ErrKeyframeMismatch, len(l.Keys)-next, len(l.Events))
	}
	return nil
}

// SizeEstimate separates event bytes from keyframe bytes. Compression is not
// modelled here; measure the real artifact before promising a transfer budget.
type SizeEstimate struct {
	Events     int
	Keyframes  int
	EventBytes int
	KeyBytes   int
	TotalBytes int
}

// Estimate computes the uncompressed size of this log using the fixed framing
// constants above.
func (l *Log) Estimate() SizeEstimate {
	e := len(l.Events) * EventBytes
	k := 0
	for _, kf := range l.Keys {
		k += KeyframeHeader + len(kf.State.Score)*KeyframeEntry
	}
	return SizeEstimate{
		Events:     len(l.Events),
		Keyframes:  len(l.Keys),
		EventBytes: e,
		KeyBytes:   k,
		TotalBytes: e + k,
	}
}

// ProjectBytesPerMinute estimates a longer match from an observed event rate.
// Keyframe bodies grow with the number of actors, so the result is a lower
// bound; treat it as a planning example, not a measured file size.
func (l *Log) ProjectBytesPerMinute(ticksPerSecond, eventsPerTick int) int {
	if ticksPerSecond <= 0 || eventsPerTick <= 0 {
		return 0
	}
	events := 60 * ticksPerSecond * eventsPerTick
	keys := events/int(l.KeyEvery) + 1
	return events*EventBytes + keys*KeyframeHeader
}

这段代码刻意保留了三个边界。第一,Append 拒绝倒退的 tick,保证事件流可以顺序重建。第二,StateAt 从最近的关键帧恢复,只重放中间事件,避免每帧都从开局开始。第三,Verify 同时校验“关键帧自己声明的摘要”和“从事件重算出的摘要”,篡改关键帧或重排事件都会失败。

Estimate 和 ProjectBytesPerMinute 只做未压缩的规划估算,不模拟压缩率。测试覆盖了关键帧生成、按 tick 重建、摘要篡改、事件重排、日志截断、体积计算和事件速率投影;实际命令与结果会在验证记录中给出。

失败模式与兜底

失败模式现象处理边界
观战流泄露选手能获取全图信息比赛服与观战流物理隔离,观战延迟不能只靠规则禁止
观战服过载观众越多比赛越卡观战独立扩容,比赛服只写事件比赛 tick 不受观众影响
关键帧损坏重建结果与摘要不符拒绝发布,回退到上一个关键帧重试不自动“修正”摘要
日志被截断最后一段事件缺失用清单中的事件总数/末 tick 校验关键帧本身发现不了尾部截断
规则版本变化旧回放结果不同记录规则版本,保留对应回放工具不用新规则重放旧比赛
导出阻塞结算赛后统计拖住结果发布导出异步化,可重试结算不依赖导出成功

回放系统的兜底原则是“宁可拒绝,不要伪造”。一份明确标记为损坏的回放,比一份看起来正常但重建结果不同的回放安全得多,尤其当它被用于判罚或排名时。

常见错误与边界

第一,把观战当作比赛服的一个只读接口。观众连接数、导播切换和网络抖动都会反向影响比赛 tick;观战必须是独立服务。

第二,只存快照不存事件。体积大只是表面问题,更麻烦的是无法解释因果,裁判和精彩镜头工具都缺少“发生了什么”的信息。

第三,只存事件不存关键帧。跨版本、长对局和断线重连都要从开局重放,重建时间不可控。

第四,把回放校验做成“打开不报错”。真正的校验要重算摘要并和清单比对;能解码不等于内容可信。

第五,忽略回放的工具版本。事件格式和模拟规则都是回放契约的一部分,升级时必须保留旧版本的解码与重放能力。

第六,把赛事隔离做成部署图上的两个框。没有网络策略、只读凭证和审计日志的隔离,只是命名上的隔离。

小结

观战、回放与赛事架构的核心不是“把画面传给观众”,而是建立一条可延迟、可裁剪、可重放、可验证的权威事件链。比赛服负责产生事实,观战服负责延迟消费,回放系统负责保存和验证,赛事控制负责在异常时做出可审计的决定。

事件日志加关键帧是这条链路里比较稳妥的折中:体积可控、可跳转、可验证,代价是必须维护格式版本和关键帧策略。接受这个代价,回放才配得上“证据”两个字。

下一章从竞技回到 SLG:全球同服的大地图如何分片,离线推进如何与在线结算对账。

阅读导航:上一节:7.2 FPS 的专用服、命中判定与延迟补偿 · 下一节:8.1 全球同服的大地图与分片 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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