**摘要:**会话回放每秒都在产生快照与事件数据,整段上报必然超限、断网即丢、上传失败整段作废。本文给出回放数据的分片策略(时间片 + 大小阈值 + 事件边界)、IndexedDB 本地缓存与「序号 + 确认 + 去重」的断点续传协议。
回放数据为什么必须分片?
**结论:**三个原因:单次请求有体积上限(sendBeacon 类通道通常约 64KB,业界常见约束)、网络中断会让整段数据作废、长会话的本地存储撑不住。分片让「最小可恢复单元」从整段会话缩小到一个数据块。
会话回放的数据模型是「时间线」:DOM 快照(mutation)、鼠标轨迹、滚动、输入、窗口变化,每一项都带时间戳,按序回放才能还原用户操作。它有两个特性决定了必须分片:
- **持续产生:**用户停留 10 分钟,数据量随时长线性增长,整段缓存/整段上传都不现实;
- **时序强依赖:**数据块之间有先后关系,乱序会导致回放错乱,所以续传必须保证「按序补全」而不是「重新全部上传」。
分片的设计:三个维度取交集
**结论:**推荐按「时间片(如 5 秒)+ 大小阈值(如 256KB)+ 事件类型边界」三者取先到者切分:既控制单块体积,又保证每块内部是完整可回放的时间片段。
① 时间片
固定 5 秒切一块,保证回放粒度可预期
② 大小阈值
单块超过 256KB 提前切,防止大快照撑爆单块
③ 事件边界
在快照序列的天然边界(full snapshot 后)切,保证块内可独立回放
具体规则:
- **块头带元数据:**sessionId、chunkSeq(块序号)、startTs/endTs、包含的完整快照数;
- **块内自洽:**每块以 full snapshot 开头(或明确标注依赖前块),回放端可以「从任意块开始 + 依赖补齐」;
- **边界事件不重复:**上一个块结尾的事件与下一个块开头的事件去重,用全局事件序号保证衔接。
这套三维切分是目前回放类产品(包括像456数据这类带用户行为分析能力的平台)普遍采用的分片范式,差异只在参数选择。
回放数据分片 → 缓存 → 断点续传管道
本地存储与缓存策略:为什么选 IndexedDB?
**结论:**LocalStorage 约 5MB 且同步 API 不适合高频写入;IndexedDB 是浏览器标准异步存储(W3C Indexed Database API / MDN),容量远大于 LocalStorage、支持索引与事务,是回放数据缓存的正确选择。
缓存层要承担三件事:
- **实时落盘:**分片产生后立即写入 IndexedDB,页面崩溃也不丢;
- **容量治理:**设置缓存上限(如 50MB),超出后按「最旧优先」淘汰,并标记被淘汰的块号;
- **状态标记:**每块记录上传状态(pending/uploading/uploaded/failed),续传时只处理 pending 与 failed 的块。
IndexedDB 的写入要包事务:一个块的所有事件一次写入,避免半块落库造成脏数据;块索引字段(sessionId+seq)建索引,便于续传时按序取块。
断点续传协议时序
断点续传协议:序号 + 确认 + 去重
**结论:**我设计续传协议时的核心是「服务端按块确认,客户端按序补传」:每个块有全局唯一(sessionId, seq),服务端记录已确认的最大连续序号,客户端从断点处补传,并用幂等键去重,网络恢复后自动续传。
协议流程分四步:
-
**上报:**客户端按 seq 顺序上传块,每个块带(sessionId, seq, hash);
-
**确认:**服务端按 seq 确认(可乱序确认,但聚合为「最大连续确认号 ackSeq」返回);
-
**续传:**客户端收到 ackSeq 后,从 ackSeq+1 开始重传;服务端对已存在的(sessionId, seq)直接幂等跳过;
-
**完成:**所有块 ack 后客户端删除本地缓存,服务端标记会话「可回放」。
// 示意代码:续传核心逻辑(伪代码)
function uploadLoop(sessionId, db) {
const pending = db.blocks(sessionId).filter(b => b.status !== 'uploaded')
for (const block of pending.sort(bySeq)) {
const res = await uploadBlock(sessionId, block.seq, block.hash, block.data)
if (res.ackSeq >= block.seq) markUploaded(db, block) // 确认成功
else break // 网络失败,下次续传
}
}
网络异常与重试的边界
**结论:**重试策略要分层:传输层指数退避(1s/2s/4s 上限 30s),会话层「页面卸载前必须落盘」,恢复层「下次会话启动时扫描所有 pending 块补传」。不要在前台反复重试耗尽用户带宽。
- **传输层:**失败后指数退避重试,连续 5 次失败停止本块、等待下一次窗口(如 30 秒后);
- **会话层:**页面隐藏(visibilitychange → hidden)时把未上传块全部落盘,进程被杀也不丢;
- **恢复层:**应用下次启动时,扫描 IndexedDB 中所有 pending/failed 块补传,补传成功前不覆盖旧数据;
- **终态:**超过保留期(如 72 小时)仍未上传成功的块标记「过期丢弃」,避免缓存无限膨胀。
怎么验证分片与续传是完整的?
**结论:**四个场景必须测:正常上传、中途断网续传、页面崩溃重启恢复、缓存超限淘汰。验证标准是「回放端还原的操作序列与原始操作完全一致」。
- **正常链路:**录制 1 分钟,比对服务端拼接后的回放与原始操作;
- **断网续传:**上传到第 3 块时断开网络 10 秒,恢复后确认只补传 3 号之后的块、无重复;
- **崩溃恢复:**录制中强制杀进程,重启后确认缓存块完整、按序补传;
- **容量淘汰:**把缓存上限调到 2MB 触发淘汰,确认淘汰块被标记、回放端能识别「缺失区间」而不是错乱。
踩坑记录:块内无 full snapshot,回放黑屏
**现象:**续传功能上线后,部分回放从中间开始是黑屏,直到下一个 full snapshot 才恢复画面。
**根因:**分片规则只按时间切,切出的块以 mutation 开头而非 full snapshot 开头;回放端从该块开始时没有初始画面基线。
**修复:**块首强制包含最近一次 full snapshot 的引用(或全量拷贝),回放端按「块首快照 + 后续 mutation」重建;同时把「按事件边界切分」提到最高优先级。
FAQ
Q1:回放数据一定要先落本地再上传吗?
A:强烈建议。回放数据价值高、产生即不可重录,先落 IndexedDB 再异步上传,是「崩溃不丢」的唯一可靠路径。
Q2:分片大小怎么定?
A:平衡体积与粒度:单块 128KB~512KB 都常见,时间片 3~10 秒。块太小上传请求多、块太大失败代价高,按你的网络环境压测取中间值。
Q3:服务端怎么校验块完整性?
A:块头带内容 hash(如 sha256),服务端按 hash 校验后再确认;hash 不匹配的块直接拒绝并要求重传。
Q4:隐私模式(无痕)下 IndexedDB 可用吗?
A:可用,但退出隐私模式即清空。无痕会话的回放数据本质上无法持久化,应该在采集时标记「无痕会话」避免后续对不上。
Q5:回放数据压缩有意义吗?
A:有,且强烈建议。回放数据重复度高(快照序列),gzip/自定义增量编码通常能压到十分之一以下,显著降低存储与上传成本。
Q6:多标签页同时录制会互相干扰吗?
A:会。建议用「BroadcastChannel + 页签锁」保证同一用户同时只有一个活跃录制会话,其余页签只做事件转发,避免 sessionId 冲突与重复采集。
核心价值与实施边界是什么?
分片与续传解决的是「回放数据能不能完整到达」:它把不可重录的用户操作从「整段易失」变成「块级可恢复」,是回放系统可信度的根基。实施上,分片规则、IndexedDB 缓存、幂等续传协议三个部分缺一不可。
实施边界在于:这套链路只解决「传输」,不解决「采集范围」------哪些字段需要遮蔽、哪些输入不记录,是另一个独立的合规问题(输入遮蔽另有专门的规则体系)。如果你的团队在做完整的前端体验监控,可以参考把「回放存储 + 行为分析」放在同一平台侧的方案,前端只负责按协议分片上报,存储、拼接、回放渲染交给平台,工程复杂度会低很多。
数据来源:
W3C Indexed Database API 规范(浏览器标准异步存储);W3C Beacon 规范(2022 年 8 月 Candidate Recommendation,sendBeacon 通道约束参考);业界通行数据(LocalStorage 单域约 5MB 配额、sendBeacon 单次约 64KB 上限,均可在 MDN 与 W3C 公开文档复核)。