1. 为什么我要用一个代号“rea”的项目来盯真实用户访问
先说结论:在绝大多数业务里,线上环境的表现和你在本地、测试环境里看到的完全不是一回事。一年前我把一个内部服务命名为“rea”,取的是“Real End-user Access”的意思,简单说就是“真实用户访问”,核心目标是把每个真实用户打开页面时的性能数据、错误信息、行为路径完整捞回来,做成一套自建的真实用户监控服务(RUM,Real User Monitoring)。
为什么不自接用现成的商业监控产品?原因很现实:一是业务方要看的指标维度非常细,某些垂直场景(比如同一页面在不同地域的加载耗时分布、特定渠道用户的首屏时间趋势)在通用工具里要么没这个维度,要么要额外开很高的费用;二是数据要回传到自己的数据仓库做后续分析,配合灰度发布、AB实验做切片对比,这部分商业产品很难和内部已有的数据体系打通。所以自己搭一套,反而是长期更稳的方案。
这套服务能在什么场景帮到你?
- 线上版本发布后,实时看到新版本在大盘用户里的性能波动,而不是等用户投诉、客诉电话打进来才后知后觉;
- 定位“用户说页面很卡”到底是网络问题、前端脚本问题还是后端接口问题;
- 做性能优化时,有一个统一口径的基线数据,避免“我觉得快了”和“用户真的觉得快了”之间永远存在争议。
适合谁来参考?前端工程师、性能优化同学、刚准备搭建监控体系的团队,以及想搞清楚“一套RUM系统里到底要采哪些数据、怎么采、采回来怎么用”的读者。这篇文章不是讲概念,而是把我从指标选型、SDK设计、上报通道、踩坑修复到最终落地告警的完整过程拆开讲,里面每一步都有明确理由,也附上了真实踩过的坑。
2. 指标选型的底层逻辑:不是所有数字都值得采
2.1 按用户体验的阶段来定指标,而不是按“网上流行什么”
我在设计rea的指标模型时,一开始也犯过“什么火就采什么”的错。后来梳理清楚了:用户访问一个页面的体验,本质是一条时间线——浏览器开始发起请求、页面开始绘制、首屏内容出现、可交互、后续元素稳定布局。每一个阶段对应一个或多个指标,指标之间是有因果关系的,不是一个个孤立的数。
这条时间线我分为四段:
- 网络与请求阶段:从用户发起导航到服务器响应,典型指标是TTFB(Time To First Byte,首字节时间);
- 绘制阶段:浏览器拿到HTML后开始解析、绘制内容,典型指标是FP(First Paint,首次绘制)、FCP(First Contentful Paint,首屏有内容绘制);
- 可交互阶段:页面视觉已基本完整,用户可以操作,典型指标是LCP(Largest Contentful Paint,最大内容绘制)和INP(Interaction to Next Paint,交互到下一次绘制的延迟,这是取代旧版FID的指标);
- 稳定性阶段:页面加载完成后布局是否还在跳,典型指标是CLS(Cumulative Layout Shift,累计布局偏移)。
这几个指标是Core Web Vitals的组成部分,但我在rea里没有只采这五个,而是又补了几个对定位问题特别有帮助的指标:DOMContentLoaded时间、页面完全加载时间(window.load)、资源的加载耗时明细(JS/CSS/图片分开统计)、接口请求的成功率与耗时分位数。
为什么要这样补?举个例子:你看到LCP变慢了,但不知道慢在“图片资源加载慢”还是“接口数据返回慢导致渲染被阻塞”,这时候如果只有LCP一个数字,能知道它慢了,却不知道往哪个方向查。把资源加载和接口数据拆细之后,定位范围就大幅缩小。
2.2 指标口径不统一,数据就是死的
这是我认为整个RUM系统里最难的一步,没有之一。FP、FCP、LCP这些指标,浏览器、实验室工具、不同前端库给出的值经常不一致,原因就是口径不同。
rea里我做了几件关于口径统一的事:
第一,所有指标明确记录计算起点。我用的是navigationStart(导航开始时间)作为统一的时间原点,而不是performance.timing里的其他字段。这样微服务之间、前后端之间对“耗时”的定义才一致。
第二,指标归一化处理。CLS是一个无单位的得分,它的计算是累计布局偏移量之和;LCP是时间戳;而错误率是百分比。这三类数据在存储和展示上必须分开建模,不能塞进同一个“指标数值”字段里,得有专门的类型标识。桶状分布存储是我最终采用的方案:比如LCP不存单值,而是按“0-1秒、1-2秒、2-3秒、3-5秒、5秒以上”分成若干个桶,每个桶存计数。这样不仅天然支持分位数计算,还能大幅压缩数据量,后面告警判断也很方便。
第三,剔除异常样本。在统计LCP时,如果用户把页面切到后台很久才切回来,浏览器会暂停计算,这类数据在现场统计里要么丢弃,要么标记为“后台标签页样本”单独存放。rea的做法是对这类样本打上visible_state字段(值可能是hidden、visible、prerender),最后的报表默认过滤掉hidden样本,但保留在原始数据里可查。
2.3 指标采回来后一定要能“切片”
我在搭建初期犯过一个比较严重的错误:只看整体均值。结果某次发布后整体LCP均值几乎没有变化,但后台一查,某省的P90数据恶化明显。如果当时只看均值就发布了,那个问题会被掩盖很久。
所以rea的每条性能样本在上报时都会带上上下文属性,包括:
- 用户标识(脱敏后的ID)
- 页面路由(以路由为单位而不是以URL为单位,因为URL带参会导致同一页面分裂成无数个条目)
- 版本号(当前发布的前端版本)
- 渠道来源(扫码、广告跳转、直接访问等)
- 地区(IP解析后的省级或城市级)
- 运营商(移动、联通、电信)
- 设备类型(手机/平板/PC/其他)
- 浏览器及版本
有了这些维度,才能回答“是新版本影响了大盘还是某个渠道的问题”“是移动网络普及地区的用户更卡还是Wi-Fi用户更卡”这类问题。所有后续的告警预警、报告分析,都建立在这些维度之上。如果少了这一步,采集上来的数据只是一堆数字,无法支撑任何决策。
3. 采集端SDK的实操设计:PerformanceObserver是核心,但不止于此
3.1 为什么选PerformanceObserver而不是传统timing API
rea的采集SDK一期用的是performance.getEntriesByType('navigation')这类传统API,后来测试中发现两个硬伤:一是SPA应用里路由切换时,传统Navigation Timing拿不到新页面的导航时间;二是如果要监听资源加载完成的事件,用轮询的方式不仅损耗性能,还容易丢数据。
后来换成PerformanceObserver,它的事件驱动方式更优雅,也是官方推荐的做法。具体做法是先判断浏览器是否支持PerformanceObserver,不支持的环境再降级到getEntriesByType轮询兜底。rea在整个SDK里封装了一套名为“能力检测”的工具,对FP、FCP、LCP、CLS、INP分别判断支持情况,缺哪个就放弃采哪个,并在上报时带一个support字段说明当前环境采集了哪些指标。
这是一个很划算的取舍:不会因为某台老设备浏览器不支持新API就整条数据丢掉,而是明确标注缺失指标后照常上报,后续分析时能做方法层面的区分。
3.2 采集LCP的细节坑
LCP看起来简单,就是“最大内容绘制的时间”,但实际处理时有几个很容易忽略的细节。
LCP的候选元素可能是图片、视频、文本块、背景图,计算时需要用PerformanceObserver监听largest-contentful-paint条目。关键坑在于:LCP的候选值会随着加载过程变化,最开始时可能是一个小文本块,后面一个更大的图片加载完成后,LCP值会更新。所以不能只取第一个条目就直接上报,而是要持续观察,直到页面加载稳定下来,再上报“最终LCP”。
rea的SDK里是这样处理的:监听所有largest-contentful-paint类型的条目,每次回调都记录当前最大值,然后在页面进入稳定状态后(监听页面visible状态、window load事件、以及一个超时兜底)取最终值。稳定状态这个细节很重要——如果用户页面一直在加载资源,LCP的最终值可能迟迟不落定。rea的做法是:捕获到第一个LCP候选后启动一个计时器,连续多次没有新的、更大的候选出现,就判定为稳定并上报当前值。
3.3 CLS的正确测量窗口
CLS这个指标我在内部叫它“页面跳动的罪魁祸首”。它衡量的是页面生命周期内发生的意外的布局偏移。这里两个关键词都容易被忽略:一个是“意外”(用户主动触发的布局变化不算),一个是“生命周期内”(不是只计算加载期间)。
要排除“意外”这个词的干扰,就得结合用户交互来判断。比如用户点击了某个按钮展开菜单、下拉刷新、输入文字等,这些行为导致的布局偏移,标准工具会通过“在用户交互后短暂窗口内发生的偏移不计入”来排除。rea在监听layout-shift条目时,会记录entry的hadRecentInput字段。这个字段表示“偏移是否发生在用户输入后的短时间内”,如果为true,则不计入CLS累计。
但要注意:hadRecentInput为false时也不一定就计入——需要排除浏览器自身机制导致的合法布局变化。实际处理时我一般直接采用“记录所有不为hadRecentInput的条目,再按会话窗口计算最大CLS值”。多次加载页面时,CLS算的是整个页面生命周期内的累计表现,所以rea在单页应用路由切换时,会重置本轮会话的CLS计算起点,避免把上一个页面的布局偏移带进当前页面的统计数据里。
3.4 数据兜底与采样:忘了兜底就会丢数据
不管API多现代,总有一批老环境不支持。rea的采集SDK里做了基于功能检测的分层方案:
- 支持PerformanceObserver的环境:用Observer采集FP、FCP、LCP、CLS、INP;
- 不支持Observer但支持getEntriesByType('navigation')的环境:降级用一次性读取的方式采集TTFB、DOMContentLoaded、load时间,不采LCP和CLS;
- 两个都不支持的环境(极老设备):只采集最基础的JS错误和页面URL,作为访问量的补充样本。
这套分层的价值在于,不会因为某个老浏览器缺一个API就把整个页面的监控废掉。同时也能看到不同设备类型的样本覆盖差异。
采样方面,rea在正式上线后设置的默认采样率是10%——也就是每十个用户里随机选一个上报全量数据。这个比率对大部分产品形态都够用了,毕竟要的不是精确值,是趋势和分布。但如果某个页面访问量特别大(比如首屏点击率极高的核心页面),可以单独调低到5%甚至1%;反之,如果问题是偶发性的,可以临时把特定路由的采样率调到100%,抓几天数据定位问题再调回来。
注意:采样策略一定要“动态可配置”,并且把采样率的变更记录在案。我曾经因为调整采样率后忘了同步给数据分析那边,导致对比数据时出现了明显的样本量差异,白白浪费了几天排查时间。
4. 上报通道的工程化:不卡主线程,还要确保数据不丢
4.1 sendBeacon是最省事的选择,但不是万能钥匙
采集到的数据最终要送到后端,发送动作如果在主线程做同步XHR,会直接拖慢页面响应;如果在卸载(pagehide)时用普通fetch发送,大概率丢数据,因为页面马上销毁,请求来不及发出。
rea早期用的是Image对象打点上报,也就是把参数拼在URL的query后面,让浏览器加载一个1像素的图片来触发GET请求。这种方式能保证90%的送达率,但有个问题:拼在URL上的长度有限,数据稍微大一点就会被截断。后来切到navigator.sendBeacon,它能保证只要浏览器支持,卸载流程中数据也会正常送达,而且不会阻塞页面关闭流程。
但sendBeacon有一个明显的坑:请求大小限制在很多浏览器里大约在64KB左右。如果采集字段太厚(比如把某次加载的全部资源明细都塞进去),很容易超限。rea的做法是把单条上报数据控制在10KB以内,超过的部分拆包,并且设置了“如果sendBeacon不可用,降级用XMLHttpRequest同步发送”的逻辑。
很多团队会在这一步纠结要不要用上报延迟策略(比如积累N条后批量发送),我的经验是:RUM上报不应该过度批量。因为性能监控数据的实时性是有价值的,延迟几分钟就失去了“发布后立刻发现异常”的意义。rea最终采用“即时上报关键指标 + 延迟上报资源明细”的双通道策略:
- 第一类数据:核心Web Vitals、JS错误、接口失败记录,页面加载完成后立即sendBeacon发送;
- 第二类数据:资源明细列表、长任务记录、详细堆栈,这类数据量大且对实时性要求不高,积累到一定数量或页面卸载前再上报。
4.2 后端接收器的幂等去重设计
SDK一旦跑起来,就会面临网络抖动、前端重试导致的重复上报。rea的后端接收器重点做了两件事:一是“按事件ID去重”,每条上报数据在SDK里就生成一个全局唯一的eventId,后端接收后如果发现相同eventId已经处理过就丢弃;二是“按时间窗口聚合”,把同一个用户在同一个小时内上报的重复数据合并计数。
这样设计的原因是:前端重试机制不可避免,网络超时、服务端返回5xx都可能导致同一条数据被重发。如果后端不做去重,报表里的PV数会虚高,甚至出现荒谬的“某个页面访问量比真实值高30%”的情况。事件ID的生成规则是用随机数加时间戳加页面会话ID,确保同一会话内不同事件不重复,同一事件不同时间重试时ID不变。
4.3 数据入库与消费链路
rea的数据链路是:浏览器SDK → 网关接收 → 消息队列 → 数据处理服务 → 写入数据仓库(明细表 + 聚合表)。
为什么中间要加消息队列?因为监控数据的特点是“突发性”。白天线上业务高峰时,上报量可能瞬间冲到平时的几十倍,如果后端没有缓冲,直接把请求打进数据库,高峰期查询会直接把库拖垮。有了队列做削峰填谷,数据处理服务按稳定的速率消费,即使处理速度暂时跟不上,数据也只是堆积在队列里延迟处理,不会丢失。
聚合表是我后来加上的,因为用原始数据明细表去做“某个路由昨天的P90 LCP”这类的查询,海量样本下会非常慢。rea每隔五分钟跑一次聚合任务,按“路由+地区+运营商+浏览器+版本号”组合键,把桶状分布数据汇总成一张聚合表。日常看板读聚合表,秒级出结果;深度排查时再去查原始明细表。这套两层结构是监控系统的标配,跑了一段时间后非常稳定。
5. 上线后的踩坑实录:SPA路由、同源iframe、低端机与“假首屏”
5.1 SPA路由切换时监控“重置”的边界条件
这是rea碰到的第一个大坑。某次上线后报表显示某路由的访问量异常低,查了半天发现是SDK在SPA模式下没有正确监听路由变化。
SPA单页应用的核心特点是“换页面不刷新浏览器”,整个应用只加载一次,后面都是前端路由在切换视图。如果SDK在首次window load之后就认为“页面加载完成”,那后续所有通过路由切换打开的子页面都不会被记录为一次新的访问。rea最初接入时就是这样,只上报了第一个路由的数据,后面的页面全部丢失。
解决方案是监听History API的pushState/replaceState事件,同时监听popstate(用户点浏览器的前进后退按钮),路由发生变化时重置本轮采集的状态。注意这里有个坑:pushState是编程式调用,浏览器不会自动触发popstate事件,所以SDK必须通过“包装原生方法”的方式在pushState执行时主动触发自己的回调。社区里很多监控方案都默认监听hashchange,但现代SPA基本都用History模式,如果只监听hashchange,一样会漏数据。
rea的SDK在路由切换时做的是:重新生成会话ID和route字段,保留前一个路由的LCP等已上报指标作为前页面的最终数据,然后重置LCP、CLS、INP的监听状态,开始新页面的数据采集。
5.2 同源iframe导致的指标重复计算
另一个比较有意思的坑是页面里嵌了同源的iframe,在采集资源加载时间时,iframe里的资源也被PerformanceObserver捕获到了。虽然理论上iframe里的资源耗时也属于用户真实体验的一部分,但它会污染主文档的FCP、LCP计算——如果主文档首屏明明在1.5秒完成,但某个后台iframe里有一个巨大的广告图在5秒时才加载,LCP就会变成5秒多,报表直接“爆表”。
rea在选择LCP条目时,只采纳“entry是在主文档上下文中触发的”这一部分,过滤掉iframe内产生的largest-contentful-paint条目。做法是比对entry的element所属的Document,只有它等于当前页面的document时才算。这个处理对其他指标也一样适用——资源监控明细里,iframe的资源单独归到一个iframeList字段里,不混在主文档资源里,方便排查时区分。
5.3 低端机和弱网下的采样失真
做RUM系统的人最容易忽略的真实情况是:低端机的性能数据本身就是一段“动荡曲线”。一台两年前的千元机,CPU主频低,内存吃紧,浏览器在渲染过程中很容易产生长任务,导致FCP时间剧烈波动。如果直接用均值去评估优化效果,很容易被极端设备拉偏。
rea在数据展示层做了两个处理:一是区分设备档位,用“设备性能等级”字段(高/中/低),用户侧的Device Memory和硬件并发核数来判断;二是所有性能指标在报表里默认展示“P50、P75、P90、P95”四个分位数,而不是平均数。为什么是分位数而不是均值?
因为性能数据的分布根本不是正态分布,少数极端样本会把均值拉高,而分位数能告诉你看板的人“正常情况下用户是什么体验”。P75是大多数人在中档设备上的真实感受,P95是大部分团队要优化的瓶颈区间。每次评比需求也会基于同一组分位数来做,这是保住分析口径一致性的关键。
5.4 首屏时间的“假数据”陷阱
还有一种情况我曾经误判了很久:页面FCP数据优化得很好,但用户仍然反馈说慢。后来查清楚了——很多SPA的FCP指标是在HTML里只有一块静态骨架屏时就触发了。骨架屏本身确实是页面有内容绘制,但它不是一个有用的首屏,真正的业务内容还没出来。这就是“假首屏”。rea后来新增了一个自定义指标叫“业务可用首屏时间”,以“首屏区域出现首个真实业务组件(比如商品卡片、列表项)”为准,采集方式是显式调用SDK提供的markFirstRender()方法,业务方在核心页面的关键组件挂载完成后调用。这个方法能保证FCP这类浏览器层面的指标和“用户觉得页面能用了”的业务指标对齐。
这个自定义指标对业务价值远远大于浏览器原生指标,因为这能真正回答“用户从打开页面到看到核心内容花了多久”。
6. 告警规则怎么定:阈值不是一拍脑袋定出来的
6.1 先跑两周基线,再定告警线
监控系统搭好以后,最怕的就是从第一天就设一个主观阈值。比如“LCP超过2.5秒就告警”,听起来很合理,但实际跑出来可能是各地域正常泛型就是P75达到3秒。
rea的做法是:上线后先“静默采集”两周,这段时间只看数据不定告警。两周后通过聚合表算出各指标在“版本正常、业务正常”时期的P90值,把这个值作为告警基线的起点。比如LCP在正常时期的P90为2.8秒,那告警阈值就设在3.2秒(P90×1.15的宽松系数),“持续五分钟超出该阈值”才触发告警。
这个基线的价值在于,它自动包含了业务特性和用户结构的影响——比如你的用户画像整体偏弱网,那基线自然就高一些;如果整体是纯Wi-Fi的办公类工具,那基线就低很多。一旦直接套用别人的通用阈值,几乎每周都会被误报淹没。
6.2 按“维度切片”设置差异化阈值
在rea告警里,我强烈建议不要只按全球或全国大盘配置一个阈值。不同地区、不同运营商、不同版本之间的性能差异极大。
我实际用的解法是“白名单 + 优先关注列表”:核心版本(当前正式发布版本)触发全局告警阈值时,立刻通知;非核心版本、低流量渠道的异常,不直接告警而是进“观察列表”,做定时扫描后只有连续上升趋势才升级告警。
地区差异化阈值也很有用。有些地区的宽带基础差,P90天然比其他地区高,用平均逻辑很容易造成“某地区天天报警导致告警疲劳”。最后团队把审批规则调成“重点地区(覆盖主要用户的地域)单独设置阈值,其他地区做聚合趋势分析”,告警噪音下降了七成以上。
6.3 告警之后的分析路径要“留得下痕迹”
每次告警触发后,团队里都要有一个人能回答“这个告警到底值不值得处理”。如果告警信息里只有“LCP P90超过阈值”,那排查者仍然要从零开始查。
所以rea的告警后台上自动附带了“快照上下文”:触发告警的维度组合、该组合在告警前后的趋势变化、最近一个采集周期里的头部异常资源(接口、脚本、图片)、涉及版本等。这一段看起来简单,实际价值极大——很多告警在带上上下文之后,当场就能看出是哪个脚本导致的。
如果告警快照里显示某个CDN域名下的JS文件加载时长自某个时间点起突然翻倍,那方向就很清晰了,不用再从头分析性能瀑布图。
7. 这套系统还能扩展出什么:从监控到诊断
7.1 版本对比:用发布前后的数据说话
监控系统如果只用来监控,价值只发挥了三成。rea最有用的一个场景是版本差异分析:某个版本发布后,自动对比上一个版本的同维度性能数据,输出“P75上涨/下跌幅度”的对比报告,并标记出恶化超过5%的维度组合。这种能力让团队在发版后不再靠感觉拍板,而是直接看到数据。
你可以在rea告警台的“版本对比”模块里看到类似下面这张表的结构(示意):
| 版本 | 样本数 | LCP P75 | LCP P90 | CLS P75 | JS错误率 |
|---|---|---|---|---|---|
| v1.0.0(上一版) | 8,432 | 2.1s | 3.0s | 0.08 | 0.4% |
| v1.0.1(当前版) | 9,017 | 2.4s | 3.6s | 0.12 | 1.1% |
如果发布后看到JS错误率上涨,说明大概率是新代码的异常;如果LCP上涨但JS错误率持平,方向则主要在加载链路或后端接口。告警与版本关联,是RUM系统从“被动监控”走向“主动诊断”的关键一步。
7.2 从指标到达因:接入日志与链路追踪
到了后期,rea已经不停留在性能指标层面,而是开始对接内部已有的日志系统和链路追踪服务。做法是:前端SDK在关键接口请求的发生点,生成一个traceId,这个traceId随同接口请求头传给后端,同时写入rea的上报数据里。后端日志系统里也有同样的traceId,两边一碰撞,就能把“用户感知慢(前端指标)”和“后端某个接口P99高(后端日志)”关联起来。
这套打通对一个曾经靠猜来定位问题的团队来说,是质的飞跃。曾经排查一次白屏问题要花半天,前端说是接口慢,后端说是前端不调用,两边僵持不下。现在只要打开一条数据,看traceId对应后端的调用链路和耗时,几秒钟就知道卡在哪一步。
7.3 长期演化:从“采集数据”走向“自动化优化闭环”
如果rea这套系统跑上一年,数据沉淀足够后,我打算做的是“自动化优化建议”:根据采集到的资源明细,自动找出体积明显偏大且加载耗时高的JS/CSS/图片文件,给出“哪些文件该拆包、哪些图片该转格式、哪些接口该加缓存”这类建议。
虽然这一步现在还没完全实现,但趋势是明确的——RUM不仅仅是监控工具,它正在成为前端工程团队最重要的数据基础设施之一。从“我怀疑这里有问题”到“数据告诉我问题就在这里”,这个转变是监控体系给研发流程带来的最大价值。
我不打算写什么高远的展望,只说一点个人体会:真实用户监控这个事,投入不小,但它属于“今天不做,明天出事故时追悔莫及”的基础设施。rea这个代号从取名到现在跑了大半年,期间数不清多少个晚上因为它揪出了藏在角落的问题。如果你也在考虑搭一套,最合适的时机永远是现在。数据不会自己出现,采集端一旦铺好,后面所有优化工作都会变得有的放矢。