《互联网产品的客户端架构·端形态与体验基础》12.1 性能指标口径

性能指标最容易在口径上失焦:同一个 LCP,均值、p75 与 p95 会讲出不同故事。本节把指标定义、采样边界、窗口聚合与主口径选择写进一个可运行的 TypeScript 指标字典,说明何时用分位、何时看均值,并用真实测试输出验证聚合结果与取舍。

同一个数字,三种故事

「回声」进入端上观测阶段后,第一个争论不是“采不采 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 却不标注。报表会出现同名不同数,且没人知道从哪天开始变。
  • 把窗口边界设成闭区间。相邻窗口会重复计算边界样本。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查