快照 + 增量的回放架构,以及工程实践中的内存、IO、存储问题与解法
录像回放是竞技游戏中一项高价值、高复杂度的基础功能。它既服务于玩家(精彩时刻、社交分享),也服务于研发(debug 还原、行为分析、AI 训练)。本文记录一款重度状态同步射击游戏的录像回放系统的完整技术方案——从方案选型、核心架构到工程中遇到的每一个真实问题与解法。
01需求背景:为什么要做录像回放
录像回放的需求来自三个方向:
技术背景:项目采用重度状态同步架构。服务端通过 RPC 与 Property 机制,将 AOI 范围内的 Entity 状态同步到各客户端;客户端根据收到的数据创建对象并推演逻辑。
权威状态
Entity 的属性变更通过 Property 实时同步,行为指令通过 RPC 下发。录像即记录这两类数据。
02方案选型:三种主流回放方案对比
市面上的录像回放大致有三种技术路线:
客户端视频录制
- 性能与磁盘开销大
- 无法数据调试
- 无法切换视角
客户端录制网络消息
- 受客户端掉线影响
- 受 AOI 限制(只有本机视野)
- 版本兼容问题
服务端录制网络消息
+ 支持调试和切换视角
- 版本兼容问题
- 服务端存储成本
选型依据
三种方案并非互斥——和平精英同时使用 A(精彩时刻视频)和 B(淘汰回放)。我们因重度状态同步 + 全局视角需求,选择方案 C,实现上接近守望先锋的思路。
03核心架构:快照 + 增量
录像回放的核心是两类数据:
| 数据类型 | 内容 | 作用 |
|---|---|---|
| 快照(Snapshot) | 某一时刻全局所有 Entity 的完整 Property 数据 | 恢复世界状态的"锚点" |
| 增量(Delta) | 两个快照之间所有 Entity 的变化(RPC、Property 变更、创建/销毁等) | 驱动世界状态向前推演 |
服务端
微服务
微服务
录像:服务端在对局开始后保存一次全量快照,之后持续记录所有 Entity 的增量变化。数据基于服务器全局视角(不受 AOI 限制),经分片压缩后存入数据库。
回放:客户端按需拉取分片,优先读本地缓存;恢复快照复用断线重连路径,再按增量逐步推演——本质上是把"服务端同步"替换为"从录像文件读取"。
跳帧的数学基础
快照 + 增量的结构天然支持跳帧(快进/快退)——任意时刻的状态都可以被推导:
T=3s 的状态 = 快照₁ + Δ₁
T=10s 的状态 = 快照₁ + Δ₁ + Δ₂
T=15s 的状态 = 快照₁ + Δ₁ + Δ₂ + Δ₃
▲ 跳转到 T=32s 时,只需恢复快照₄(T=30s)+ 推导 2 秒增量,而非从头推导 32 秒数据。
跳帧有两种实现方式:
| 方式 | 逻辑 | 耗时特征 |
|---|---|---|
| 从头推导 | 恢复开局快照 → 推导所有增量至目标时间 | 目标越远耗时越长 |
| 就近快照恢复 | 找到目标时间最近的快照 → 仅推导剩余增量 | 耗时稳定可控 |
通过实测数据来决策:
当对局超过 43 秒(≈ 1300ms ÷ 150ms × 5s)时,"从头推导"就不如"就近恢复"了。线上对局平均时长约 7 分钟,因此采用就近快照恢复方案。
04性能优化:从不可用到工程达标
核心架构确定后,工程实践中遇到了一系列真实的性能问题。每个问题都指向一个清晰的数字。
问题一:快照生成耗时
服务端帧率 30(每帧 33ms),快照生成不能超过一帧:
问题虚拟机环境超出 33ms 帧预算,且同时开 2 局时帧预算缩至 16ms。
解法多线程并行化:利用多线程优势减少等待时间,总运算量不变。优化后所有平台均在一帧以内完成。
问题二:内存爆炸
解法两步走——分片 + 压缩:
- 分片:以 10 秒为单位切割录像,每个分片包含一个快照 + 10 秒增量
- 压缩:zlib 压缩,平均压缩率 12%(即压缩至原大小的 12%)
问题三:流式传输
单局录像约 35 MB,全部下载耗时过长。结合分片方案,自然引出流式传输:
只要传输时间小于分片播放时长,用户就感知不到等待。
05客户端本地缓存
减少服务器成本、加快加载速度的关键——本地缓存:
缓存清理策略
- 距离上次观看超过 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) 遍历。
文件命名的编码陷阱
对局 ID 需要 Base64 编码后用作文件名,但踩了两个坑:
| 问题 | 原因 | 解法 |
|---|---|---|
Base64 含 / |
/ 在文件系统中是目录分隔符 |
改用 Base32 编码(不含特殊字符),或 Base16/MD5 |
| 大小写不敏感冲突 | Windows 文件系统不区分大小写 |
最终方案比较:
| 编码方式 | 可逆性 | 冲突概率 | 膨胀率 |
|---|---|---|---|
| Base32 + 填充替换 | 单向不可逆 | ≈(1/32)¹⁵ 量级 | 适中 |
| Base16 | 可逆 | 无冲突 | 2x |
| MD5 | 不可逆 | 1/2¹²⁸ | 固定 32 字符 |
06回放 AOI:解决性能瓶颈
录像数据基于服务端全局视角(不带 AOI),这意味着回放时客户端需要处理全图所有 Entity——性能压力极大。
回放中的 AOI 方案与正常游戏有本质区别:
● AOI 外:从内存清除
● AOI 外:仅更新 C++ Property
| 正常游戏 | 录像回放 | |
|---|---|---|
| AOI 内 | 正常逻辑 + 渲染 | 正常逻辑 + 渲染 |
| AOI 外 | 从内存清除 | 仅更新 C++ 层 Property,不渲染不处理逻辑 |
| 进入 AOI | 服务端下发快照 → 创建 Entity | 触发重连逻辑 → 恢复渲染与逻辑 |
关键点:AOI 外的 Entity 不销毁,只是停止渲染和逻辑处理,Property 保持更新。这样在切换视角时可以快速恢复,无需重新创建。
07回放适配的核心洞察:复用断线重连
回放的"恢复世界状态"与断线重连的"恢复世界状态"在本质上是同一件事:
核心结论
做好断线重连 ≈ 适配了回放的大部分机制。回放的快照恢复走断线重连路径是最小成本方案。
但回放的恢复要求高于断线重连,还有一些额外需要处理的问题:
- 特效恢复:如烟雾弹——生命周期绑定 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云存储成本估算
录像数据的持续增长带来存储成本压力:
这是一个需要持续关注的运营成本,后续可通过录像过期策略、冷热分层等手段优化。
10应用场景
录像回放系统建成后,衍生出多个应用方向:
| 场景 | 说明 |
|---|---|
| 精彩时刻 | 对局结束自动回放最高评分片段,支持社交分享 |
| 反外挂 | 基于录像数据进行深度学习,识别异常行为 |
| AI 研究 | 离线分析玩家行为数据,辅助 AI 训练 |
| 玩家复盘 | 支持自由切换视角进行对局分析与学习 |
| 线上 Debug | 完整还原战斗过程,快速定位问题 |
11方案总结
一张表回顾整个系统的核心决策与数字:
| 问题 | 解法 | 关键数字 |
|---|---|---|
| 方案选型 | 服务端录制 + 客户端推演 | 全局视角、支持多视角切换 |
| 跳帧策略 | 就近快照恢复 | 43 秒为分界点 |
| 快照性能 | 多线程并行化 | 全平台 <33ms |
| 内存压力 | 10 秒分片 + zlib 压缩 | 268 GB → 3.6 GB |
| 传输延迟 | 流式分片传输 | ≈450 KB/片,0.5s |
| 缓存性能 | Hash Slot + 内存 Meta | IO 操作 O(n) → O(1) |
| 回放 AOI | AOI 外仅更新 Property | 中端机 +14 帧 |
| 回放适配 | 复用断线重连路径 | 最小适配成本 |
| 分享码 | Redis + Lua 原子操作 | 100 万码,0.3 GB |
本文基于内部技术分享整理,已隐去项目代号、人员信息及部分敏感数据。