跳到主要内容

前端 RUM 自建方案调研:从采集到报表的每一步

· 阅读需 13 分钟

上一篇《前端 RUM:真实用户性能监测》讲清了 RUM 是什么、怎么用浏览器的 Performance API 采数据。但「采集」只是开始——真正的工程难题在后面:数据怎么不丢地送出去、怎么存得又快又省、怎么从海量原始数据里榨出结论、怎么把结论变成报表和告警

这篇把「自建 RUM」当成一条完整流水线来拆,深入每一个环节:采集 → 传输 → 接入 → 存储 → 聚合 → 分析 → 报表。每一层都给出取舍和可落地的方案。

一、整体架构:一条流水线

自建 RUM 不是「写个上报接口 + 存进数据库」那么简单,它是一条从浏览器到报表的流水线:

每一层各自解决一类问题:

解决什么问题关键技术点
采集 SDK采什么、怎么采得准PerformanceObserver、错误捕获、bfcache
传输怎么不丢数据、不拖慢页面sendBeacon、批量、压缩、采样
接入怎么挡住脏数据、削峰校验、限流、解压、异步写队列
存储怎么存得下、查得快列式存储、schema 设计、分区/保留
聚合怎么算分位值、降维t-digest、直方图、预聚合
分析怎么从数据到结论分位值、切片、关联、归因
报表怎么让结论被看见、被行动看板、SLO、告警

下面逐层展开。

二、采集层:SDK 采什么、怎么采得稳

采集层是整条链路的地基,采错了后面全是垃圾。核心三件事:采什么指标、用什么 API、边缘情况怎么兜底

2.1 指标清单

类别指标API说明
加载LCPlargest-contentful-paint取最后一次
加载FCP / TTFBnavigation 条目paint / responseStart - startTime
交互INPeventdurationThreshold: 40取最慢一次
视觉CLSlayout-shift排除 hadRecentInput
主线程长任务longtask主线程阻塞 > 50ms
资源慢请求resourceinitiatorType 细分
稳定JS 错误 / Promise 异常error / unhandledrejection附 stack
业务自定义埋点performance.mark/measure首屏、接口耗时等

2.2 统一用 PerformanceObserver

所有性能指标都走 PerformanceObserver——异步回调、不阻塞主线程,buffered: true 保证注册晚了也能拿到历史条目:

function observe(type, handler, extra = {}) {
new PerformanceObserver((list) => handler(list.getEntries()))
.observe({ type, buffered: true, ...extra });
}

2.3 维度(Dimension)才是分析的命根子

指标是「数」,维度是「这个数属于谁」。缺了维度,后面所有分析都做不了。至少要带上:

维度来源
页面 pagelocation.pathname
设备 deviceUA 解析(移动/桌面/平板)
浏览器/OSUA 解析
网络 networknavigator.connection.effectiveType(4g/3g/2g)
地区 region服务端由 IP 解析(客户端不做,省得泄漏隐私)
版本 version发版时注入的 release tag

地区放服务端解析是刻意的:客户端解析要么需要额外请求、要么涉及隐私,服务端拿到 IP 顺手就能算出 region/isp

2.4 边缘情况:bfcache 与页面卸载

  • bfcache(往返缓存):用户点「后退」时页面可能从内存直接恢复,load 不会重跑。用 pageshowevent.persisted 判断是否是缓存恢复,恢复时补发一次数据、重置状态。
  • 页面卸载:部分指标(INP、CLS)是「攒着等最终值」,在 visibilitychange → hiddenpagehide 时统一 flush(),别等 unload(不可靠)。
window.addEventListener('pageshow', (e) => {
if (e.persisted) flush({ type: 'bfcache-restore' });
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});

三、传输层:怎么把数据不丢地送出去

监控脚本自己不能拖垮页面,还要保证数据在页面关闭的瞬间也送得出去。

3.1 sendBeacon:页面关了也能送

navigator.sendBeacon 是专为「页面即将卸载时上报」设计的,不阻塞页面关闭。但它没有响应回调、body 有大小上限(约 64KB),所以只适合「最后一条」这类 fire-and-forget 场景:

function send(url, data) {
const body = JSON.stringify(data);
if (navigator.sendBeacon) return navigator.sendBeacon(url, body);
fetch(url, { method: 'POST', body, keepalive: true }); // 兜底:fetch keepalive
}

3.2 批量 + 压缩 + 短键名

日常上报不要一条一发,攒到阈值再发,省请求数、也方便压缩:

const queue = [];
function report(type, data) {
queue.push({ t: type, d: data });
if (queue.length >= 20) flush(); // 攒 20 条或到时间再发
}
setInterval(() => queue.length && flush(), 5000);

body 用短键名t 代替 typed 代替 data),配合 gzip 压缩,数据量能降一个量级。压缩在服务端解,客户端用 Content-Encoding: gzip 时浏览器可自动解压,但 sendBeacon 不支持自定义压缩,所以大批量用 fetch 手动 CompressionStream 压缩、sendBeacon 只发小体积的卸载兜底。

3.3 采样:不必采 100%

数据量与成本正比,用户量大的站点 10% 采样足够统计。用 sessionId 做确定性采样(hash 后取模),比 Math.random() 稳定——同一用户不会被随机裁掉:

const SAMPLE_RATE = 0.1;
const hash = sessionId.split('').reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 1e9, 0);
if ((hash % 100) / 100 > SAMPLE_RATE) return; // 确定性采样

四、接入层:服务端第一道门

上报服务站在浏览器和存储之间,要快、要稳、要挡住脏数据。

// 伪代码:接入层主流程
app.post('/rum', async (req, res) => {
if (!checkToken(req)) return res.status(401).end(); // ① 鉴权
if (rateLimited(req.ip)) return res.status(429).end(); // ② 限流
const body = await decompress(req); // ③ 解压 gzip
const rows = parseAndValidate(body); // ④ 校验、丢弃非法
await queue.produce('rum', rows); // ⑤ 异步写消息队列
res.status(204).end(); // ⑥ 立即返回
});
  • 鉴权:appKey / token,防止被刷。
  • 限流:单 IP / 单 app 维度限流,削峰防打挂。
  • 校验:schema 校验,指标类型不对、维度缺失的直接丢,别让脏数据污染下游。
  • 异步写队列:接口只负责「把数据丢进 Kafka/Pulsar」然后立刻返回,不阻塞浏览器,也解耦「收到数据」和「写入存储」的速率差。
  • 地区解析:在这里用 IP 库把 ip → region/isp 补进去。

五、存储层:选型与 schema 设计

RUM 是典型「写多读少、按时间聚合、维度多」的场景,存储选型直接决定查询体验和成本。

5.1 选型对比

方案定位RUM 场景表现
ClickHouseOLAP 列式✅ 首选:分位值函数全、列式压缩好、亿级查询毫秒级
Elasticsearch搜索 + 聚合⚠️ 聚合重、高基维易爆内存,适合日志检索而非指标
VictoriaMetrics / Prometheus指标监控⚠️ 拉模式为主、标签高基数有压力,RUM 推模式别扭
TimescaleDBPostgres 时序扩展⚠️ 能用,但高基数维度下不如 ClickHouse
InfluxDB时序⚠️ 单机上限低、查询语言几经变更

结论:自建 RUM 默认选 ClickHouse。它天生为「海量事件 + 分组聚合 + 分位值」而生。

5.2 schema:宽表 + LowCardinality

CREATE TABLE rum_events (
ts DateTime,
app String,
page LowCardinality(String),
device LowCardinality(String),
browser LowCardinality(String),
os LowCardinality(String),
network LowCardinality(String),
region LowCardinality(String),
version LowCardinality(String),
-- 指标
lcp Float64, inp Float64, cls Float64, ttfb Float64, fcp Float64,
-- 错误
error_type LowCardinality(String), error_message String,
sample_weight Float64 -- 采样权重,聚合时反推全量
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(ts) -- 按月分区,过期直接删分区
ORDER BY (app, page, ts) -- 按 app + page 聚簇,查询走索引
TTL ts + INTERVAL 90 DAY; -- 保留 90 天自动过期

要点:

  • 维度用 LowCardinality:设备/浏览器/网络这类枚举值,低基数编码能大幅降存储、提速。
  • 指标用数值列:聚合函数(quantileavg)直接在列上算。
  • sample_weight:采样后每条代表 N 个真实用户,聚合时乘权重反推全量。
  • 分区 + TTL:按月分区 + 到期自动删,控制存储成本。

5.3 原始数据 vs 预聚合

  • 原始数据:所有事件存下来,最灵活,但贵、查询慢。
  • 预聚合:定时(如每小时)把原始数据聚合成「分钟/小时级」汇总表(每维度每指标的分位值/计数),查询只看汇总表,快一个量级。

折中:原始数据保留短(7~30 天,供回溯排查),汇总表保留长(1 年,供趋势分析)

六、聚合层:分位值才是答案

6.1 用分位值,别用平均数

平均数会被长尾极值拉偏,监控一律看 p75 / p90 / p95 / p99。Web Vitals 官方就是用 p75 作为达标口径。ClickHouse 原生支持:

SELECT
quantile(0.75)(lcp) AS p75_lcp,
quantile(0.95)(lcp) AS p95_lcp
FROM rum_events WHERE toDate(ts) = today();

大数据量下精确分位值代价高,用 quantileTDigest(t-digest 算法)做近似,内存 O(1)、精度够用。

6.2 直方图看分布

分位值只给一个点,直方图看整体形状——双峰分布(一峰快一峰慢)意味着存在两类用户(如好网 vs 弱网),单看 p75 会漏掉:

SELECT histogram(5)(lcp) AS buckets
FROM rum_events WHERE toDate(ts) = today();

七、数据分析:从原始数据到可行动结论

这是自建 RUM 价值最大的地方。原始数据本身没用,「切片 + 关联 + 归因」才是目的

7.1 切片(Segmentation)

整体指标会掩盖问题。同样的 LCP,拆开看才有意义:

-- LCP 按网络类型切片
SELECT network, quantile(0.75)(lcp) AS p75_lcp, count() AS n
FROM rum_events WHERE toDate(ts) = today()
GROUP BY network ORDER BY p75_lcp DESC;

典型切片维度:页面、设备、浏览器、网络、地区、版本、时段。「移动端 3g 网络 LCP 4.5s」比「整体 LCP 2.1s」更能定位问题。

7.2 关联:性能与错误一起看

性能恶化往往伴随错误率上升。把 LCP 趋势和 JS 错误率放同一张图:如果某版本 LCP 和错误率同时抬头,大概率是这个版本引入的问题,而不是网络波动。

7.3 归因:找出「谁」让指标劣化

从「知道变慢了」到「知道为什么」,靠维度归因。常见套路:

  1. 版本对比version 维度环比,新版本 LCP p75 是否显著劣化。
  2. 设备/网络下钻:劣化集中在某个设备或网络类型,是性能问题;均匀分布,是全局(服务端/CDN)问题。
  3. 漏斗:按「首屏 → 可交互 → 转化」分步看流失,定位卡点。

7.4 趋势与回归检测

单日数据没意义,要和基线比:

  • 环比:今天 vs 昨天,周末 vs 工作日。
  • 回归检测:发版后 p75 突增超过阈值(如 +20%)即视为回归,联动告警。

八、报表与告警:让数据被看见、被行动

监控的终点不是「有数据」,而是「数据异常时有人知道、知道后能行动」。

8.1 看板分层

自建看板按「从总到分」分层:

概览(今日核心指标 p75 + 达标率 + 环比)
└─ 趋势(近 7/30 天折线)
└─ 分维度(设备/网络/地区/版本 下钻)
└─ 分布(直方图)+ 明细(慢样本/错误堆栈)

8.2 核心面板:一张图看健康度

指标口径需改进
LCPp75≤ 2.5s2.5~4s> 4s
INPp75≤ 200ms200~500ms> 500ms
CLSp75≤ 0.10.1~0.25> 0.25

面板上每个指标展示:p75 当前值、环比涨跌、达标率(good 占比),一眼看出是否健康。

8.3 达标率(Good rate)

比 p75 更直观的是「有多少比例的用户体验是好的」:

SELECT
countIf(lcp <= 2500) / count() AS good_rate,
countIf(lcp > 2500 AND lcp <= 4000) / count() AS needs_improvement,
countIf(lcp > 4000) / count() AS poor
FROM rum_events WHERE toDate(ts) = today();

8.4 图表选型

想表达什么用什么图
随时间变化折线图
数值分布直方图 / 箱线图
占比构成堆叠柱 / 饼图
两维关系散点图 / 热力图(如 网络×LCP)
漏斗流失漏斗图
地域分布地图

8.5 告警:定 SLO,自动提醒

给核心指标定 SLO,配告警:

  • 阈值告警:LCP p75 > 2.5s 持续 15 分钟。
  • 环比告警:p75 较昨日同时段 +20%。
  • 错误告警:JS 错误率 > 1%。
  • 收敛:告警聚合 + 静默窗口,避免刷屏。

告警要带上下钻链接,让收到告警的人一键跳到「是哪个维度、哪个版本出的问题」。

8.6 报表:周报/月报自动化

把「本周 p75、环比、Top 劣化页面、Top 错误」自动生成周报,定时推送到群里。让「看监控」从「有人想起来才看」变成「固定节奏」。

九、成本与取舍:自建到底值不值

自建 RUM 的隐性成本别忽略:

取舍说明建议
采样率 vs 精度采样越低越省,但长尾可能失真核心指标 10~30%,明细 1%
保留期 vs 存储原始数据最贵原始 30 天 + 聚合 1 年
指标数量 vs 传输每个指标都要带宽和存储只采「会行动」的指标
开发 vs 维护看板/告警/存储都要长期养小团队先上商业方案

结论:自建的价值在于数据主权 + 无限切片 + 业务联动(能 join 业务数据、看「性能→转化」的因果),代价是整套链路都要自己搭、自己养。数据量小、没专职监控的团队,先用 Sentry / ARMS / Aegis 这类商业方案;等「要按业务维度切片」「要联动订单数据」这类需求出现了,再上自建不迟。

总结

自建前端 RUM 是一条清晰的流水线,每层都有标准答案:

  • :PerformanceObserver 统一采 Web Vitals + 长任务 + 资源 + 错误,维度(设备/网络/地区/版本)是分析命根子,bfcache/卸载要兜底。
  • :sendBeacon 保卸载不丢,批量 + 压缩 + 确定性采样控成本。
  • :鉴权 + 限流 + 校验 + 异步写队列,快速返回。
  • :ClickHouse 列式存储,维度 LowCardinality、按月分区、TTL 过期。
  • :分位值(p75)代替平均数,t-digest 近似、直方图看分布。
  • :切片、关联、归因,把「变慢了」变成「谁、为什么变慢了」。
  • :看板分层、达标率、SLO 告警、自动化周报。

一句话:自建 RUM 难的不是采集,而是「把海量原始数据变成一份让人能行动起来的报表」。