Web 性能监控(RUM):从采集到告警
Lighthouse 只在开发时跑,生产用户的真实体验谁管?RUM(Real User Monitoring)采集真实用户的 LCP/INP/CLS 和 JS 错误。本文讲清 RUM 的采集(web-vitals 库)、上报、看板、告警,以及把"实验室快"变成"用户也快"。
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
我见过太多团队这样干活:本地跑 Lighthouse,分数 95,PR 一合,上线,庆功。三天后客服接到客诉"页面卡死了",你去查,服务器 CPU 正常、接口 P99 正常、错误日志干净——一切都"正常",但用户就是慢。
问题不在于你没监控,而在于你监控的是服务器和自己,不是用户。Lighthouse 是实验室仪器,它在一台干净的机器上模拟一次理想加载;而真实用户在地铁里、在千元机上、开着 30 个标签页、装着广告插件。两套世界之间的差距,只有从用户浏览器里采集数据才能填平。这就是 RUM(Real User Monitoring)。
这篇文章把 RUM 从采集到告警的完整链路讲透,顺带把实战中真正踩过的坑列出来。
一、Lighthouse 测的是"能多快",RUM 测的是"实际多快"
RUM 的价值就一句话:它从真实用户的浏览器里采集性能数据。每个用户访问你的页面,他的浏览器实际测了 LCP 多少毫秒、INP 多卡、CLS 跳没跳,然后把这些数字发回你的服务器。汇总起来,你看到的是"野外"的真实分布,不是机房的理想曲线。
为什么必须做 RUM?三个理由,每一条 Lighthouse 都给不了你:
第一,用户设备你模拟不了。你的 Mac 节流 4× 之后,跑 React hydration 还是比一台三年前的安卓千元机快。RUM 让低端机的痛苦第一次显形。
第二,用户的网络你模拟不了。Lighthouse 模拟的"4G"是 9Mbps / 170ms RTT 的理想 4G;真实世界的 4G 一秒内能从 1Mbps 掉到断流。模拟值和真实值的差距常常是 3 倍以上。
第三,用户的行为你预测不到。Lighthouse 测完加载就结束,但用户会立刻滚动、疯狂点按钮、在页面还没 ready 时就交互——这些 INP 你压根没测过。
一句话:Lighthouse 是开发期的体检,RUM 是生产期的 24 小时心电监护。两个都得有,但只有后者能告诉你"用户现在是不是在遭罪"。
二、采集:web-vitals 库,把浏览器变成传感器
Google 官方维护的 web-vitals 库是事实标准。它暴露几个回调,浏览器每次测到指标就调一次,你拿到数字自己决定怎么处理。
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';
const sessionId = crypto.randomUUID(); // 同一次会话内多指标可关联
function report(metric) {
// metric.name = 'LCP' | 'INP' | 'CLS' | ...
// metric.value = 数值(LCP/INP 是毫秒,CLS 是无量纲分数)
// metric.id = 这次测量的唯一 ID(同一指标可能多次上报,比如 INP)
// metric.rating = 'good' | 'needs-improvement' | 'poor'
const body = {
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
sessionId,
url: location.pathname, // 注意只取 pathname,别带 query/敏感参数
ts: Date.now(),
};
send(body);
}
onLCP(report);
onINP(report);
onCLS(report);
onTTFB(report);
onFCP(report);
几个关键点容易踩:
LCP/FCP/TTFB 一个页面只报一次(都是加载期指标),但 CLS 和 INP 可能多次回调。CLS 在页面整个生命周期内累积,可能在用户卸载前还在变;INP 是页面所有交互延迟的最差值,每次新交互都可能刷新。所以 web-vitals 给的 metric.id 要带上,后端按 (sessionId, metric.name, metric.id) 去重,保留最后一次即可——别每次回调都当一条新记录往里塞,看板上同一个用户的 LCP 会变成 5 条。
Next.js 直接有现成的 hook。next/web-vitals 在 App Router 里通过 useReportWebVitals 暴露同样的数据,不用自己装库:
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitalsReporter() {
useReportWebVitals((metric) => {
send(makeBody(metric));
});
return null;
}
把它挂在根 layout 里,所有路由都有采集。
三、上报:sendBeacon 别让数据在 unload 时丢
采集到了怎么发回去?这是 RUM 最容易踩坑的地方。性能数据有个特殊性:它往往在页面即将关闭时才最终成型(CLS、INP 都依赖完整生命周期)。而用户关页面这件事,会让你的 fetch 请求被浏览器直接掐掉——TCP 还没握完手页面就没了,数据蒸发。
解决办法两个,优先级从高到低:
navigator.sendBeacon(url, data):浏览器专门为"页面卸载时上报"设计的 API。它把请求加进浏览器的事件队列,即使页面已经关闭,浏览器也会保证发出去。注意它发的是 POST + blob/arrayBuffer,后端要按这个收。
fetch(url, { keepalive: true }):效果类似 sendBeacon,但能用 GET/POST 任意方法、能设 header、能发 JSON。更灵活,现在很多库(包括新版本 web-vitals 内部)都走这条。
一个生产可用的 send 应该这样写:
function send(body: object) {
const payload = JSON.stringify(body);
const url = '/api/rum';
// 优先 sendBeacon:卸载时也能发出去
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' });
if (navigator.sendBeacon(url, blob)) return;
}
// 回退到 fetch keepalive
fetch(url, {
method: 'POST',
body: payload,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
credentials: 'omit',
}).catch(() => {/* 静默失败,别影响用户 */});
}
几个反模式必须点名:别用 unload 事件上报(很多浏览器在 bfcache 场景下根本不触发 unload,数据全丢),用 visibilitychange + pagehide;别用同步 XHR(会被现代浏览器禁掉);别把上报做重了(加解密、压缩、批量攒 50 条再发——这些都是过度设计,单次几十字节的 JSON 用 sendBeacon 直接发就行,真实成本几乎为零)。
上报目的地可选:Google Analytics 4(GA4 免费但采样、看板弱)、Sentry Performance(开箱即用、关联错误强)、Datadog/New Relic(贵但企业级),或者自建:接一个轻量端点写 ClickHouse / Postgres,自己用 Grafana 画图。我个人偏好自建 + Sentry 双跑——自建存原始数据做切片分析,Sentry 出 Dashboard 和告警。
四、上报内容:一个数据点该长什么样
光报个数字没意义,数据点必须带上下文,否则看板上只有一堆平均数,什么也定位不了。最小可用集合:
interface RumEvent {
name: 'LCP' | 'INP' | 'CLS' | 'TTFB' | 'FCP';
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
sessionId: string; // 同一次浏览会话的多个指标可以关联
url: string; // location.pathname,去掉 query
device: 'mobile' | 'tablet' | 'desktop'; // UA 或 matchMedia 判
connection: '4g' | '3g' | 'wifi' | 'slow-2g'; // navigator.connection.effectiveType
route?: string; // SPA 当前逻辑路由,不是物理 URL
release: string; // 当前前端版本号,告警和归因必备
ts: number;
}
三个字段特别重要,新手最容易漏:
sessionId:用户一次会话可能加载多个页面、产生多次交互。会话 ID 把它们串起来,后端可以算"同一用户在首页 LCP 慢,详情页 INP 也慢"这种关联,也可以做会话级聚合。用 sessionStorage 存一个 UUID,页面刷新保留、关标签页失效,正好是"一次会话"。
release(版本号):这个不写,告警就是死的——你只知道"p75 LCP 涨了",不知道是哪个版本涨的。带上 release,看板上能直接画"每个版本的 p75 LCP 折线",新版本回归一眼看到。
device 和 connection:看板切片的核心维度。"移动端 4G 的 p75 LCP 是 4.2s,桌面 wifi 是 1.6s"——这一句信息量,够你接下来一周的优化方向。navigator.connection.effectiveType 是个实验性 API,但 Chrome 全平台支持,Safari/Firefox 拿不到就报 unknown,别强求。
JS 错误也要走同一条管道:window.onerror + unhandledrejection 抓到的异常,加上同样的 sessionId/url/release/device 一起上报。性能和错误放一个表里,后面要做"错误率和 LCP 同时飙升"的关联分析,不用再写 JOIN。
五、看板:看 p75,千万别看平均
RUM 数据拿到后,第一原则:看分位数(p75 / p95),不要看平均。
为什么不能看平均?因为性能数据是长尾分布。100 个用户里,95 个 LCP 是 1.5s,5 个倒霉蛋在 12s——平均 2s,看着挺美,但那 5 个用户已经流失了。平均数会被长尾拉扯,既掩盖了大多数人的体验,又稀释了少数人的痛苦。
Google 的标准就是 p75:CrUX 报告、Search Console、排名信号全部看 p75。p75 LCP < 2.5s 就是"良好"。你内部看板直接对齐这个口径,免得和 SEO 同事鸡同鸭讲。
看板至少要这几张:
- 全局总览:每个核心指标的 p75 / p95 时间序列,叠加发版标记(在折线上画 release 版本号竖线)。
- 按页面切片:Top 20 页面的 p75 LCP/INP/CLS 排行。优先优化最差的那几个。
- 按设备 × 网络切片:
mobile + 4g、desktop + wifi这种组合的 p75,把"真实世界的慢"暴露出来。 - 趋势对比:本周 vs 上周、本版本 vs 上一版本。绝对值会随用户结构漂移,版本间对比才能告诉你"改动有没有效"。
六、告警:突变比绝对值更要命
告警设计有一条核心原则:绝对阈值告警 + 趋势突变告警,两者都要。
绝对阈值是地板:p75 LCP 连续 1 小时 > 4s,报警——这说明某类用户体验已经很差。但绝对阈值会"温水煮青蛙",慢慢恶化到边界你才反应。
更敏感的是突变告警:p75 LCP 相比前 24 小时基线涨幅 > 30%,报警。这条规则最有力,因为它精准对应"刚发了个新版本,把性能搞坏了"这个最高频的故障模式。
几条我实战配过的告警规则,直接抄:
- p75 LCP 环比涨 > 30% 持续 15 分钟 → 多半是新版本引入了大资源或回归。
- CLS p75 突然 > 0.25 → 布局不稳定,通常是有动态注入的 DOM(广告、banner、字体回退)。
- JS 错误率 > 1%(或环比 5 倍) → 经典发版炸了。
- TTFB p75 > 1.5s → 后端/CDN 问题,不是前端,立刻转交。
- 某 release 版本的采样数 < 上一版本 50% → 报告链路可能挂了,数据可能在骗你。
第 5 条很多人忽略。RUM 数据会静默失败——上报端点挂了、CORS 配错、SDK 报错被 try/catch 吞了,你不会有任何告警,只是看板上数字慢慢变小。所以一定要监控"上报量"本身,数据掉量比指标变差更早暴露问题。
七、配合后端 trace:用户慢,慢在哪一段
RUM 告诉你"用户感觉慢",但不直接告诉你"为什么慢"。LCP 4 秒,是因为 TTFB 就占了 2.5 秒(后端/CDN 问题),还是因为图片下载 1.8 秒(前端资源问题),还是因为 hydration 卡了 1.2 秒(前端 JS 问题)?RUM 给总数,trace 给分解。
web-vitals 的指标本身有隐含的分解:TTFB 是服务端响应,FCP - TTFB 是 HTML/首字节到首次绘制,LCP - FCP 是主内容渲染。把这些差值都上报,看板上画出来,慢在哪段一目了然。
更进一步,把前端 RUM 和后端分布式 trace 关联:上报时带上 traceId(从 server-timing header 或 HTML 里注入),后端那条 trace 就能和这次 LCP 对应起来。Sentry、Datadog、OpenTelemetry 都支持这种前端到后端的串联。定位问题时,点开一个慢 LCP 样本,直接跳到后端 trace,看到底是哪个 SQL 慢、哪个下游服务超时——这种排查效率,没接 trace 的人想象不到。
一个常见误区:别只盯着前端。我见过不少团队 RUM 一报 LCP 慢就开始砍 JS bundle,折腾两周没改善,最后发现是后端一个 N+1 查询把 TTFB 拖到了 2 秒。让数据说话,别让屁股决定结论。
八、真实场景:一次发版让 LCP 翻倍
讲个真事。去年一个团队发了个小版本,加了个富文本编辑器组件。CI 全绿,Lighthouse 还是 92,周会庆功。第二天早会,RUM 看板上 p75 LCP 从 2.1s 直接跳到 4.3s,移动端 4G 用户从 3.8s 飙到 7.2s。
按 release 切片,新版 vs 旧版一目了然。点开慢样本看,发现新引入的编辑器把一个 380KB 的 JS bundle 塞进了首屏(在列表页根本用不到,但被顶层 import 了)。LCP 时间线显示,主图早就下完了,但主线程被编辑器 JS 占着,LCP 元素一直 paint 不出来。
10 分钟回滚,p75 LCP 立刻回落到 2.2s。后续把编辑器改成动态 import + 路由级 code splitting,重新发版,数字稳定。
如果没有 RUM,这件事的剧本是这样的:客诉攒两周、客服 escalation、老板截图质问、研发拍脑袋怀疑三天、翻 git log 翻到眼瞎、最后定位到那个 import。从"上线"到"定位"可能是 7 天,而 RUM 把它压到了 1 小时。这就是 RUM 的真实价值——不是让网站更快,而是让"变慢"这件事第一时间被发现。
小结
- Lighthouse 是开发期体检,RUM 是生产期监护。前者告诉你能多快,后者告诉你用户实际多快。两个都得有,只有 RUM 能让野外用户的痛苦显形。
- 采集用 web-vitals,上报用 sendBeacon + fetch keepalive,visibilitychange 触发,别用 unload。INP/CLS 可能多次回调,按
metric.id去重。 - 数据点必须带 sessionId / release / device / connection,否则看板只剩平均数,什么都定位不了。JS 错误走同一根管道。
- 看板看 p75,别看平均;按页面、设备、网络切片;版本间对比比绝对值更有意义。
- 告警既看绝对阈值也看突变。p75 LCP 环比涨 30% 这种"突变"规则,是最早暴露新版本回归的雷达。同时监控上报量本身——数据掉量比指标变差更先发生。
- RUM 配合 trace,才能回答"慢在哪一段"。别默认前端是元凶,TTFB / 主线程 / 资源下载,让数据指认。
最后一句:"上线不是终点,是观测的起点。" 你不在生产环境盯着真实用户的体验,就等于把网站扔进了黑盒——里面的尖叫你一句都听不见。RUM 是你塞进黑盒里的那只耳朵。
Related