同一个数字,三种故事
「回声」进入端上观测阶段后,第一个争论不是“采不采 LCP”,而是“周报上的 LCP 到底写哪个数”。同一批样本,均值、p75、p95 可以讲出三个方向:均值说“整体还行”,p75 说“四分之一用户在等”,p95 说“最慢那批已经放弃”。如果指标字典不先固定口径,团队会在每次评审里重新吵一遍,最后往往是谁的图表好看用谁的。
本节把性能指标拆成定义、采样、窗口与统计量四件事,并用一个可运行的 TypeScript 指标字典把它们固定下来。文中比例、阈值与耗时除标注为本机运行输出外,都是案例假设值,用来演示判断过程。
口径的四个组成部分
定义:先把无效样本挡住
一个指标至少要有名字、单位、类型和有效区间。名字决定报表能不能对齐,单位决定 300 是毫秒还是秒,类型决定它该看分位还是看速率,有效区间决定 0 毫秒和 99999 毫秒要不要算进去。缺少计时器时埋点常写出 0,设备时钟回拨时又可能写出一个未来时间,这两类样本如果不挡掉,p95 会被拉到一个没有意义的位置。
rejectReason 把丢弃原因分成 not-finite、below-min、above-max、bad-timestamp、empty-session 五类。分开记的原因不是好看,而是排障时能回答“这周数据变差是因为用户变慢,还是因为某个版本的计时器坏了”。
采样:谁在什么时候记录
性能指标通常按会话采一次,而不是每帧采一次。按帧采会把长会话的权重放大,让“用得久的人”代表全体用户;按会话采则更接近“一次访问的体验”。采样率本身也要记录,否则采样从 10% 调到 50% 后,样本数翻五倍,均值不变但分位的稳定性变了。
覆盖率是采样是否可信的前置条件。一个只有 12% 会话上报的 p95,哪怕数字很漂亮,也只能说明“愿意上报的那批人”体验不错。代码里的 expectedSessions 就是用来把这件事显式化的。
窗口:按固定时钟切,不按请求切
窗口用左闭右开的 [startMs, endMs)。这样相邻窗口不会重复计算边界样本,跨窗口补报的样本也能被识别为 outOfWindow 而不是悄悄混进本周。窗口长度通常取一天或一小时,但必须和业务周期对齐:如果产品在晚上有流量高峰,按 UTC 切天会把高峰劈成两半。
统计量:时延看分位,计数看速率
时延类指标的主口径用 p75 或 p95,不用均值。均值会被极少数超长样本拉动,也会被大量很快的样本压低,它回答的是“总时长除以次数”,不是“典型用户等了多久”。计数类指标则相反,主口径用“每会话发生次数”,因为对计数器求 p95 没有业务含义。
这里用的是最近秩分位:排序后取第 ceil(p*n) 个真实样本,不做线性插值。插值会在样本之间造出一个不存在的值,对延迟指标尤其容易把 p95 说得比真实体验更好。
把口径写进字典
下面的文件把上述四件事收敛成一个字典和一次窗口聚合。它不依赖 DOM、网络或真实时钟,所有时间都由调用方传入,因此测试里不需要等待。
// file: src/obs/metrics.ts
// tier: L1
// verify: node --test test/obs/metrics.test.ts
export type MetricUnit = "ms" | "count" | "ratio" | "bytes";
export type MetricKind = "timing" | "counter" | "gauge";
export type Statistic = "mean" | "p50" | "p75" | "p95" | "rate";
export interface MetricDefinition {
name: string;
unit: MetricUnit;
kind: MetricKind;
/** 对外口径必须显式选择统计量,不能交给报表工具临时决定 */
headline: Statistic;
/** 低于该值的样本视为无效,例如计时器缺失产生的 0 */
minValid: number;
/** 高于该值的样本视为异常,例如时钟回拨或计时溢出 */
maxValid: number;
description: string;
}
export type MetricDictionary = ReadonlyMap<string, MetricDefinition>;
export interface MetricSample {
name: string;
value: number;
atMs: number;
sessionId: string;
}
export interface AggregateWindow {
startMs: number;
endMs: number;
/** 窗口内应当有样本的会话数,用于计算覆盖率 */
expectedSessions: number;
}
export interface WindowAggregate {
name: string;
unit: MetricUnit;
count: number;
invalid: number;
mean: number;
p50: number;
p75: number;
p95: number;
max: number;
headline: number;
headlineKind: Statistic;
coverage: number;
}
export interface WindowReport {
aggregates: WindowAggregate[];
unknownNames: string[];
outOfWindow: number;
}
const NAME_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/;
/** 最近秩分位:返回排序后第 ceil(p*n) 个样本,不插值。 */
export function percentile(samples: readonly number[], p: number): number {
if (samples.length === 0) throw new Error("percentile: samples 不能为空");
if (!(p > 0 && p <= 1)) throw new Error(`percentile: p 必须在 (0, 1] 内,收到 ${p}`);
const sorted = [...samples].sort((a, b) => a - b);
return sorted[Math.max(1, Math.ceil(p * sorted.length)) - 1];
}
/** 指标字典在启动时校验一次,运行期只做查表,避免每条样本重复判断口径。 */
export function defineMetrics(definitions: readonly MetricDefinition[]): MetricDictionary {
const dictionary = new Map<string, MetricDefinition>();
for (const definition of definitions) {
if (!NAME_PATTERN.test(definition.name)) {
throw new Error(`defineMetrics: 指标名不合法 ${definition.name}`);
}
if (dictionary.has(definition.name)) {
throw new Error(`defineMetrics: 指标名重复 ${definition.name}`);
}
if (definition.minValid > definition.maxValid) {
throw new Error(`defineMetrics: ${definition.name} 的 minValid 大于 maxValid`);
}
if (definition.kind === "counter" && definition.headline !== "rate") {
throw new Error(`defineMetrics: 计数器 ${definition.name} 的口径必须是 rate`);
}
if (definition.kind === "timing" && !["p50", "p75", "p95", "mean"].includes(definition.headline)) {
throw new Error(`defineMetrics: 时延指标 ${definition.name} 的口径必须是分位或均值`);
}
dictionary.set(definition.name, { ...definition });
}
return dictionary;
}
/** 返回 null 表示样本有效;否则返回可写入丢弃原因桶的字符串。 */
export function rejectReason(sample: MetricSample, definition: MetricDefinition): string | null {
if (!Number.isFinite(sample.value)) return "not-finite";
if (sample.value < definition.minValid) return "below-min";
if (sample.value > definition.maxValid) return "above-max";
if (!Number.isFinite(sample.atMs) || sample.atMs < 0) return "bad-timestamp";
if (sample.sessionId.trim() === "") return "empty-session";
return null;
}
function headlineOf(
definition: MetricDefinition,
stats: { mean: number; p50: number; p75: number; p95: number },
total: number,
expectedSessions: number,
): number {
switch (definition.headline) {
case "mean": return stats.mean;
case "p50": return stats.p50;
case "p75": return stats.p75;
case "p95": return stats.p95;
case "rate": return expectedSessions > 0 ? total / expectedSessions : 0;
}
}
/**
* 按时间窗口聚合样本。未知指标不抛错,而是收集到 unknownNames,
* 让数据质量报表能区分“新指标还没登记”和“客户端写错了名字”。
*/
export function aggregateWindow(
samples: readonly MetricSample[],
dictionary: MetricDictionary,
window: AggregateWindow,
): WindowReport {
if (!(window.endMs > window.startMs)) {
throw new Error("aggregateWindow: endMs 必须大于 startMs");
}
const grouped = new Map<string, MetricSample[]>();
const unknown = new Set<string>();
let outOfWindow = 0;
for (const sample of samples) {
if (sample.atMs < window.startMs || sample.atMs >= window.endMs) {
outOfWindow += 1;
continue;
}
if (!dictionary.has(sample.name)) {
unknown.add(sample.name);
continue;
}
const bucket = grouped.get(sample.name) ?? [];
bucket.push(sample);
grouped.set(sample.name, bucket);
}
const aggregates: WindowAggregate[] = [];
for (const name of [...grouped.keys()].sort()) {
const definition = dictionary.get(name);
if (definition === undefined) continue;
const bucket = grouped.get(name) ?? [];
const sessions = new Set<string>();
const valid: number[] = [];
let invalid = 0;
for (const sample of bucket) {
if (rejectReason(sample, definition) !== null) {
invalid += 1;
continue;
}
valid.push(sample.value);
sessions.add(sample.sessionId);
}
if (valid.length === 0) continue;
const total = valid.reduce((sum, value) => sum + value, 0);
const stats = {
mean: total / valid.length,
p50: percentile(valid, 0.5),
p75: percentile(valid, 0.75),
p95: percentile(valid, 0.95),
};
aggregates.push({
name,
unit: definition.unit,
count: valid.length,
invalid,
mean: stats.mean,
p50: stats.p50,
p75: stats.p75,
p95: stats.p95,
max: Math.max(...valid),
headline: headlineOf(definition, stats, total, window.expectedSessions),
headlineKind: definition.headline,
coverage: window.expectedSessions > 0 ? sessions.size / window.expectedSessions : 0,
});
}
return { aggregates, unknownNames: [...unknown].sort(), outOfWindow };
}
/**
* 均值与主口径的差异说明。均值只作为辅助信息,
* 当它和分位讲出不同故事时必须把差异写清楚,而不是二选一隐藏。
*/
export function explainHeadline(aggregate: WindowAggregate): string[] {
const notes: string[] = [];
if (aggregate.headlineKind === "rate") {
notes.push(`${aggregate.name} 按每会话发生次数计数,均值不作为主口径`);
return notes;
}
if (aggregate.headlineKind !== "mean" && aggregate.mean > aggregate.headline * 1.2) {
notes.push(
`${aggregate.name} 均值 ${aggregate.mean.toFixed(1)}${aggregate.unit} 高于 ${aggregate.headlineKind} ${aggregate.headline.toFixed(1)}${aggregate.unit},说明长尾样本在拉高均值`,
);
}
if (aggregate.coverage < 0.8) {
notes.push(`${aggregate.name} 覆盖率仅 ${(aggregate.coverage * 100).toFixed(0)}%,分位可能只代表活跃用户子集`);
}
return notes;
}
聚合结果怎么读
aggregateWindow 返回的字段各自回答一个问题,混用就会得出相反结论:
| 字段 | 含义 | 常见误用 |
|---|---|---|
count | 窗口内有效样本数 | 采样率变化后直接和上周比 |
invalid | 被有效区间挡掉的样本数 | 只看总数,不解释突增 |
mean | 算术平均 | 当成“典型用户耗时” |
p50/p75/p95 | 最近秩分位 | 样本太少时 p95 只代表一两个人 |
headline | 字典选定的主口径 | 报表另选一个数,导致同名不同数 |
coverage | 有样本会话 / 应有机会 | 覆盖率很低仍下全量结论 |
unknownNames 和 outOfWindow 不是错误,而是数据质量信号。前者说明客户端上报了字典里没有的名字,可能是新版本提前上线;后者说明补报或时钟有问题。把它们直接抛掉,等于把最有价值的排障线索扔了。
测试与真实输出
在实验工程根目录执行:
node --test test/obs/metrics.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 指标字典拒绝重复名、非法名和不匹配的口径 (0.779ms)
✔ 最近秩分位不插值,返回真实样本 (0.125917ms)
✔ 无效样本计入 invalid,不参与均值与分位 (1.164958ms)
✔ 计数器按每会话次数出主口径,而不是分位 (0.228875ms)
✔ 未知指标被收集而不是打断整批聚合 (0.150417ms)
✔ 均值被长尾拉高且覆盖率不足时给出两条说明 (0.144042ms)
✔ 样本拒绝原因可以单独复用 (0.066291ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 165.416666
均值与分位为什么会打架
测试里有两组样本值得对照。第一组是 100、200、300,均值 200,p75 却是 300;第二组是九个 100 加一个 2000,均值 290,p75 只有 100。前者说明分位取的是排序位置,不受数值大小线性影响;后者说明一个极端样本就足以把均值抬高近两倍。
所以“均值高还是分位高”没有固定方向,取决于分布形状。真正要固定的是主口径:时延看 p75 或 p95,均值只作为辅助说明。另一个常见错误是把多个窗口的 p75 再求平均,那既不是全局 p75,也不是任何用户的体验,只是几个分位的算术平均。
口径变更管理
指标字典应该和代码一起进版本库。改动 maxValid、headline 或窗口长度,都属于口径变更,不是普通重构。变更时要同时做三件事:在提交信息里写明旧口径与新口径,在报表上标注切换日期,在新旧口径并行一段时间后再下线旧图。
代码本身没有保存字典版本,这是刻意的:字典是输入,版本应该由代码仓库和构建产物承担。如果团队需要更强的可追溯性,可以把字典文件的哈希写进周报元数据,这样看到一张图就能知道它对应哪一版口径。
小结
性能指标的口径由定义、采样、窗口与统计量四件事共同决定。时延指标用最近秩分位做主口径,均值只作辅助;计数指标用每会话速率;无效样本要按原因分桶;覆盖率低时不要下全量结论。
把这些规则写成字典和函数,比写在文档里更可靠。文档会被跳过,函数会在每次聚合时执行。下一节讨论另一类更难归一化的数据:崩溃堆栈与卡顿样本。
阅读导航:上一节:11.3 埋点补报与可恢复性 · 下一节:12.2 崩溃与卡顿归因 。
常见错误与边界
- 用均值代表典型体验。均值回答的是总时长除以次数,不是“用户等了多久”。
- 把不同窗口的 p75 再平均。分位不能跨窗口直接算术平均。
- 不设有效区间。0 毫秒和时钟溢出值会直接污染分位。
- 采样率变化后直接比较样本数。样本数变了,分位的稳定性也变了。
- 覆盖率很低仍下全量结论。先看
coverage,再看数字。 - 把未知指标当脏数据丢掉。它往往意味着新版本已经上线而字典还没更新。
- 改了
headline却不标注。报表会出现同名不同数,且没人知道从哪天开始变。 - 把窗口边界设成闭区间。相邻窗口会重复计算边界样本。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。