首屏 2400 毫秒,然后呢
某客户端的详情页首屏耗时 2400 毫秒。监控只报了一个总数,团队凭直觉判断「网络慢」,于是加了一层客户端缓存。下一个版本首屏降到 2280 毫秒,改善 5%,问题依旧。
把同一次加载的 trace 拆开,数字是这样的(构造数据):
| 阶段 | 自耗时 | 占比 | 期间发生了什么 |
|---|---|---|---|
| network | 1500 ms | 62.5% | 三个串行请求:配置、详情、推荐 |
| parse | 260 ms | 10.8% | 详情 JSON 解码与 DTO 映射 |
| render | 420 ms | 17.5% | 首屏布局与图片占位 |
| script | 220 ms | 9.2% | 价格计算与埋点组装 |
缓存只命中了详情请求,配置与推荐仍然串行,所以 1500 毫秒里只省下 120 毫秒。真正该改的是请求编排:把配置改成启动时预取,把推荐改成首屏后加载,关键路径能直接少一跳。
这就是归因要回答的三个问题:慢在哪个阶段、哪条路径、哪一行代码。只知道「首屏慢」,所有优化都是猜测。
四阶段的归因模型
阶段不能按团队组织架构拆,而要按「时间花在哪里」拆。四个阶段的边界要能落到代码层:
| 阶段 | 包含 | 常见占比 | 首先看哪里 | 典型误判 |
|---|---|---|---|---|
| network | DNS、连接、请求排队、重试 | 45%–65% | 串行请求、超时配置 | 以为带宽不够 |
| parse | JSON 解码、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,
};
}
代码里有四个关键选择:
unionDuration先合并重叠区间,保证总时长不会被重复计算。exclusiveOf只减直接子事件的并集,避免把孙事件重复扣除。criticalPath从最晚结束的根出发,每层选最晚结束的子节点,而不是选耗时最大的阶段。suspiciousEvents同时看占比与绝对耗时,避免把 5 毫秒的解析阶段标成可疑。
判据与反例
判据一:自耗时占比之和应接近 1。 如果四个阶段加起来是 1.4,说明有事件被重复计时,或者父事件没有正确挂载子事件。先修口径,再看结论。
判据二:关键路径优先于占比。 占比最高的阶段不一定在关键路径上。一个 800 毫秒的并行图片解码,可能和首屏无关;一个 200 毫秒的串行配置请求,才是必须等的那一跳。
判据三:可疑标记要带绝对耗时。 只按占比排序,短请求里的解析阶段会被放大。默认阈值是占比不低于 25% 且自耗时不低于 50 毫秒,两个条件同时满足才标记。
判据四:和预期占比比,而不是和感觉比。 网络阶段占 62% 对首屏页可能正常,对纯本地页面就是异常。deviationFromExpected 用倍数表达偏离,便于跨页面比较。
四个反例:
- 把包含时间直接相加。 父事件与子事件各算一次,总时长虚高,占比全部失真。
- 只看占比最高的阶段。 高占比可能来自并行任务,优化它不会缩短关键路径。
- 用单次采样下结论。 一次 trace 的抖动可能超过版本差异,至少要取多次并看分位数。
- 把归因结果直接当优化方案。 定位到「脚本阶段」不等于知道改哪一行,还要继续下钻到函数与调用栈。
一次「网络慢」的复盘
回到开头的 2400 毫秒。团队最初的判断是网络慢,依据是 network 占比 62.5%。这个判断只对了一半:
- 网络阶段确实最大,但它由三个串行请求组成。
- 缓存只覆盖其中一个请求,所以只省下 120 毫秒。
- 把配置请求提前到启动阶段、推荐请求延后到首屏之后,才是真正缩短关键路径的改法。
复盘时,团队把归因表换成了「阶段 + 关键路径 + 代码层提示」三列。评审时第一个问题不再是「哪个阶段占比最高」,而是「关键路径上还有几跳、每一跳能不能并行」。问题变具体,方案才不会跑偏。
测试与输出
✔ 嵌套子事件的时间不重复计入父事件
✔ 阶段按自耗时降序排列
✔ 关键路径沿最晚子节点向下
✔ 关键路径并列时按 id 稳定选择
✔ 可疑阶段按自耗时占比标记
✔ 缺失父节点的事件按根处理
ℹ tests 18
ℹ pass 18
ℹ fail 0
这些用例里最重要的是「嵌套」与「并列」两类。前者验证时间口径,后者验证结果稳定;归因结果每次构建都变,评审就无法比较。
小结
归因不是找一个最大的数字,而是把时间拆到可行动的位置。
- 按网络、解析、渲染、脚本四阶段拆解,先统一包含时间与自耗时的口径。
- 总时长取区间并集,嵌套事件不能重复计时。
- 关键路径按「最晚结束」逐层下钻,不按阶段占比选。
- 可疑阶段要同时满足占比与绝对耗时,结论才不会被短事件放大。
归因的终点不是阶段名,而是「下一跳能不能并行、下一行能不能删」。
阅读导航:上一节:4.1 性能预算与分端指标 · 下一节:4.3 优化闭环与回归防护 。
常见错误与边界
- 把父事件与子事件的耗时直接相加。嵌套结构会重复计时,占比全部失真。
- 按占比最高选优化对象。并行任务占比再高,也不在关键路径上。
- 用包含时间计算阶段占比。父阶段会把子阶段的时间算进自己。
- 只做一次采样。单次 trace 的抖动可能大于版本差异。
- 可疑阈值只设占比。短请求里的解析阶段会被误标为瓶颈。
- 把阶段名当代码位置。定位到脚本阶段,离定位到具体函数还很远。
- 缺失父节点时直接丢弃事件。时间会从总时长里消失,归因结果偏小。
- 把本地 trace 外推到所有设备。低端机的主线程与内存压力完全不同。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。