《互联网产品的客户端架构·规模化与架构演进》4.3 优化闭环与回归防护

性能优化没有闭环,就等于把成果交给下一次发版碰运气。本节把优化项登记、基线对比、阈值与显著性、回归门禁串成一条可执行的链路,用可运行的 TypeScript 模块判定改进、通过、告警与阻断,说明基线该锁在哪个版本,以及告警如何跟进,让每次发版都有明确结论。

首屏从 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 把预期与实际变化放在一起,收益不达标时应该继续排查,而不是直接关单。

四个反例:

  1. 用优化前的基线比较。 每次优化都给后续回归发一张免死金牌。
  2. 只看均值,不看显著性。 一次构建的抖动就能触发回滚,团队很快不再相信门禁。
  3. 每次发布重置基线。 回归会被新基线吸收,问题永远暴露不出来。
  4. 优化项不登记。 半年后没人知道这 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。告警不阻断,但必须有人跟进并限期复测。
  • 用同一套阈值覆盖所有端。低端机与桌面端的噪声水平不同,阈值应分端校准。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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