rea:运行时环境分析如何揪出隐藏的局部卡顿
1. rea是什么:起因于一次说不清的线上卡顿
大概三个月前,我们某项目中台收到了一连串用户投诉,都说首页打开后点按钮没反应、白屏时间长得离谱。坏就坏在"说不清"这三个字上——传统监控平台里的数字看起来都正常:整体首屏时间中位数是1.8秒,报错率不到0.3%,JS异常寥寥无几。但用户就是觉得卡,而且不是少数人,是一个明确的小群体,占比大约6%。
我把那几天的性能数据翻来覆去地看,发现一个以前没在意的问题:所有统计都是"全局平均值",哪个页面慢、哪类接口耗时高,仪表盘上一目了然,可一旦追问"是哪部分用户慢、他们跑在什么环境里、用的什么网络、什么设备、当时页面是什么版本",就彻底抓瞎了。监控系统能回答"发生了什么",回答不了"在谁身上发生的,为什么单单是他们"。
这就是我这个"rea"方案诞生的直接起因。手动打了个小包,全名Runtime Environment Analysis,翻译过来就是运行时环境分析。它做的事很聚焦:在业务代码跑起来的时候,把每个性能样本附带上"环境快照"——设备型号、系统版本、浏览器内核、网络类型、内存水位、页面版本号和实验分组标记——再连同性能指标一起上报。服务端拿到这批数据后,不再只算平均值,而是按环境维度做聚类,自动找出"哪一群环境下的人特别慢,慢在哪一段,是网络、是渲染、还是脚本执行"。
这篇文章把rea从设计到落地中间折腾的完整过程写下来。它不是什么标准方案,更接近一个偏实战的轻量思路:适合被"整体数据正常但局部用户很卡"折磨过的前端团队,也适合想给现有监控体系加一层"环境归因"能力、又不打算上一套重型可观测性平台的人参考。主题不大,但踩坑不少,后面的原理、代码和排错过程我都尽量给到细节。
2. rea的核心原理与采集维度设计
2.1 环境指纹:把"跑在哪"变成可对比的数据
rea最关键的第一步,不是统计性能,而是先解决"还原现场"的问题。我把它拆成两层来设计。
第一层叫静态环境指纹。它在运行时通过平台自带API拿一批基本不会变的信息:
硬件层用navigator.hardwareConcurrency拿CPU逻辑核心数,结合设备内存navigator.deviceMemory(这个属性Chrome系支持,个别浏览器会返回undefined,需要给默认值)。这两个值组合起来可以粗略划分"低端机/中端机/高端机"。我在项目里按cores <= 4 && memory <= 2判定为低性能设备,实际情况里这套阈值能圈住绝大多数中低端Android机。
系统层用navigator.userAgent做解析,不引第三方库,就自己写了个几十行的解析函数,提取出平台、系统版本、浏览器名和版本号。再用navigator.connection.effectiveType拿网络类型:4g、3g、2g,加上savedata开关来识别"用户开了省流量模式"。这里必须说明,effectiveType给的是浏览器估算值,并不精确反映实时带宽,但在画像层面已经足够用了。
第二层叫动态环境状态。它负责回答"这台设备此刻健康吗"。我在rea里每分钟采样一次这几项:
performance.now()取当前时间戳,配合前置采样点计算主线程繁忙比例;- 用
performance.memory(只有Chromium系支持)读取已用JS堆内存; - 采集设备电池等级和充电状态,这部分用的是
getBatteryAPI,调用时会请求权限,所以做了失败兜底,拿不到就标记为unknown; - 页面可见性状态,区分用户是前台真实等待还是切后台回来看到的"假慢"。
环境指纹组装完成后,会做一次轻量哈希。哈希的目的不是拿来精确标识用户,而是给服务端做环境聚类的粗分组键。同一批指纹相同或相近的样本,会被归入同一个"环境桶",后面分析时直接按桶比较性能差异,非常直观。
2.2 性能消耗点:从规范和浏览器API里挖数据
环境信息只是侧写,真正的核心还是性能数据。rea没有自研一套性能采集器,而是围绕标准Performance API做封装。理由很朴素:浏览器已经默默把每个资源的加载耗时、每次渲染的关键节点都记录好了,与其发明轮子,不如把这些规范里的"审计日志"读出来用。
我在rea里重点采集四类指标,它们正好对应一次页面从请求到交互的完整链路:
首次内容绘制和时间到首次交互。前者用PerformanceObserver监听paint类型,拿到first-contentful-paint(FCP);后者是event类型下的first-input-delay(FID)。这两项代表用户对"页面有没有反应"的第一感知。
最大内容绘制,也就是LCP。监听largest-contentful-paint类型,额外记录触发元素标签名和包含它的图片地址。这信息关键时刻救过我一次,后面章节会展开说。不过LCP在rea里不是取最后一次上报,而是跟踪整个生命周期里最大的那一次绘制,实现上要不断对比更新。
资源加载明细。监听resource类型,把脚本、样式、图片、接口请求的name、duration、transferSize、protocol都收下来。这里有个坑:跨域资源如果不带Timing-Allow-Origin响应头,duration和transferSize会读不到,我在采集层做了判别并打了标记,避免脏数据混进统计。
布局与长任务。监听layout-shift算CLS,监听longtask记录超过50ms的脚本执行片段,每个片段都带开始时间和持续时长。
为了不过分影响性能本身,rea的采集器只在页面进入hidden状态或者达到60秒时限时统一批量取一次数据,而不是每当指标更新就立刻上报。前端只做"记录者",把原始条目暂存在队列里,避免高频回调挤占主线程。
2.3 变化集成:版本、发布与A/B标记
静态环境和性能数据都有了之后,我意识到还缺一环:同一套代码不可能在所有环境里一样快,发布节奏也是变量。某个版本改坏了低端机渲染路径,这种事在监控面板上特别容易被平均值掩盖。
所以rea在每条上报里额外挂了三个业务元信息字段:
releaseVersion,当前前端包版本号。我在构建时通过注入方式替换,不用在代码里手写版本,避免了忘记更新的尴尬。
routeName,是哪个路由页面产生的样本。埋点不用每个页面单独写,而是在全局路由切换钩子里统一打标。
bucketMarkers,一个字符串数组,存放当前的实验分组或功能开关标识。比如"新版列表骨架屏""新版首屏接口合并""新版图片模糊预加载"这些标记直接复用现有开关的值,这样后面分析时能直观看到"开了某开关的那批人是不是更慢了"。
有了这三个元信息,rea的数据维度就齐了:环境(设备/网络/系统)加上发布状态(版本/路由/实验组)再加上性能数值。三者叠加,才能支撑后面服务端的归因判断。
3. rea的具体实现:一条从采集到归因的完整链路
3.1 采集层的代码组织
rea最早是我在某个临时排错脚本里东拼西凑出来的,后来实验组打算长期用,我才把它抽成了一个独立模块。整体做了分层的模块设计,避免所有代码揉在一起。
采集层分三块:环境探测器、性能探测器、队列调度器。探测器只负责收集和格式化数据,调度器管"什么时候收、什么时候发、发不出去怎么办"。我专门把调度逻辑独立出来,因为真实场景里事件触发时机非常杂:有页面load触发的一次性采集、有PerformanceObserver回调的持续监听、有路由切换时的上下文更新,没做好调度容易出现重复上报或者漏报。
下面是rea初始化入口的简化版代码,这个版本已经去掉了业务内部细节,只看骨架:
import { createEnvironmentSnapshot } from './env'; import { createPerformanceTracker } from './perf'; import { createReporter } from './reporter'; export function initREA({ appVersion, routeName, bucketMarkers = [], reportEndpoint, sampleRate = 0.2, }) { const env = createEnvironmentSnapshot(); const perf = createPerformanceTracker(); let currentRoute = routeName; const reporter = createReporter({ endpoint: reportEndpoint, hooks: { beforeSend: (payload) => ({ ...payload, appVersion, routeName: currentRoute, bucketMarkers, }), }, }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { const sample = { env: env.getSnapshot(), perf: perf.flush(), }; if (Math.random() <= sampleRate) { reporter.send(sample); } } }); }关于采样率,rea默认设成0.2,也就是五分之一会话上报一条。这不是拍脑袋定的,性能分析并不需要全量数据,只要样本量够形成统计口径就行。线上真实测试里,20%采样在千万级日活的系统上每天也能汇集百万级样本,做环境聚类完全够。
3.2 上报协议与数据格式
rea上报走的是浏览器sendBeacon,这个API最大的好处是页面卸载时也能尽量把数据送出去,比XMLHttpRequest在visibilitychange场景下可靠得多。如果sendBeacon不可用或载荷过大,会自动降级为图片打点或者fetch的keepalive模式。
每条上报是一个JSON对象,核心结构我用表格列一下,方便理解每个字段的用途:
| 字段 | 类型 | 说明 |
|---|---|---|
| appVersion | string | 前端包版本号,构建注入 |
| routeName | string | 当前路由名 |
| bucketMarkers | string[] | 实验分组/功能开关标记 |
| env.platform | string | android / ios / windows / mac |
| env.deviceClass | string | low / mid / high,按核心数与内存划分 |
| env.network | string | 4g / 3g / 2g / offline / unknown |
| env.memoryHeap | string | JS堆内存水位归一化后的档位 |
| perf.fcp | number | 首次内容绘制,单位ms |
| perf.lcp | number | 最大内容绘制,单位ms |
| perf.lcpElement | string | LCP对应的元素或图片标识 |
| perf.fid | number | 首次输入延迟,单位ms |
| perf.cls | number | 累计布局偏移分数 |
| perf.longTasks | Array | 长任务列表,包含开始时间和耗时 |
| perf.resources | Array | 资源加载明细(截断后保留前40条) |
资源明细整包上传体量太大,我做了两个限制:一是只保留耗时在前20的慢资源,二是个别字段超过长度阈值就截断。一条上报压缩前大概控制在8KB到15KB之间,实测对网络的影响可以忽略。服务端收到后还会再做一层压缩存储,这部分放到下游处理。
3.3 服务端的聚类与归因逻辑
数据上报到服务端后,真正的分析才开始。rea的服务端逻辑分两层:
聚合层按小时粒度,用环境指纹字段做分组键,统计每个环境桶的样本数、P50/P75/P90/P95耗时,以及LCP、FCP、FID各指标的分布。分组键不是单字段,而是由设备档位+网络+系统大版本+路由名拼接而成。为什么不用完整UA做键?因为完整UA粒度太细,会导致每个桶样本量太小,失去对比意义。系统大版本我只取到主版本号,比如Android 11、iOS 16,不再往下拆。
归因层做的事情是"自动指出异常环境桶"。我一开始是想用复杂的异常检测算法,后来发现基线偏移法更实用:以全体样本的P90作为基准线,找出那些P90显著高于基线、同时样本量超过总样本1%的环境桶,标记为"待排查"。
算法能定位到桶,但桶内部到底哪种因素主导,还依赖人工判断。举个例子,如果标记出来的是"mid等级设备+3g网络+Android 12",我可以横向比较同一个设备档位在不同网络下的差值,判断是网络主导;也可以比较同一个网络在不同设备档位下的差值,判断是设备算力主导。这种方法虽然朴素,但在绝大多数场景里已经足够定位问题方向,而且执行成本极低。
4. 接入rea后踩过的坑与排查实录
4.1 跨域脚本的timing清零问题
rea上线第一周,我就发现资源加载数据里有一批JS文件的时间全部是0。这些文件走的是独立CDN域名,和主业务域名不同源。查了规范才知道,Performance Resource Timing在跨域资源上默认拿不到详细的duration和transferSize,除非被请求的资源响应头里带上Timing-Allow-Origin。
但CDN域名不全是我们能直接改的,尤其是第三方统计脚本、外部字体这类资源。rea里给跨域资源专门做了标记,在initiatorType为script或css且duration为0时,记录"资源本身耗时未知,但页面等待它的时间仍然体现在FCP和LCP里"。这种数据不参与耗时排序,但保留数量统计,用来判断"页面是否加载了大量第三方脚本"这个相对粗放的问题。
如果是自家CDN,解决方式很直接:在响应头加一行Timing-Allow-Origin: *或指定来源域名。加了之后重新上线,时长数据立刻就完整了,这一点值得做性能监控的团队优先排查。
4.2 LCP总比用户体感慢的偏差
有段时间线上反馈看到一个奇怪现象:rea里的LCP数值普遍在2.5秒以上,但用户自己录屏测试体感是"一秒多就出内容了"。两边对不上,我刚开始怀疑是埋点时机问题。
深挖后发现机制上的原因:largest-contentful-paint记录的是整个页面生命周期里"最大"的一次内容绘制,它不代表"用户眼中的首屏内容"。比如首屏本身很快就渲染完了,但页面下方有一个巨大的背景图在滚动后才开始加载、最终成为最大元素,LCP的数值就被这个后来者拉高了。
这说明直接拿LCP标准定义做业务体验指标可能失真。rea随后调整了LCP元素的记录策略,加了一个可配置的"观察时间窗",默认只统计前5秒内出现的最大绘制元素,超过窗口的一律忽略。这样对齐用户体感之后,数据终于能和实际体验对上号。做类似监控的同学如果遇到LCP偏高,建议先看看LCP对应元素到底是什么,别急着优化代码。
4.3 低端机上的采集自耗
玩过性能监控的人都会有这个顾虑:监控本身会不会变成新的性能负担。rea在一批低端机上的真实情况是:确实会。Android 10以下的千元机跑起来,rea的采集脚本峰值CPU占用能到8%—12%,尤其在页面滚动和加载同时发生时,PerformanceObserver回调频繁触发,对低端机主线程是实打实的挤占。
这问题后来用了两个手段缓解。第一是条件采集:deviceClass为low且网络为2g/3g时,不再采集资源明细和长任务列表,只保留FCP、LCP、CLS和FID这四个核心指标。少掉Resource Timing的数据采集和序列化,CPU占用直接降了一大截。第二是采样率分级:低端设备采样率从0.2降到0.1,高端设备反而提到0.5。这种"越有问题越少打扰"的设计逻辑,实际上也让rea在低端机上的数据更加真实——不再因为采集器本身拖慢页面而测出失真的性能。
5. 从rea数据到性能优化的实战打法
5.1 找出"环境小群体"而不是"整体平均值"
rea给我最大的认知转变,是让我彻底不再信任"整体平均耗时"这个数字。平均值最大的问题在于它掩盖了分布。拿首页首屏举例,假设整体P50是1.8秒,听起来还行,但rea的环境桶里能看到:iPhone用户P50是1.2秒,Android中端机是2.5秒,Android低端机配合3g网络是6.8秒。哪个数字才是产品的真实体验?每个都是,但它们属于不同的人群。
没有环境维度的时候,优化方向很容易被平均数据误导,大多数人盯着整体P75去优化,结果改了共享代码全部人受益,却忽略了那个6.8秒的群体可能根本是另一个问题——是网络差、是低端机JS执行慢、还是接口响应本来就慢。rea的做法是先把群体细分出来,再按群体逐一分析。优化目标也变成"哪个环境桶改善带来的整体收益最大"。
这种打法的工程价值相当直观。举例来说,把2g/3g网络群体的首屏优化好了,虽然它们只占6%的用户,但对他们而言是从"基本不可用"到"勉强能用"的质变,对口碑的影响远远超过对平均数据的影响。产品团队往往会愿意为这种"体验公平性"让步一部分性能预算。
5.2 一个从rea数据直接定位问题的案例
光讲方法论可能不够具体,我分享一个实际案例。某次版本发布后,rea的环境桶分析发现"mid档设备+Android 12+4g+路由A"这一个桶的LCP P90从2.1秒直接飙到4.8秒,其他所有桶都正常。传统监控面板上这个异常大概率会被稀释掉,因为整体LCP P90只涨了不到0.3秒。
我按rea的思路继续下钻:先看这个桶的FCP,正常;再看LCP元素,发现是一张背景大图;最后看这张图的resource明细,发现它的加载方式在A路由上走的是一个新加的懒加载逻辑,而这个懒加载在Android 12的某内核版本上出现了竞态,导致图片请求延迟到滚动后才发出。
问题定位到这里,方向已经非常清晰。最后修复方法是调整懒加载的判断时机,把"元素即将进入视口再加载"改成"首屏渲染完成后立即预加载"。修复上线后,这个桶的LCP P90第二天就回落到了2.3秒。
这个案例想说明的是:真正值钱的不是那个"异常峰值"本身,而是围绕峰值展开的环境分组与元素明细。rea把展开路径做得很顺滑——从P90异常点一下就能看到环境组合和环境组合之外的LCP元素、资源明细,排查成本大幅降低。
5.3 后续演进:把rea扩展到WebView端
rea目前跑在业务前端页面上,但这半年我一直想把它推到App内的WebView场景去。原因很简单,许多真实业务的流量大头其实在App内嵌页,而WebView环境比普通浏览器复杂得多:不同宿主App的WebView内核版本不一样、有各自的缓存策略、JsBridge注入时机不同、页面通信方式也不同。
把rea扩展到WebView需要多采集两类数据:一类是宿主App信息,比如宿主版本、WebView的UA标识、是否开启了硬件加速;另一类是JsBridge调用耗时,因为这往往是WebView页面里隐藏最深的性能黑洞。实现上并不复杂,宿主App在初始化WebView时通过一个全局对象把宿主信息暴露给页面,rea识别到后自动追加进环境指纹里。
有些坑我也提前踩过:Android上不同宿主的WebView设置项差异很大,有的默认关闭了DOM存储,有的限制了缓存大小,这些都会直接影响页面性能,但浏览器常规环境里根本遇不到。所以rea在WebView端的分析逻辑要额外增加"宿主App"作为第一分组维度,不然数据混在一起又回到了平均值陷阱。这块目前还在内测,后续跑稳了再单独写一篇详细记录。