快照 + 增量的回放架构,以及工程实践中的内存、IO、存储问题与解法

录像回放是竞技游戏中一项高价值、高复杂度的基础功能。它既服务于玩家(精彩时刻、社交分享),也服务于研发(debug 还原、行为分析、AI 训练)。本文记录一款重度状态同步射击游戏的录像回放系统的完整技术方案——从方案选型、核心架构到工程中遇到的每一个真实问题与解法。

01需求背景:为什么要做录像回放

录像回放的需求来自三个方向:

玩家侧
精彩时刻
对局结束自动回放最高评分片段,支持分享炫耀,提升中长期留存
研发侧
Debug 还原
战斗过程完整还原,方便定位线上问题
数据侧
行为分析
基于录像数据进行 AI 研究、外挂识别、大数据辅助研发

技术背景:项目采用重度状态同步架构。服务端通过 RPC 与 Property 机制,将 AOI 范围内的 Entity 状态同步到各客户端;客户端根据收到的数据创建对象并推演逻辑。

状态同步机制概览
服务端
权威状态
Property 同步
RPC 调用
客户端 AAOI 视野内
客户端 BAOI 视野内

Entity 的属性变更通过 Property 实时同步,行为指令通过 RPC 下发。录像即记录这两类数据。

02方案选型:三种主流回放方案对比

市面上的录像回放大致有三种技术路线:

方案 A
客户端视频录制
+ 实现简单
- 性能与磁盘开销大
- 无法数据调试
- 无法切换视角
荒野行动 · 和平精英 · LOLM
方案 B
客户端录制网络消息
+ 支持调试和切换视角
- 受客户端掉线影响
- 受 AOI 限制(只有本机视野)
- 版本兼容问题
PUBG · 和平精英 · 穿越火线
采用
方案 C
服务端录制网络消息
+ 全局视角,数据稳定
+ 支持调试和切换视角
- 版本兼容问题
- 服务端存储成本
DOTA2 · 守望先锋 · 荒野行动

选型依据

三种方案并非互斥——和平精英同时使用 A(精彩时刻视频)和 B(淘汰回放)。我们因重度状态同步 + 全局视角需求,选择方案 C,实现上接近守望先锋的思路。

03核心架构:快照 + 增量

录像回放的核心是两类数据:

数据类型内容作用
快照(Snapshot) 某一时刻全局所有 Entity 的完整 Property 数据 恢复世界状态的"锚点"
增量(Delta) 两个快照之间所有 Entity 的变化(RPC、Property 变更、创建/销毁等) 驱动世界状态向前推演
录像链路(服务端 → 存储)
战斗
服务端
全量快照
对局开始时保存所有 Entity 的 Property
增量记录
RPC / Property 变更 / 创建销毁
10s 分片
每片含 1 快照 + 10s 增量
zlib 压缩(压缩率 12%)
录像
微服务
Meta 索引
分片列表 / 时间映射
分片存储
MongoDB / 云对象存储
分享码
Redis 映射 + Lua 原子操作
回放链路(存储 → 客户端)
录像
微服务
查询 Meta
定位目标分片
流式下发
约 450KB/片,0.5s/次
按需拉取,边播边下
客户端
本地缓存
命中 100ms / 未命中走网络
解压 + 恢复快照
走断线重连路径
增量推演
播放 / 暂停 / 跳帧 / 倍速
回放 AOI
视野外仅更新 Property

录像:服务端在对局开始后保存一次全量快照,之后持续记录所有 Entity 的增量变化。数据基于服务器全局视角(不受 AOI 限制),经分片压缩后存入数据库。

回放:客户端按需拉取分片,优先读本地缓存;恢复快照复用断线重连路径,再按增量逐步推演——本质上是把"服务端同步"替换为"从录像文件读取"。

跳帧的数学基础

快照 + 增量的结构天然支持跳帧(快进/快退)——任意时刻的状态都可以被推导:

T=3s 的状态 = 快照₁ + Δ₁

T=10s 的状态 = 快照₁ + Δ₁ + Δ₂

T=15s 的状态 = 快照₁ + Δ₁ + Δ₂ + Δ₃

分片时间线 — 快照 + 增量结构
快照₁
Δ
Δ
Δ
Δ
Δ
快照₂
Δ
Δ
Δ
Δ
Δ
快照₃
Δ
Δ
Δ
Δ
Δ
快照₄
Δ
Δ
Δ
Δ
Δ
快照₅
Δ
Δ
Δ
Δ
Δ
快照₆
Δ
Δ
Δ
跳转 T=32s
0s10s20s30s40s50s53s

▲ 跳转到 T=32s 时,只需恢复快照₄(T=30s)+ 推导 2 秒增量,而非从头推导 32 秒数据。

跳帧有两种实现方式:

方式逻辑耗时特征
从头推导 恢复开局快照 → 推导所有增量至目标时间 目标越远耗时越长
就近快照恢复 找到目标时间最近的快照 → 仅推导剩余增量 耗时稳定可控

通过实测数据来决策:

快照恢复耗时
1300ms
恢复一次完整游戏世界
推导 5 秒增量
150ms
约 160 帧数据
推导 15 分钟增量
24s
从头推导不可接受

当对局超过 43 秒(≈ 1300ms ÷ 150ms × 5s)时,"从头推导"就不如"就近恢复"了。线上对局平均时长约 7 分钟,因此采用就近快照恢复方案。

04性能优化:从不可用到工程达标

核心架构确定后,工程实践中遇到了一系列真实的性能问题。每个问题都指向一个清晰的数字。

问题一:快照生成耗时

服务端帧率 30(每帧 33ms),快照生成不能超过一帧:

Linux 虚拟机
41ms — 超标
Windows 开发机
32ms
物理机 Docker
27ms

问题虚拟机环境超出 33ms 帧预算,且同时开 2 局时帧预算缩至 16ms。

解法多线程并行化:利用多线程优势减少等待时间,总运算量不变。优化后所有平台均在一帧以内完成。

问题二:内存爆炸

15 分钟原始录像数据
2.8 GB
一次性加载容易引发客户端 crash
单台服务器 96 局
268 GB
服务器仅 128GB 内存,远超硬件限制

解法两步走——分片 + 压缩

  1. 分片:以 10 秒为单位切割录像,每个分片包含一个快照 + 10 秒增量
  2. 压缩:zlib 压缩,平均压缩率 12%(即压缩至原大小的 12%)
单个分片内部结构(10 秒)
头部
Meta
全量快照
所有 Entity Property
增量数据
RPC + Property 变更 × 300 帧
校验
CRC
← zlib 压缩整体 → 压缩后 ≈450KB / 片
服务端 96 局内存占用268 GB → 3.6 GB
268 GB 原始
3.6 GB
客户端单次加载2.8 GB → 38 MB
2.8 GB 整局
38 MB

问题三:流式传输

单局录像约 35 MB,全部下载耗时过长。结合分片方案,自然引出流式传输:

客户端请求分片
微服务查询 Meta
从存储拉取分片
下发 ≈450KB/片
单次分片传输大小
≈450 KB
请求到可用耗时
0.5s
分片时长 10s,远大于传输时间

只要传输时间小于分片播放时长,用户就感知不到等待。

05客户端本地缓存

减少服务器成本、加快加载速度的关键——本地缓存:

本地缓存命中
100ms
直接读取本地文件
请求服务器
1.6s
网络往返 + 存储读取
缓存命中
客户端请求分片
Hash Slot 定位文件
读取本地文件
解压 + 播放
≈100ms
缓存未命中
客户端请求分片
Hash Slot 未找到
请求录像微服务
微服务查询存储
下载 + 写入缓存
解压 + 播放
≈1.6s

缓存清理策略

  • 距离上次观看超过 1 周的对局自动清理
  • 缓存超过 10 局时,按最近观看时间排序淘汰最久的
  • 检测到文件损坏时全量清理

IO 性能优化:O(n) → O(1)

初版实现在清理时需要扫描缓存目录(O(n) 的磁盘 IO),极端情况下导致卡顿:

关键数字HDD 磁盘单次 IO 响应 >15ms,DDR4 内存响应 <100ns——差距达 15 万倍

解法:采用 Hash Slot 映射,slot 范围 [1, 10]。通过 hash 值直接定位文件,覆盖与删除变为 O(1);仅对内存中的 Meta 数据做 O(n) 遍历。

Hash Slot 文件映射示意
对局ID: "abc123"
hash() % 10
Slot #7
replay_7.dat
对局ID: "xyz789"
hash() % 10
Slot #3
replay_3.dat
#1空闲
#2空闲
#3xyz789
#4空闲
#5空闲
#6空闲
#7abc123
#8空闲
#9空闲
#10空闲

文件命名的编码陷阱

对局 ID 需要 Base64 编码后用作文件名,但踩了两个坑:

问题原因解法
Base64 含 / / 在文件系统中是目录分隔符 改用 Base32 编码(不含特殊字符),或 Base16/MD5
大小写不敏感冲突 Windows 文件系统不区分大小写

最终方案比较:

编码方式可逆性冲突概率膨胀率
Base32 + 填充替换单向不可逆≈(1/32)¹⁵ 量级适中
Base16可逆无冲突2x
MD5不可逆1/2¹²⁸固定 32 字符

06回放 AOI:解决性能瓶颈

录像数据基于服务端全局视角(不带 AOI),这意味着回放时客户端需要处理全图所有 Entity——性能压力极大。

中端机开启 AOI 后帧率提升
+14 帧
达到 30+ FPS(高通 765 级别设备)
主要收益来源
渲染 + 逻辑
AOI 外 Entity 跳过渲染与逻辑处理

回放中的 AOI 方案与正常游戏有本质区别:

正常游戏
AOI 外 = 销毁
● AOI 内:完整实体
● AOI 外:从内存清除
录像回放
AOI 外 = 仅 Property
● AOI 内:渲染 + 逻辑
● AOI 外:仅更新 C++ Property
正常游戏录像回放
AOI 内 正常逻辑 + 渲染 正常逻辑 + 渲染
AOI 外 从内存清除 仅更新 C++ 层 Property,不渲染不处理逻辑
进入 AOI 服务端下发快照 → 创建 Entity 触发重连逻辑 → 恢复渲染与逻辑

关键点:AOI 外的 Entity 不销毁,只是停止渲染和逻辑处理,Property 保持更新。这样在切换视角时可以快速恢复,无需重新创建。

07回放适配的核心洞察:复用断线重连

回放的"恢复世界状态"与断线重连的"恢复世界状态"在本质上是同一件事:

网络闪断
连上服务器时,服务端下发状态数据,客户端执行 on_reconnect 恢复
退出重进
走重建 Entity 流程恢复游戏世界
进入 AOI
根据 Entity 快照重建,执行 on_reconnect 恢复逻辑
录像回放跳帧
根据快照恢复 → 与上述三个场景完全一致

核心结论

做好断线重连 ≈ 适配了回放的大部分机制。回放的快照恢复走断线重连路径是最小成本方案。

断线重连路径复用
网络闪断
退出重进
进入 AOI
录像跳帧
↓ ↓ ↓ ↓
on_reconnect 统一恢复路径
恢复快照
重建 Entity
恢复特效
恢复动画
重建定时器

但回放的恢复要求高于断线重连,还有一些额外需要处理的问题:

  • 特效恢复:如烟雾弹——生命周期绑定 Entity,恢复时根据记录的开始时间 Seek 到对应时刻
  • 动画恢复:如 NPC 表演——从 RPC 控制改为 Property 记录当前播放状态
  • 定时器问题:跳帧后定时器状态需要重建
  • 视角差异:攻守方视角不同的机制(如幻影类技能)需要特殊处理

08分享码:外围社交功能

分享码让玩家可以把对局分享给其他人观看。

存储选型

方案读写 QPS特性
MongoDB 单机读 3W / 写 2.3W冷数据访问慢
Redis 单机读写 11W全内存,QPS 稳定

分享码与对局 ID 的映射存储在 Redis 中。100 万个 Key-Value 预计仅占 0.3 GB 内存。

分布式环境的原子性问题

分享码生成为区间 [1万, 100万] 的随机数。多进程环境下"先读后写"存在竞争:

解法方式取舍
Lua 原子脚本 将 Redis 的读写操作封装到 Lua 脚本中一次执行 彻底解决竞争
区间分割 N 个进程各分配不重叠的子区间,各自在子区间内随机 扩容时需重新分配

容量评估:假设日均 30 万玩家各分享 2 次,峰值 3 小时内 50%,单次冲突率约 0.000064,满足需求。

09云存储成本估算

录像数据的持续增长带来存储成本压力:

单局录像大小
≈35 MB
日均对局数
≈20 万
月存储成本
≈2.4 万
按 0.12 元/GB/月

这是一个需要持续关注的运营成本,后续可通过录像过期策略、冷热分层等手段优化。

10应用场景

录像回放系统建成后,衍生出多个应用方向:

场景说明
精彩时刻对局结束自动回放最高评分片段,支持社交分享
反外挂基于录像数据进行深度学习,识别异常行为
AI 研究离线分析玩家行为数据,辅助 AI 训练
玩家复盘支持自由切换视角进行对局分析与学习
线上 Debug完整还原战斗过程,快速定位问题

11方案总结

一张表回顾整个系统的核心决策与数字:

问题解法关键数字
方案选型服务端录制 + 客户端推演全局视角、支持多视角切换
跳帧策略就近快照恢复43 秒为分界点
快照性能多线程并行化全平台 <33ms
内存压力10 秒分片 + zlib 压缩268 GB → 3.6 GB
传输延迟流式分片传输≈450 KB/片,0.5s
缓存性能Hash Slot + 内存 MetaIO 操作 O(n) → O(1)
回放 AOIAOI 外仅更新 Property中端机 +14 帧
回放适配复用断线重连路径最小适配成本
分享码Redis + Lua 原子操作100 万码,0.3 GB

本文基于内部技术分享整理,已隐去项目代号、人员信息及部分敏感数据。