前端 RUM 自建方案调研:从采集到报表的每一步
上一篇《前端 RUM:真实用户性能监测》讲清了 RUM 是什么、怎么用浏览器的 Performance API 采数据。但「采集」只是开始——真正的工程难题在后面:数据怎么不丢地送出去、怎么存得又快又省、怎么从海量原始数据里榨出结论、怎么把结论变成报表和告警。
这篇把「自建 RUM」当成一条完整流水线来拆,深入每一个环节:采集 → 传输 → 接入 → 存储 → 聚合 → 分析 → 报表。每一层都给出取舍和可落地的方案。
一、整体架构:一条流水线
自建 RUM 不是「写个上报接口 + 存进数据库」那么简单,它是一条从浏览器到报表的流水线:
每一层各自解决一类问题:
| 层 | 解决什么问题 | 关键技术点 |
|---|---|---|
| 采集 SDK | 采什么、怎么采得准 | PerformanceObserver、错误捕获、bfcache |
| 传输 | 怎么不丢数据、不拖慢页面 | sendBeacon、批量、压缩、采样 |
| 接入 | 怎么挡住脏数据、削峰 | 校验、限流、解压、异步写队列 |
| 存储 | 怎么存得下、查得快 | 列式存储、schema 设计、分区/保留 |
| 聚合 | 怎么算分位值、降维 | t-digest、直方图、预聚合 |
| 分析 | 怎么从数据到结论 | 分位值、切片、关联、归因 |
| 报表 | 怎么让结论被看见、被行动 | 看板、SLO、告警 |
下面逐层展开。
二、采集层:SDK 采什么、怎么采得稳
采集层是整条链路的地基,采错了后面全是垃圾。核心三件事:采什么指标、用什么 API、边缘情况怎么兜底。
2.1 指标清单
| 类别 | 指标 | API | 说明 |
|---|---|---|---|
| 加载 | LCP | largest-contentful-paint | 取最后一次 |
| 加载 | FCP / TTFB | navigation 条目 | paint / responseStart - startTime |
| 交互 | INP | event(durationThreshold: 40) | 取最慢一次 |
| 视觉 | CLS | layout-shift | 排除 hadRecentInput |
| 主线程 | 长任务 | longtask | 主线程阻塞 > 50ms |
| 资源 | 慢请求 | resource | 按 initiatorType 细分 |
| 稳定 | 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)才是分析的命根子
指标是「数」,维度是「这个数属于谁」。缺了维度,后面所有分析都做不了。至少要带上:
| 维度 | 来源 |
|---|---|
页面 page | location.pathname |
设备 device | UA 解析(移动/桌面/平板) |
| 浏览器/OS | UA 解析 |
网络 network | navigator.connection.effectiveType(4g/3g/2g) |
地区 region | 服务端由 IP 解析(客户端不做,省得泄漏隐私) |
版本 version | 发版时注入的 release tag |
地区放服务端解析是刻意的:客户端解析要么需要额外请求、要么涉及隐私,服务端拿到 IP 顺手就能算出
region/isp。
2.4 边缘情况:bfcache 与页面卸载
- bfcache(往返缓存):用户点「后退」时页面可能从内存直接恢复,
load不会重跑。用pageshow的event.persisted判断是否是缓存恢复,恢复时补发一次数据、重置状态。 - 页面卸载:部分指标(INP、CLS)是「攒着等最终值」,在
visibilitychange → hidden和pagehide时统一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 代替 type、d 代替 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 场景表现 |
|---|---|---|
| ClickHouse | OLAP 列式 | ✅ 首选:分位值函数全、列式压缩好、亿级查询毫秒级 |
| Elasticsearch | 搜索 + 聚合 | ⚠️ 聚合重、高基维易爆内存,适合日志检索而非指标 |
| VictoriaMetrics / Prometheus | 指标监控 | ⚠️ 拉模式为主、标签高基数有压力,RUM 推模式别扭 |
| TimescaleDB | Postgres 时序扩展 | ⚠️ 能用,但高基数维度下不如 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:设备/浏览器/网络这类枚举值,低基数编码能大幅降存储、提速。 - 指标用数值列:聚合函数(
quantile、avg)直接在列上算。 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 归因:找出「谁」让指标劣化
从「知道变慢了」到「知道为什么」,靠维度归因。常见套路:
- 版本对比:
version维度环比,新版本 LCP p75 是否显著劣化。 - 设备/网络下钻:劣化集中在某个设备或网络类型,是性能问题;均匀分布,是全局(服务端/CDN)问题。
- 漏斗:按「首屏 → 可交互 → 转化」分步看流失,定位卡点。
7.4 趋势与回归检测
单日数据没意义,要和基线比:
- 环比:今天 vs 昨天,周末 vs 工作日。
- 回归检测:发版后 p75 突增超过阈值(如 +20%)即视为回归,联动告警。
八、报表与告警:让数据被看见、被行动
监控的终点不是「有数据」,而是「数据异常时有人知道、知道后能行动」。
8.1 看板分层
自建看板按「从总到分」分层:
概览(今日核心指标 p75 + 达标率 + 环比)
└─ 趋势(近 7/30 天折线)
└─ 分维度(设备/网络/地区/版本 下钻)
└─ 分布(直方图)+ 明细(慢样本/错误堆栈)
8.2 核心面板:一张图看健康度
| 指标 | 口径 | 好 | 需改进 | 差 |
|---|---|---|---|---|
| LCP | p75 | ≤ 2.5s | 2.5~4s | > 4s |
| INP | p75 | ≤ 200ms | 200~500ms | > 500ms |
| CLS | p75 | ≤ 0.1 | 0.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 难的不是采集,而是「把海量原始数据变成一份让人能行动起来的报表」。
