《互联网产品的客户端架构·规模化与架构演进》4.2 归因:从慢到哪一行

只知道首屏慢没有用,必须回答慢在哪个阶段、哪条路径、哪一行。本节把一次加载拆成网络、解析、渲染、脚本四个阶段,用可运行的 TypeScript 模块计算自耗时、阶段占比、关键路径与可疑阶段,把结论落到具体代码层,避免把并行任务误当成瓶颈,并给出归因时最常见的四类误判。

首屏 2400 毫秒,然后呢

某客户端的详情页首屏耗时 2400 毫秒。监控只报了一个总数,团队凭直觉判断「网络慢」,于是加了一层客户端缓存。下一个版本首屏降到 2280 毫秒,改善 5%,问题依旧。

把同一次加载的 trace 拆开,数字是这样的(构造数据):

阶段自耗时占比期间发生了什么
network1500 ms62.5%三个串行请求:配置、详情、推荐
parse260 ms10.8%详情 JSON 解码与 DTO 映射
render420 ms17.5%首屏布局与图片占位
script220 ms9.2%价格计算与埋点组装

缓存只命中了详情请求,配置与推荐仍然串行,所以 1500 毫秒里只省下 120 毫秒。真正该改的是请求编排:把配置改成启动时预取,把推荐改成首屏后加载,关键路径能直接少一跳。

这就是归因要回答的三个问题:慢在哪个阶段、哪条路径、哪一行代码。只知道「首屏慢」,所有优化都是猜测。

四阶段的归因模型

阶段不能按团队组织架构拆,而要按「时间花在哪里」拆。四个阶段的边界要能落到代码层:

阶段包含常见占比首先看哪里典型误判
networkDNS、连接、请求排队、重试45%–65%串行请求、超时配置以为带宽不够
parseJSON 解码、DTO 映射、校验5%–15%大对象、重复解析以为可以忽略
render布局、列表复用、图片解码15%–30%首屏节点数、同步布局以为全是脚本问题
script事件处理、同步计算、埋点5%–20%主线程长任务以为加机器能解决

模型还要区分两种时间:

  • 包含时间:父事件从开始到结束的总时长,包含子事件。
  • 自耗时:父事件减去子事件并集后的时间,才是这个阶段真正花掉的时间。

把包含时间直接相加,会把嵌套事件算两遍,得出一个超过总时长的荒谬占比。归因的第一步不是排序,而是把时间口径统一。

把阶段拆解写成代码

下面的模块用「事件树 + 区间并集」表达一次加载:父事件带子事件,自耗时扣除子事件覆盖的区间,总时长取顶层事件的并集。关键路径与可疑阶段都在这个模型上计算:

// file: src/perf/perf-attribution.ts
// tier: L1
// verify: node --test test/perf/perf-attribution.test.ts

export type Phase = "network" | "parse" | "render" | "script";

export type PhaseEvent = {
  id: string;
  phase: Phase;
  start: number;
  duration: number;
  parentId?: string;
};

export type Interval = {
  start: number;
  end: number;
};

export const PHASES: Phase[] = ["network", "parse", "render", "script"];

export const EXPECTED_SHARE: Record<Phase, number> = {
  network: 0.55,
  parse: 0.1,
  render: 0.2,
  script: 0.15,
};

export function endOf(event: PhaseEvent): number {
  return event.start + event.duration;
}

export function unionDuration(intervals: Interval[]): number {
  const valid = intervals
    .filter((item) => Number.isFinite(item.start) && Number.isFinite(item.end) && item.end > item.start)
    .sort((a, b) => a.start - b.start || a.end - b.end);
  let total = 0;
  let cursor = Number.NEGATIVE_INFINITY;
  for (const item of valid) {
    const start = Math.max(item.start, cursor);
    if (item.end > start) total += item.end - start;
    cursor = Math.max(cursor, item.end);
  }
  return total;
}

export function topLevelEvents(events: PhaseEvent[]): PhaseEvent[] {
  const ids = new Set(events.map((event) => event.id));
  return events.filter((event) => event.parentId === undefined || !ids.has(event.parentId));
}

export function totalDuration(events: PhaseEvent[]): number {
  return unionDuration(topLevelEvents(events).map((event) => ({ start: event.start, end: endOf(event) })));
}

export function childrenOf(events: PhaseEvent[], id: string): PhaseEvent[] {
  return events.filter((event) => event.parentId === id);
}

export function exclusiveOf(events: PhaseEvent[], event: PhaseEvent): number {
  const children = childrenOf(events, event.id).map((child) => ({ start: child.start, end: endOf(child) }));
  return Math.max(0, event.duration - unionDuration(children));
}

export function phaseTotals(events: PhaseEvent[]): Record<Phase, number> {
  const out: Record<Phase, number> = { network: 0, parse: 0, render: 0, script: 0 };
  for (const event of events) {
    out[event.phase] += exclusiveOf(events, event);
  }
  return out;
}

export function phaseShares(events: PhaseEvent[]): Record<Phase, number> {
  const totals = phaseTotals(events);
  const sum = PHASES.reduce((acc, phase) => acc + totals[phase], 0);
  const out: Record<Phase, number> = { network: 0, parse: 0, render: 0, script: 0 };
  if (sum === 0) return out;
  for (const phase of PHASES) out[phase] = totals[phase] / sum;
  return out;
}

export type PhaseRank = {
  phase: Phase;
  exclusive: number;
  share: number;
};

export function rankPhases(events: PhaseEvent[]): PhaseRank[] {
  const totals = phaseTotals(events);
  const shares = phaseShares(events);
  return PHASES.map((phase) => ({ phase, exclusive: totals[phase], share: shares[phase] })).sort((a, b) => {
    if (b.exclusive !== a.exclusive) return b.exclusive - a.exclusive;
    return PHASES.indexOf(a.phase) - PHASES.indexOf(b.phase);
  });
}

export function criticalPath(events: PhaseEvent[]): string[] {
  const roots = topLevelEvents(events);
  if (roots.length === 0) return [];
  const pickLatest = (items: PhaseEvent[]): PhaseEvent =>
    items.reduce((best, item) => {
      const bestEnd = endOf(best);
      const itemEnd = endOf(item);
      if (itemEnd > bestEnd) return item;
      if (itemEnd === bestEnd && item.id < best.id) return item;
      return best;
    });
  const path: string[] = [];
  let current = pickLatest(roots);
  while (current) {
    path.push(current.id);
    const children = childrenOf(events, current.id);
    if (children.length === 0) break;
    current = pickLatest(children);
  }
  return path;
}

export function criticalPathDuration(events: PhaseEvent[]): number {
  const path = criticalPath(events);
  if (path.length === 0) return 0;
  const byId = new Map(events.map((event) => [event.id, event]));
  const first = byId.get(path[0]) as PhaseEvent;
  const last = byId.get(path[path.length - 1]) as PhaseEvent;
  return endOf(last) - first.start;
}

export type SuspicionOptions = {
  shareThreshold?: number;
  minDuration?: number;
};

export type Suspicion = {
  event: PhaseEvent;
  exclusive: number;
  share: number;
  hint: string;
};

export function suspiciousEvents(events: PhaseEvent[], options: SuspicionOptions = {}): Suspicion[] {
  const shareThreshold = options.shareThreshold ?? 0.25;
  const minDuration = options.minDuration ?? 50;
  const total = totalDuration(events);
  if (total === 0) return [];
  const out: Suspicion[] = [];
  for (const event of events) {
    const exclusive = exclusiveOf(events, event);
    const share = exclusive / total;
    if (share >= shareThreshold && exclusive >= minDuration) {
      out.push({ event, exclusive, share, hint: lineHint(event.phase) });
    }
  }
  return out.sort((a, b) => b.exclusive - a.exclusive || a.event.id.localeCompare(b.event.id));
}

export function lineHint(phase: Phase): string {
  if (phase === "network") return "请求编排层:串行请求、重试与超时配置";
  if (phase === "parse") return "数据解析层:JSON 解码、DTO 映射与校验";
  if (phase === "render") return "视图层:布局计算、列表复用与重绘";
  return "业务脚本层:事件处理、同步计算与大对象遍历";
}

export type PhaseDeviation = {
  phase: Phase;
  share: number;
  expected: number;
  factor: number;
};

export function deviationFromExpected(events: PhaseEvent[]): PhaseDeviation[] {
  const shares = phaseShares(events);
  return PHASES.map((phase) => ({
    phase,
    share: shares[phase],
    expected: EXPECTED_SHARE[phase],
    factor: EXPECTED_SHARE[phase] === 0 ? 0 : shares[phase] / EXPECTED_SHARE[phase],
  })).sort((a, b) => b.factor - a.factor);
}

export type AttributionReport = {
  total: number;
  phases: PhaseRank[];
  criticalPath: string[];
  criticalDuration: number;
  suspicious: Suspicion[];
  topPhase: Phase | undefined;
};

export function attributionReport(events: PhaseEvent[], options: SuspicionOptions = {}): AttributionReport {
  const phases = rankPhases(events);
  return {
    total: totalDuration(events),
    phases,
    criticalPath: criticalPath(events),
    criticalDuration: criticalPathDuration(events),
    suspicious: suspiciousEvents(events, options),
    topPhase: phases[0]?.exclusive > 0 ? phases[0].phase : undefined,
  };
}

代码里有四个关键选择:

  1. unionDuration 先合并重叠区间,保证总时长不会被重复计算。
  2. exclusiveOf 只减直接子事件的并集,避免把孙事件重复扣除。
  3. criticalPath 从最晚结束的根出发,每层选最晚结束的子节点,而不是选耗时最大的阶段。
  4. suspiciousEvents 同时看占比与绝对耗时,避免把 5 毫秒的解析阶段标成可疑。

判据与反例

判据一:自耗时占比之和应接近 1。 如果四个阶段加起来是 1.4,说明有事件被重复计时,或者父事件没有正确挂载子事件。先修口径,再看结论。

判据二:关键路径优先于占比。 占比最高的阶段不一定在关键路径上。一个 800 毫秒的并行图片解码,可能和首屏无关;一个 200 毫秒的串行配置请求,才是必须等的那一跳。

判据三:可疑标记要带绝对耗时。 只按占比排序,短请求里的解析阶段会被放大。默认阈值是占比不低于 25% 且自耗时不低于 50 毫秒,两个条件同时满足才标记。

判据四:和预期占比比,而不是和感觉比。 网络阶段占 62% 对首屏页可能正常,对纯本地页面就是异常。deviationFromExpected 用倍数表达偏离,便于跨页面比较。

四个反例:

  1. 把包含时间直接相加。 父事件与子事件各算一次,总时长虚高,占比全部失真。
  2. 只看占比最高的阶段。 高占比可能来自并行任务,优化它不会缩短关键路径。
  3. 用单次采样下结论。 一次 trace 的抖动可能超过版本差异,至少要取多次并看分位数。
  4. 把归因结果直接当优化方案。 定位到「脚本阶段」不等于知道改哪一行,还要继续下钻到函数与调用栈。

一次「网络慢」的复盘

回到开头的 2400 毫秒。团队最初的判断是网络慢,依据是 network 占比 62.5%。这个判断只对了一半:

  • 网络阶段确实最大,但它由三个串行请求组成。
  • 缓存只覆盖其中一个请求,所以只省下 120 毫秒。
  • 把配置请求提前到启动阶段、推荐请求延后到首屏之后,才是真正缩短关键路径的改法。

复盘时,团队把归因表换成了「阶段 + 关键路径 + 代码层提示」三列。评审时第一个问题不再是「哪个阶段占比最高」,而是「关键路径上还有几跳、每一跳能不能并行」。问题变具体,方案才不会跑偏。

测试与输出

✔ 嵌套子事件的时间不重复计入父事件
✔ 阶段按自耗时降序排列
✔ 关键路径沿最晚子节点向下
✔ 关键路径并列时按 id 稳定选择
✔ 可疑阶段按自耗时占比标记
✔ 缺失父节点的事件按根处理
ℹ tests 18
ℹ pass 18
ℹ fail 0

这些用例里最重要的是「嵌套」与「并列」两类。前者验证时间口径,后者验证结果稳定;归因结果每次构建都变,评审就无法比较。

小结

归因不是找一个最大的数字,而是把时间拆到可行动的位置。

  • 按网络、解析、渲染、脚本四阶段拆解,先统一包含时间与自耗时的口径。
  • 总时长取区间并集,嵌套事件不能重复计时。
  • 关键路径按「最晚结束」逐层下钻,不按阶段占比选。
  • 可疑阶段要同时满足占比与绝对耗时,结论才不会被短事件放大。

归因的终点不是阶段名,而是「下一跳能不能并行、下一行能不能删」。

阅读导航:上一节:4.1 性能预算与分端指标 · 下一节:4.3 优化闭环与回归防护 。

常见错误与边界

  • 把父事件与子事件的耗时直接相加。嵌套结构会重复计时,占比全部失真。
  • 按占比最高选优化对象。并行任务占比再高,也不在关键路径上。
  • 用包含时间计算阶段占比。父阶段会把子阶段的时间算进自己。
  • 只做一次采样。单次 trace 的抖动可能大于版本差异。
  • 可疑阈值只设占比。短请求里的解析阶段会被误标为瓶颈。
  • 把阶段名当代码位置。定位到脚本阶段,离定位到具体函数还很远。
  • 缺失父节点时直接丢弃事件。时间会从总时长里消失,归因结果偏小。
  • 把本地 trace 外推到所有设备。低端机的主线程与内存压力完全不同。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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