首屏从 1.8 秒降到 1.5 秒,下一个版本又回到 1.79 秒
某团队做了一轮首屏优化:合并请求、延后推荐、把图片改成懒加载。构造数据显示,首屏从 1800 毫秒降到 1500 毫秒,提升 16.7%。团队很满意,优化项关单。
两个版本后,监控显示首屏 1795 毫秒。团队内部出现两种声音:
- 「相比优化前的 1800 毫秒,我们仍然快了,没有问题。」
- 「相比上一个版本的 1500 毫秒,我们退化了 19.7%,必须回滚。」
两种说法都对,但只有一种能用于门禁。性能回归的基线必须是上一个被接受的版本,不是优化开始前的版本。否则每做一次优化,就给自己换来一次「永久豁免」,后面的回归永远看不见。
这就是优化闭环要解决的事:优化项要有登记,基线要锁定,差异要过阈值与显著性两道关,门禁要给出明确结论。
闭环的四个部件
一个能运转的性能闭环,至少包含四张表:
| 部件 | 回答的问题 | 关键字段 | 失败后果 |
|---|---|---|---|
| 优化项登记 | 改了什么、预期收益多少 | id、指标、端、预期收益、状态 | 无法验证优化是否真的生效 |
| 基线 | 和谁比 | 版本、设备、场景、样本 | 回归被旧数据掩盖 |
| 对比 | 差异多大、是否显著 | 均值、变化率、效应量 | 把噪声当回归,或把回归当噪声 |
| 门禁 | 能不能发 | 阻断项、告警项 | 结论含糊,靠人拍板 |
对比结果分四类,不能只留「通过/不通过」:
| 结论 | 条件 | 处置 |
|---|---|---|
| improved | 显著下降 | 记录收益,更新基线 |
| pass | 变化在阈值内,或未达显著 | 正常发布 |
| warn | 超过阈值但统计上不显著 | 发布并限期复测 |
| fail | 超过阈值且显著 | 阻断,修复或回滚 |
阈值与显著性缺一不可。只看阈值,2% 的噪声会被当成 5% 的回归;只看显著性,样本足够大时 0.1% 的差异也会「显著」,但业务上根本不值得管。
把闭环写成代码
下面的模块实现了均值、标准差、变化率、效应量、显著性、四类结论、门禁聚合与优化项登记:
// file: src/perf/perf-regression.ts
// tier: L1
// verify: node --test test/perf/perf-regression.test.ts
export type Sample = {
id: string;
endpoint: string;
metric: string;
before: number[];
after: number[];
};
export type Policy = {
thresholdPct: number;
minEffectPct: number;
zScore: number;
};
export type Verdict = "improved" | "pass" | "warn" | "fail";
export type Comparison = {
id: string;
endpoint: string;
metric: string;
beforeMean: number;
afterMean: number;
changePct: number;
effect: number;
significant: boolean;
verdict: Verdict;
};
export type Optimization = {
id: string;
title: string;
endpoint: string;
metric: string;
expectedGainPct: number;
status: "proposed" | "landed" | "verified";
};
export type GateResult = {
decision: "pass" | "warn" | "fail";
blockers: string[];
warnings: string[];
improved: string[];
};
export const DEFAULT_POLICY: Policy = {
thresholdPct: 3,
minEffectPct: 1,
zScore: 1.96,
};
export function mean(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((acc, value) => acc + value, 0) / values.length;
}
export function stddev(values: number[]): number {
if (values.length < 2) return 0;
const avg = mean(values);
const variance = values.reduce((acc, value) => acc + (value - avg) ** 2, 0) / (values.length - 1);
return Math.sqrt(variance);
}
export function percentChange(before: number[], after: number[]): number {
const base = mean(before);
if (base === 0) return 0;
return ((mean(after) - base) / base) * 100;
}
export function standardError(before: number[], after: number[]): number {
if (before.length < 2 || after.length < 2) return 0;
const variance = stddev(before) ** 2 / before.length + stddev(after) ** 2 / after.length;
return Math.sqrt(variance);
}
export function effectSize(before: number[], after: number[]): number {
if (before.length < 2 || after.length < 2) return 0;
const se = standardError(before, after);
const diff = Math.abs(mean(after) - mean(before));
if (se === 0) return diff === 0 ? 0 : Number.POSITIVE_INFINITY;
return diff / se;
}
export function isSignificant(before: number[], after: number[], policy: Policy = DEFAULT_POLICY): boolean {
if (before.length < 2 || after.length < 2) return false;
const pct = Math.abs(percentChange(before, after));
if (pct < policy.minEffectPct) return false;
return effectSize(before, after) >= policy.zScore;
}
export function compareSample(sample: Sample, policy: Policy = DEFAULT_POLICY): Comparison {
const beforeMean = mean(sample.before);
const afterMean = mean(sample.after);
const changePct = percentChange(sample.before, sample.after);
const significant = isSignificant(sample.before, sample.after, policy);
let verdict: Verdict;
if (changePct <= 0) {
verdict = significant ? "improved" : "pass";
} else if (changePct <= policy.thresholdPct) {
verdict = "pass";
} else if (significant) {
verdict = "fail";
} else {
verdict = "warn";
}
return {
id: sample.id,
endpoint: sample.endpoint,
metric: sample.metric,
beforeMean,
afterMean,
changePct,
effect: effectSize(sample.before, sample.after),
significant,
verdict,
};
}
export function gate(comparisons: Comparison[]): GateResult {
const blockers = comparisons.filter((item) => item.verdict === "fail").map((item) => item.id);
const warnings = comparisons.filter((item) => item.verdict === "warn").map((item) => item.id);
const improved = comparisons.filter((item) => item.verdict === "improved").map((item) => item.id);
let decision: GateResult["decision"] = "pass";
if (blockers.length > 0) decision = "fail";
else if (warnings.length > 0) decision = "warn";
return { decision, blockers, warnings, improved };
}
export function runGate(samples: Sample[], policy: Policy = DEFAULT_POLICY): GateResult {
return gate(samples.map((sample) => compareSample(sample, policy)));
}
export function noiseFloor(before: number[], after: number[], policy: Policy = DEFAULT_POLICY): number {
const base = mean(before);
if (base === 0) return 0;
return (policy.zScore * standardError(before, after) * 100) / base;
}
export function registerOptimization(ledger: Optimization[], item: Optimization): Optimization[] {
if (!(item.expectedGainPct > 0)) {
throw new Error(`优化项 ${item.id} 的预期收益必须为正数`);
}
if (ledger.some((entry) => entry.id === item.id)) {
throw new Error(`优化项 ${item.id} 已登记`);
}
return [...ledger, { ...item }];
}
export function findOptimization(ledger: Optimization[], id: string): Optimization | undefined {
return ledger.find((entry) => entry.id === id);
}
export type GainCheck = {
id: string;
expectedGainPct: number;
actualChangePct: number;
met: boolean;
};
export function expectedVsActual(optimization: Optimization, comparison: Comparison): GainCheck {
return {
id: optimization.id,
expectedGainPct: optimization.expectedGainPct,
actualChangePct: comparison.changePct,
met: comparison.changePct <= -optimization.expectedGainPct,
};
}
三个参数决定了门禁的松紧:
thresholdPct是业务容忍度,默认 3%。超过它才可能判回归。minEffectPct是最小效应,默认 1%。低于它的变化即使统计显著也不值得追。zScore是显著性门槛,默认 1.96,对应双侧 95% 的粗略近似。
参数不是越小越好。阈值调到 0.5%,每次发版都会被噪声拦住;调到 10%,真正的回归会漏过去。合理做法是先跑一段时间,观察 noiseFloor 的分布,再把阈值定在噪声下限之上。
判据与反例
判据一:基线锁在上一个被接受的版本。 优化后的版本通过门禁,就成为新基线。不能因为「相比很久以前还是快的」而放过一次回归。
判据二:阈值与显著性同时满足才阻断。 fail 需要 changePct > thresholdPct 且 significant === true。只有涨幅没有显著性,最多是 warn。
判据三:样本不足时不下结论。 少于两个样本无法估计方差,isSignificant 直接返回 false。这不是保守,而是避免用单次采样制造假回归。
判据四:优化项必须带预期收益。 没有预期收益,就无法判断「优化有没有用」。expectedVsActual 把预期与实际变化放在一起,收益不达标时应该继续排查,而不是直接关单。
四个反例:
- 用优化前的基线比较。 每次优化都给后续回归发一张免死金牌。
- 只看均值,不看显著性。 一次构建的抖动就能触发回滚,团队很快不再相信门禁。
- 每次发布重置基线。 回归会被新基线吸收,问题永远暴露不出来。
- 优化项不登记。 半年后没人知道这 300 毫秒是哪个改动带来的,也无法判断能否回退。
一次「是否回滚」的判定
回到开头的数字。假设上一个被接受的版本首屏均值是 1500 毫秒,新版本五次采样是 1795、1788、1802、1799、1794 毫秒。
- 均值约 1795.6 毫秒,变化率约 +19.7%,远超 3% 阈值。
- 两组样本的波动都很小,效应量远大于 1.96,判定显著。
- 结论是
fail,门禁阻断,应该修复或回滚,而不是拿 1800 毫秒的旧基线安慰自己。
如果新版本采样是 1495、1510、1488、1505、1492 毫秒,均值约 1498 毫秒,变化率约 -0.13%,落在阈值内,结论是 pass。如果采样是 1520、1580、1470、1600、1450 毫秒,均值约 1524 毫秒,涨幅 1.6%,虽然没超 3%,但波动很大;这时应该增加样本,而不是急着下结论。
测试与输出
✔ 大效应低噪声被判定显著
✔ 高噪声下同样涨幅不显著
✔ 超过阈值且显著判为 fail
✔ 超过阈值但噪声大判为 warn
✔ 门禁中 fail 优先于 improved
✔ 重复优化项被拒绝
ℹ tests 22
ℹ pass 22
ℹ fail 0
最容易被忽略的是 warn:它不阻断发布,但必须有人跟进。没有跟进机制的 warn,等于把回归推迟到下一次。
小结
优化闭环的目标不是证明「我们快过」,而是保证「不会悄悄慢回去」。
- 优化项要登记预期收益,否则无法验证是否生效。
- 基线锁定在上一个被接受的版本,不能被旧基线豁免。
- 阈值管业务容忍度,显著性管噪声,两者同时满足才阻断。
- 门禁给出 improved、pass、warn、fail 四类结论,warn 也要有人跟进。
性能治理的终点不是一次漂亮的优化,而是一条能持续挡住回归的流水线。
阅读导航:上一节:4.2 归因:从慢到哪一行 · 下一节:5.1 故障域与爆炸半径 。
常见错误与边界
- 拿优化前的基线比较。每次优化都会给后续回归发一张免死金牌。
- 每次发布重置基线。回归被新基线吸收,问题永远暴露不出来。
- 只看阈值不看显著性。构建抖动会被当成真实回归,门禁逐渐失去信任。
- 只看显著性不看阈值。大样本下 0.1% 的差异也会显著,却不值得处理。
- 样本不足就下结论。单次采样无法估计方差,容易制造假回归。
- 优化项不登记预期收益。无法判断优化是否生效,也无法评估回滚代价。
- 把 warn 当成 pass。告警不阻断,但必须有人跟进并限期复测。
- 用同一套阈值覆盖所有端。低端机与桌面端的噪声水平不同,阈值应分端校准。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。