一场对不上比分的回放
《裂隙战场》的一场线上赛事结束后,运营导出的回放和裁判记录对不上:回放里最后一波团战的击杀顺序与比分板不同,时间轴还整体偏移了两秒。比赛结果已经公布,重导一份“看起来对”的回放并不能解决问题,因为没有人能证明哪一份是真的。
排查发现,回放工具读取的是观战服录制的事件流,而比分板读取的是比赛服的结算结果。两套系统都在“记录比赛”,却使用了不同的时间基准和不同的事件过滤规则。问题不在某一个 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 全球同服的大地图与分片 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。