结论先行:RUM 大盘最容易被低估的污染源不是弱网、不是低端机,而是同一用户同时开 3~5 个标签页这件事本身——它把 LCP 拉成「假健康」、把同一个 bug 的影响面放大成 3 倍、把并行会话串成一条超长会话。根因是几乎所有 RUM SDK 的聚合主键都是
user_id,而 LCP / 错误 / 会话时长这三类指标的真实计算单元是「标签页(文档)」而非「用户」。主键错配,数字就系统性失真。
一、现象与背景
我们是一个 B 端中台(React 18.2 + Vite 5 + TS 5.5,约 220 个路由,自研 RUM SDK 100% 采样,日均 UV 约 12 万)。客服和运营的习惯是同时开 3~5 个 tab 处理同一产品(左边工单、右边客户资料、再开一个知识库),我们埋点侧统计:单日内「同一 user_id 有 ≥3 个并发文档」的用户占比约 38%,这部分人贡献了约 51% 的上报事件。
三个被污染的指标,数字摆出来:
- LCP 假健康:大盘按
user_id聚合 LCP P75 显示 1.8s(Good 阈值 <2.5s,一片绿)。但按「每个 tab 唯一 LCP 样本、再取该用户最差 tab」重算后,P75 变成 2.6s——多 tab 里总有一两个「预取命中、秒开」的快 tab 把分位数拉低,幸存者偏差把问题藏住了。 - 错误影响面虚高:一个
TypeError在错误看板显示「影响 21,000 用户」。按user_id去重后实为 6,300 个用户 × 平均 3.3 个 tab——同一 bug 因为每个 tab 都触发了一次、指纹相同,被记成 3 倍规模,值班同学据此升级成了 P1。 - 会话时长虚高:按
user_id+ 30 分钟 gap 切分,这批用户人均会话时长 74 分钟;按「用户 × 标签页」拆开后回到 26 分钟——并行的 3 条会话被串成了一条。
二、原理与根因
三类失真,机制各不相同,但都指向「主键 = user_id」这个错误默认:
- LCP 是「每文档」指标,不是「每用户」指标。 浏览器对每个导航文档独立计算 LCP(
LargestContentfulPaintentry 挂在各自的navigationStart上)。同一用户开 3 个 tab 就产生了 3 个独立 LCP 样本。聚合取 P75 时,这 3 个样本互相「稀释」——只要有一个 tab 命中 CDN / 预取,该用户就贡献了一个低值样本,把用户维度的分位数往下拽。样本数被多 tab 放大,快样本占比上升,分位数系统性偏低。 - 错误指纹跨 tab 相同,计数被 tab 数放大。 错误去重指纹通常是
message + 归一化 stack。同一份 bundle 的同一处 bug,在用户开的每个 tab 里都会触发、指纹完全一致。count(*)得到的是「事件数」,count(distinct user_id)才是「用户数」,而看板常常把前者当后者展示。更隐蔽的是:不同 tab 触发路径不同(一个从菜单进、一个从深链进)但指纹相同,被强行合并,真实触发场景被抹平。 - 会话切分把「并行」误判成「串行」。 会话切分本质是「按用户排序的事件流上,相邻事件间隔超 gap 就断一刀」(参见站内 #930 对去重键粒度的分析)。同一
user_id的多个 tab 事件在时间轴上交错到达,切分逻辑会把 3 条并行会话焊成 1 条,时长相加、gap 被跨 tab 事件「填满」,并行在线被误读成长时间专注使用。
三、具体做法(可复现)
核心一条:引入 tabId 作为「文档级」主键,sessionId 作为「会话级」主键,聚合时按指标性质选对主键。
1. 三级 ID 的生成与生命周期
// tabId:文档级。sessionStorage 天然「每 tab 一份、关 tab 即失效」
const tabId = (() => {
let id = sessionStorage.getItem('rum.tabId');
if (!id) {
id = (crypto.randomUUID?.() ?? `t_${Date.now()}_${Math.random().toString(36).slice(2,8)}`);
sessionStorage.setItem('rum.tabId', id);
}
return id;
})();
// sessionId:会话级。localStorage 跨 tab 共享 + 30min 滑动续期
function getSessionId() {
const now = Date.now();
const raw = localStorage.getItem('rum.session');
if (raw) {
const { id, ts } = JSON.parse(raw);
if (now - ts < 30 * 60 * 1000) {
localStorage.setItem('rum.session', JSON.stringify({ id, ts: now }));
return id;
}
}
const id = crypto.randomUUID?.() ?? `s_${now}`;
localStorage.setItem('rum.session', JSON.stringify({ id, ts: now }));
return id;
}
每个上报事件带四元组:eventId(事件唯一,幂等键)、tabId、sessionId、user_id。
2. LCP 聚合:先按 tab 去重,再按用户取「最差」
-- 每 tab 只取该文档唯一 LCP 样本,再对每个用户取最差(最大) tab
WITH per_tab AS (
SELECT user_id, tab_id,
MAX(lcp_ms) AS lcp_ms -- 同 tab 内 LCP 只会上报一次,MAX 兜底
FROM rum_vitals
WHERE metric = 'LCP'
GROUP BY user_id, tab_id
)
SELECT
quantile(0.75)(lcp_ms) AS p75,
count(*) AS user_cnt
FROM (
SELECT user_id, MAX(lcp_ms) AS lcp_ms -- 用户体验看最差那个 tab
FROM per_tab
GROUP BY user_id
) t;
关键:用户维度取 MAX(最差 tab)而非 AVG——用户投诉的永远是那个慢 tab,不是平均。
3. 错误影响面:「用户数」与「tab 数」分开报
SELECT
fingerprint,
count(DISTINCT user_id) AS affected_users, -- 真正的影响面(用于定级)
count(DISTINCT tab_id) AS affected_tabs, -- 触发广度(用于判断是否普遍路径)
count(*) AS event_cnt -- 仅用于趋势,绝不用于「影响 N 人」
FROM rum_errors
WHERE fingerprint = 'xxx'
GROUP BY fingerprint;
定级看 affected_users;affected_tabs / affected_users 这个比值(≈1.0 说明是单 tab 必现,≈3.0 说明和并发 tab 强相关)本身就是排查线索。
4. 会话切分:主键升级为 (user_id, tab_id)
把 #930 那套 lag() 物化 session_id 的口径保留,但 PARTITION BY 从 user_id 改成 (user_id, tab_id),并行 tab 天然各自成会话,不再串号。
5. 采样一致性:决策绑定 sessionId
若后续要降采样,采样率对 sessionId 取哈希,而不是对每次请求或每个 tab——保证「同一会话内所有 tab、所有事件要么全采、要么全丢」,否则分会比(分母口径)会再错一次。
四、踩过的坑 / 反直觉
- 修完大盘「变差了」,这是对的。 把 LCP 从「取平均」改成「取最差 tab」后 P75 从 1.8s 涨到 2.6s,第一反应是「是不是退化了我们?」——不是,是之前一直低估了。健康度看的是「最差体验」,不是「平均体验」。
sessionStorage在window.open(同源)时会被复制。 用户在页面里点「在新窗口打开」,新窗口继承了旧 tab 的sessionStorage,tabId撞车,两个 tab 被当成同一个。解法:拿到tabId后用performance.getEntriesByType('navigation')[0].id或document对象引用做二次校验,撞车则重新生成。- 关 tab 时上报的 tabId 会丢。 最后一个 tab 关闭触发
pagehide上报时,若此时sessionStorage已被清理,事件里tabId为空、无法归组。这正落在 #2265 讲过的「sendBeacon 卸载窗口只有约 50ms、静默丢包」的高危区——tab 级事件要尽量在visibilitychange→hidden时提前上报,别全押在pagehide。 - 别用
count(*)当「影响用户数」给业务方看。 这条最贵:一次把 6,300 用户误报成 21,000,直接触发了一次不必要的 P1 升级和一次回滚评估。
五、结论与可复用清单
一句话:LCP / 错误 / 会话时长这三类指标的计算单元是「标签页」,不是「用户」;主键错配会让 RUM 大盘系统性偏向「乐观」。 可复用清单:
- [ ] 引入
tabId(sessionStorage,window.open场景做二次校验)+sessionId(localStorage,30min 滑动),事件带eventId/tabId/sessionId/user_id四元组; - [ ] LCP 聚合:先按
tab_id去重,再按user_id取MAX(最差 tab),别取平均; - [ ] 错误定级用
count(DISTINCT user_id),count(*)只看趋势;tabs/users比值作为排查信号; - [ ] 会话切分
PARTITION BY (user_id, tab_id),避免并行串号; - [ ] 采样率绑定
sessionId取哈希,保证同会话口径一致; - [ ] tab 级事件在
visibilitychange→hidden提前上报,别全押pagehide(卸载窗口极短且静默丢包)。
参考:站内 #930《会话切分为什么总对不齐》里「同一 user_id 多设备并行在线时,按 user_id 合并会把两条并行会话串成一条」,正是多标签页在会话时长上的同源问题;#2265《sendBeacon 静默丢包》解释了为什么 tab 级上报要提前、不能只押
pagehide。