
用户操作录制回放实践JSON 体积失控、回放卡顿、密码明文进快照一个项目踩出三个坑如果你接手过一个“用户操作录制回放”相关的需求大概率经历过类似的崩溃瞬间明明录的是文本事件流存下来一看几分钟的数据 JSON 比同时间的视频还大回放时想还原用户操作轨迹画面却一帧一帧跳像在看幻灯片更让人后背发凉的是打开录制快照的 JSON用户登录密码就明晃晃地躺在某个input事件的value字段里。这三个问题表面上分别属于“存储体积”“回放性能”“数据安全”但你在排查之后会发现它们的根因往往是同一个录制回放系统的数据模型在一开始就没有被认真设计。录什么、不录什么、用什么结构存、按什么节奏回放、敏感字段在哪一层拦截这些决策在项目启动的当天就决定了后续的成本。这篇文章不打算复述“录制回放怎么做”的入门概念而是围绕这三个真实痛点展开先讲清楚录制回放的底层原理和数据模型再给出 JSON 体积压缩、回放性能优化、敏感信息脱敏三条可落地的解决路径最后补充项目实践中的排查清单和工程建议。建议收藏备用尤其是准备做行为分析类产品的同学这篇文章能帮你少踩一大圈坑。1. 这篇文章真正要解决的问题1.1 录制回放为什么值得做用户操作录制回放本质上是把用户在页面上的操作过程“录下来、存下来、能重放”。它的价值在于产品经理可以看用户真实操作路径测试团队可以自动复现 BUG反爬团队可以识别异常操作习惯客服可以还原用户反馈的问题场景。最常见的实现方式有两种一种是直接录屏幕视频另一种是录制 DOM 事件流。录视频直观但视频文件体积大、无法结构化检索、不方便定位某个具体元素的点击而且受录制权限和分辨率影响很大。所以很多团队选择录制事件流用 JSON 描述用户操作回放时再通过脚本把这些操作重新执行一遍。事件录制方案看起来“轻量”但一旦工程化很快就会遇到标题里那三个问题。这篇文章就是要解释为什么一个看似简单的事件流会在存储、回放和安全三个维度同时失控。1.2 三个问题的共同根因先说结论这三个问题不是孤立的它们在同一个数据模型里互相放大。JSON 体积大是因为事件采集太“全”高频事件、冗余字段、重复快照全部落库没有做差分和精简。回放卡顿是因为回放引擎逐条解析大 JSON、逐条触发 DOM 操作没有批处理和渲染调度浏览器频繁重排重绘。密码明文快照是因为采集端把input事件的值原样记录服务端也没有做落库前的脱敏。理解了这一点你就会发现解决这三件事其实是在同一个数据管道上做三道闸门。采集端决定录什么存储端决定怎么存怎么压缩回放端决定怎么还原怎么渲染。任何一道闸门失守后面都要付出数倍代价。1.3 谁最需要看这篇文章如果你的项目正在做或准备做以下事情这篇文章会非常对胃口自建行为采集 SDK需要把用户点击、输入、滚动变为结构化数据。做前端自动化测试平台需要录制用户操作后自动回放生成测试脚本。做客户成功或异常复现工具需要让客服查看用户操作快照。做内部管理系统的操作审计既要回放操作过程又不能泄露敏感字段。它不适合只做简单埋点统计的场景。如果你的目标只是“统计按钮点击次数”那直接上报一条聚合数据就够了没必要进入录制回放这个深水区。2. 录制回放的核心原理与 JSON 数据模型2.1 事件录制比视频录制好在哪在展开具体问题之前先把录制回放的技术原理讲清楚。事件录制的核心思路是不要把页面渲染结果录下来而是把“用户的意图”录下来。用户点了哪个按钮、在哪个输入框输入了什么、滚动到了什么位置、触发了哪个下拉菜单这些行为都可以抽象为事件。回放时用一个回放引擎重新执行这些事件浏览器自然会还原出当时的页面状态。这样做的好处是数据量理论上远小于视频因为事件序列是高度结构化的。可以按事件类型、元素路径、时间区间做检索和分析。可以暂停、变速、跳过某段无用操作比视频回放灵活。不涉及屏幕隐私录制只记录业务操作本身。但注意我用的词是“理论上远小于视频”。实践中一个设计失控的事件录制系统JSON 体积完全可以超过同时间的视频这正是第三部分要展开的。2.2 一套常见的录制数据结构先看一个最朴素的录制数据结构很多项目的第一个版本都是这样的{ sessionId: session_20260101_001, userId: u_1024, timestamp: 1735689600000, events: [ { type: mousemove, x: 120, y: 340, target: #main-content div.card button, ts: 1735689600100 }, { type: input, target: #login-username, value: demo_user, ts: 1735689600300 }, { type: input, target: #login-password, value: password123, ts: 1735689600400 }, { type: click, target: #login-btn, ts: 1735689600500 } ] }这段结构非常直观一个会话下面挂一个事件数组数组里依次记录鼠标移动、输入、点击等操作。如果只是做几十秒的演示这套结构完全够用。但它存在几个隐患mousemove是高频事件用户在页面上移动鼠标 10 秒钟可能产生几百个坐标点。input事件在用户输入每个字符时都会触发输入一个 20 位密码会记录 20 条事件。value字段把用户输入的明文原样写进 JSON密码问题从这里开始。2.3 JSON 字段顺序会引发隐蔽问题有一个细节容易被忽略JSON 的字段顺序在 Java 后端序列化时可能被打乱。很多项目用HashMap存放事件字段HashMap 不保证顺序。如果某个事件在录制端是这样组织的{ type: input, target: #login-username, value: demo_user, ts: 1735689600300 }但经过 Java 的Map中转后序列化结果可能变成{ ts: 1735689600300, value: demo_user, target: #login-username, type: input }表面看没什么影响但如果你的差分算法依赖字段顺序做哈希比较或者回放引擎按字段顺序解析就会产生非常隐蔽的 bug。在实际项目中凡是需要与前端约定格式的 JSON 结构后端统一用LinkedHashMap或定义好的 POJO 做序列化不要用无序 Map 兜底。这个问题很小但排查成本极高建议一早就避开。3. JSON 体积为什么可能比视频还大3.1 先做一个小估算很多人听到“JSON 比视频还大”的第一反应是“不可能吧”但算一笔账就明白了。假设网页上有一个输入操作用户输入 20 个字符每个input事件在 JSON 里大约是 80 到 120 字节包含字段名、目标选择器、时间戳、value 值。20 个字符就是 20 条事件约 2KB。假设用户在页面上停留了 5 分钟其中有 30 秒在移动鼠标。mousemove事件每 50ms 触发一次1 秒就是 20 条30 秒就是 600 条。每条如果 100 字节就是 60KB。再叠加滚动事件、焦点切换、hover 样式变化、一两个全量 DOM 快照5 分钟操作产生的数据轻轻松松超过 200KB。而一个 5 分钟的 720p 视频H.264 压缩后通常在 1MB 到 3MB 之间。200KB 还没有超过视频但如果把录制时长拉长到 30 分钟或者把高频事件全量落库JSON 超过视频是很容易发生的。如果系统还支持“每 N 秒截一张页面快照”那问题更严重。每个快照都包含整棵 DOM 的序列化结果一次快照就是几十 KB 到几 MB。3.2 体积膨胀的三个典型原因从工程经验看JSON 体积失控基本逃不出三个原因。第一高频事件全量落库。这是最普遍的问题。mousemove、scroll、resize这类事件在浏览器里触发频率极高如果录制 SDK 不节流、不抽样、不合并直接完整记录数据量会瞬间爆炸。正确做法是鼠标移动和滚动事件只记录方向性变化或按固定时间间隔抽样而不是记录每一个像素点。第二重复字段和全量快照。每个事件都重复携带完整的元素选择器一段很长的类名拼起来可能上百个字符每个事件都重复携带sessionId、userId、pageUrl等公共字段。整段 JSON 里可能有 30% 是重复信息。全量 DOM 快照则是另一个极端它把页面整棵 DOM 树序列化一次就抵得上一百个普通事件。第三value 字段过度记录。输入框的值是一个不断变化的中间态用户输入“abc”时事件序列里会依次出现a、ab、abc。如果只需要知道“最终这个框最终变成了什么”中间的过渡值完全可以不录。这个优化通常能把 input 相关数据减少 50% 以上。3.3 全量快照 vs 差分快照再单独说说快照。快照的目的是让回放时恢复页面结构。如果你每 3 秒存一次全量 DOM 快照那一个复杂页面的录制数据量是惊人的。更合理的是“快照 差分”模式在会话开始时存一次基础快照记录页面初始 DOM 结构。之后只记录事件和 DOM 增量变化。回放时先加载基础快照再依次应用变化。差分快照的示例结构{ sessionId: session_20260101_001, version: 1, snapshotHash: 7a9f3c2d1e, baseSnapshot: 存初始 DOM 对应的哈希或压缩索引而不是整个 DOM, events: [ { type: domChange, action: addClass, target: #nav-menu, value: active, ts: 1735689606000 }, { type: domChange, action: setText, target: #order-total, value: ¥199.00, ts: 1735689607000 } ] }使用差分快照之后同样的一段用户操作JSON 体积通常可以下降到原来的 20% 到 30%。4. 解决 JSON 体积问题压缩、差分与精简4.1 方案一用差分存储替代全量存储差分存储是录制回放系统最关键的一步优化。核心思想是不要把“当时页面上有什么”重复存储而是把“用户做了什么改变”存下来。以点击按钮为例。全量快照会记录按钮的样式、文案、父级结构而差分事件只需要记录“在某个选择器上触发了 click”。两者信息量相同体积差距却很大。用 Python 做一个简单的差分逻辑可以这样理解import hashlib import json def build_node_key(node): # 实际项目中会结合 tagName、id、class、text 等生成稳定的选择器 return f{node.get(tag, )}|{node.get(id, )}|{node.get(class, )} def diff_snapshot(prev, current): diff_ops [] prev_keys {build_node_key(n) for n in prev} current_keys {build_node_key(n) for n in current} for key in current_keys - prev_keys: diff_ops.append({action: add, key: key}) for key in prev_keys - current_keys: diff_ops.append({action: remove, key: key}) for key in current_keys prev_keys: old_node next(n for n in prev if build_node_key(n) key) new_node next(n for n in current if build_node_key(n) key) old_hash hashlib.md5(json.dumps(old_node, sort_keysTrue).encode()).hexdigest() new_hash hashlib.md5(json.dumps(new_node, sort_keysTrue).encode()).hexdigest() if old_hash ! new_hash: diff_ops.append({action: update, key: key, value: new_node}) return diff_ops这个示例只是展示了差分的思想先对比前后两次快照只记录增删改。实际项目里会把差分的对象从“整棵 DOM”拆成“特定节点的文本、样式、属性变化”粒度更细体积也能控制得更小。4.2 方案二字段级精简与公共字段抽取差分存储之外另一个立竿见影的优化是把 JSON 里大量重复的公共字段抽离出来。比如把这段数据{ sessionId: session_20260101_001, userId: u_1024, pageUrl: https://example.com/order/detail, events: [ { sessionId: session_20260101_001, userId: u_1024, type: click, target: #pay-btn, ts: 1735689608000 } ] }优化成{ meta: { sessionId: session_20260101_001, userId: u_1024, pageUrl: https://example.com/order/detail, startTs: 1735689600000 }, events: [ { t: click, s: #pay-btn, d: 8000 } ] }可以看到事件数组里不再重复携带会话、用户、页面等公共字段字段名也做了精简。ts也改成了相对时间可以进一步压缩。这种结构在真实项目里很常见虽然牺牲了一点可读性但换来的是明显的体积下降。如果担心字段缩写导致后续维护困难可以在系统内部维护一份字段映射表导出给外部团队时再还原成完整字段名。4.3 方案三存储前做 Gzip 压缩即使完成了差分和精简录制数据依然是文本。文本天然适合压缩。在服务端落库前可以用 Gzip 对 JSON 做一次压缩。Java 中的标准写法// 文件路径src/main/java/com/example/recorder/compress/GzipCompressor.java import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.zip.GZIPOutputStream; public class GzipCompressor { public static byte[] compress(byte[] data) throws IOException { ByteArrayOutputStream bos new ByteArrayOutputStream(); try (GZIPOutputStream gzip new GZIPOutputStream(bos)) { gzip.write(data); } return bos.toByteArray(); } }调用时把待存储的 JSON 字符串转成字节数组压缩后存入数据库或对象存储。回放时先读字节再用GZIPInputStream解压还原 JSON。经过压缩后结构化的事件数据通常还能再减少 60% 到 80%。需要注意的是Gzip 压缩不改变 JSON 本身的数据结构它只是让数据在磁盘和网络中更小。如果你要按 JSON 字段做数据库查询和索引不能直接对压缩字节做条件查询。通常的做法是明细事件以压缩 JSON 落库同时抽取出少量关键字段如 sessionId、事件类型、时间戳建立关系表用于快速检索。4.4 存储表设计与清理策略对录制回放系统来说存储设计不能只有一张大表。一个比较稳妥的分层方案是数据层级内容存储方式保留周期元数据表sessionId、userId、页面、开始时间、设备MySQL / PG 关系表长期事件明细差分事件流、压缩后的 JSON对象存储或列式存储30 到 90 天回放索引用于查询的关键字段Elasticsearch / 宽表与事件明细一致归档数据历史会话全量冷存储 压缩按业务要求这里最重要的建议是不要把所有数据都放在 MySQL 里当 JSON 长文本存也不要只放对象存储而不建索引。前者会让数据库表迅速膨胀后者会让“回放某用户某次操作”变成一次全量扫描。5. 回放性能优化从逐事件应用到批量渲染5.1 卡顿不是回放算法的唯一问题JSON 体积优化之后第二个要解决的问题是回放卡成幻灯片。很多人认为卡顿是因为“数据太大了”但这只是表面原因。更深入的根因在于真实用户操作是连续的而回放引擎是按事件逐个执行的。如果录制数据里有 1000 个事件回放引擎在一个循环里逐个去更新 DOM浏览器就会在每一帧中执行大量布局和绘制操作尤其是输入、滚动这类事件密集场景卡顿几乎是必然的。5.2 用 requestAnimationFrame 做批量更新前端回放优化有一个非常关键的技术手法不要在一个同步循环里把所有 DOM 操作都执行完而是把多个操作攒到浏览器的下一帧里批量执行。// 文件路径web-replay/src/engine/replayScheduler.js const pendingOps []; let rafId null; function enqueueOp(op) { pendingOps.push(op); if (rafId null) { rafId requestAnimationFrame(() { // 取出当前批次的所有操作并依次执行 const batch pendingOps.splice(0, pendingOps.length); batch.forEach(applyOpToDom); rafId null; }); } } function applyOpToDom(op) { const el document.querySelector(op.target); if (!el) return; if (op.type click) { el.click(); } else if (op.type input) { el.value op.value; } else if (op.type scroll) { window.scrollTo(op.x, op.y); } }这段代码的核心是把高频的操作先放进一个队列等到浏览器进入下一帧渲染时再统一处理。这样可以避免一秒钟之内触发几十次 DOM 更新导致的布局抖动。回放引擎里这是一个非常实用的优化手段。如果回放场景中某个时间段有大量事件例如用户在 2 秒内快速输入了一整段文字还可以做“时间轴压缩”将该时间段内的事件合并成一次批量赋值只保留最终状态。这样既保证回放的视觉一致性又大幅减少 DOM 操作次数。5.3 回放时要避免强制同步布局除了事件批处理回放引擎还容易踩一个前端性能的经典坑强制同步布局。如果你在回放脚本里连续执行“读 offsetWidth - 设置样式 - 再读 offsetTop - 再设置样式”这种操作浏览器会被迫多次重新计算布局性能急剧下降。更稳妥的做法是尽量避免在事件处理器中读取布局属性把读和写分开。使用document.createDocumentFragment()批量插入 DOM。如果只是恢复页面状态可以直接用innerHTML一次性重建某个容器的内容而不是逐节点更新。对高频滚动回放优先使用requestAnimationFrame配合window.scrollTo而不是逐帧设置scrollTop。回放性能优化的最终目标是让回放引擎的 CPU 占用保持在较低水平同时保证时间轴上的事件顺序不被破坏。这两者需要平衡。5.4 快照回放与事件回放的取舍还有一种回放策略值得考虑如果 JSON 里保留的是基础快照 差分事件回放时可以先把基础快照一次性渲染出来再按时间轴播放差分事件。也就是说快照部分走“整页面重建”路线事件部分走“增量更新”路线。这种混合策略的优点是回放开始时页面状态是完整的不会出现“页面空白然后按钮一个个冒出来”的尴尬效果后续的用户操作再通过事件流逐步还原视觉上更接近真实操作。6. 敏感信息脱敏把密码挡在录制与存储之外6.1 为什么快照里会出现密码标题里那句“用户密码直接展示在快照中”听起来很吓人但在真实项目里非常常见。原因很简单录制 SDK 监听input事件时会把event.target.value原样写入 JSON。用户输入密码的每一个字符都会变成一条带着明文密码的input事件落到后端。如果你还做了全量 DOM 快照那问题更严重——快照里会完整保留input typepassword value...的值。哪怕密码输入框当时的值已经被浏览器自动填充快照里也可能出现密文或占位符不同浏览器的表现还不一样。更隐蔽的是这些问题不会在开发阶段暴露因为开发时很少有人真的用测试账号在输入框里敲密码。一旦上线第一批真实用户就会把密码送进你的录制库。6.2 三层脱敏防线针对密码泄露不能把希望寄托在“采集端别录”这一道防线上。正确的做法是三层防线第一层采集端强制脱敏。录制 SDK 在记录input事件时先判断当前输入框的type是否为password或autocomplete是否包含敏感字段如果是直接记录占位符******不回传真实值。// 文件路径web-sdk/src/recorder/eventRecorder.js function getInputEventValue(inputElement) { const type (inputElement.type || ).toLowerCase(); const name (inputElement.name || ).toLowerCase(); if (type password) { return ******; } const sensitiveKeywords [password, pwd, passwd, secret, token, card, cvv]; if (sensitiveKeywords.some(keyword name.includes(keyword))) { return ******; } return inputElement.value; }第二层服务端落库前二次脱敏。不要相信采集端因为采集端可能因为版本号差异、白名单配置错误等原因漏掉某些字段。服务端收到 JSON 后在反序列化或落库前再做一次全局扫描和清洗。Java 中可以提供一个简单的脱敏工具类// 文件路径src/main/java/com/example/recorder/sensitive/SensitiveDataMasker.java import java.util.Set; public class SensitiveDataMasker { private static final SetString SENSITIVE_FIELDS Set.of( password, pwd, passwd, token, authorization, cookie, cardNo, cvv, idCard ); public static Object mask(String fieldName, Object value) { if (value null) { return null; } if (SENSITIVE_FIELDS.contains(fieldName)) { return ******; } return value; } }实际项目中这个工具类要接入 Jackson 的自定义序列化逻辑或者做成注解。更标准的方式是定义脱敏注解在字段上标注后序列化时自动替换。第三层回放端强制兜底。即使采集端和服务端都失守回放引擎在向页面写入input值时也要再一次判断目标元素是否敏感字段如果是只显示占位符不写入真实值。这层防线看起来重复但对生产环境来说重复的防御不是浪费是安全边界。6.3 与数据库密码加密的边界这里要做一个概念区分录制数据中用户密码的脱敏和 Spring Boot 项目中数据库密码的加密例如用 Jasypt 集成 SM4 加密不是一回事但属于同一个安全主题的两个层面。数据库密码加密保护的是application.yml或配置中心里的静态连接密码防止配置文件泄露后被直接使用。录制数据脱敏保护的是“用户产生的动态行为数据”防止密码、Token 等敏感信息进入业务库、日志或回放页面。很多团队只做了前者以为“配置里的密码加密了系统就安全了”结果录制快照里的用户密码反而流到了日志系统、数据仓库、BI 报表里。切中要害的思路是凡是可能进入存储、日志、导出文件的用户输入字段都要在源头建立敏感字段清单并逐层脱敏。另外还要提醒一点不要试图在录制数据里把密码加密存储。录制回放系统不适合保存密码的密文或哈希因为回放场景完全不需要还原真实密码。直接脱敏为占位符才是最简单的做法。7. 常见问题与排查思路在实际项目里这几个问题往往不是一次性暴露的而是按“体积大 - 回放卡 - 安全检查发现问题”的顺序逐步浮出水面。以下排查表可以作为工程参考问题现象可能原因排查方式解决方案单个会话 JSON 体积几十 MB高频事件全量落库统计事件类型分布看 mousemove/scroll 占比节流、抽样、只记录变化方向input 事件数量异常多每个字符触发一条 event 且中间值都落库查看事件序列里相同 target 的连续 input只记录最终值或做输入防抖合并DOM 快照过大每几秒存储一次全量 DOM检查快照表的大小与频率改为快照 差分事件回放时页面频繁抖动批量 DOM 操作未做调度打开 Performance 面板看 Layout 次数使用 requestAnimationFrame 合并操作回放时输入框出现密码明文采集端没有按 password 类型过滤在 SDK 里打断点查看 input 事件 value增加采集端和服务端双重脱敏JSON 压缩后无法查询直接把压缩字节存库且无索引观察查询语句是否全表扫描关键字段抽离明细存对象存储回放引擎解析慢每次回放都重新 JSON.parse 超大文本查看解析耗时在回放总耗时中的占比按时间分片加载按需解析8. 最佳实践与工程建议8.1 数据模型先于功能开发如果你还在项目早期最重要的一条建议是不要把录制回放的数据模型当成临时表来设计。字段可以少结构可以简单但“差分、脱敏、压缩”这三个能力必须在第一版就考虑进去。后面补这三个能力往往需要重写数据管道。8.2 录制 SDK 要区分敏感字段白名单录制 SDK 不能只认typepassword。有些项目的登录表单为了让用户看到密码明文会做一个“眼睛”切换把type从password切到text。这时如果 SDK 只按类型判断就会把明文密码录进去。建议维护一个敏感关键字清单结合name、id、autocomplete、>