《互联网产品的客户端架构·规模化与架构演进》4.1 性能预算与分端指标

性能预算不能只有一条全局阈值。本节把启动、首屏、交互、内存四类指标按 Web、iOS、Android、小程序、桌面分别设定软预算与硬上限,用可运行的 TypeScript 模块完成预算表校验、超限明细排序与分端基线对比,并给出预算失效的常见反例。

同一个首屏,五个端五种结论

某内容产品的商品详情页做性能验收。团队只定了一条规则:首屏 2 秒内算达标。结果出现三种互相矛盾的结论:

  • iOS 首屏 1.1 秒,长期「达标」,没人再投入。
  • Android 首屏 1.6 秒,看起来也达标,但版本间在 1.4 到 2.1 秒之间来回抖动,偶尔被判定回归。
  • 小程序首屏 2.8 秒,长期超标,但因为它「本来就慢」,超标被当成常态,没有进入排期。

更麻烦的是,启动、交互和内存根本没人管。一个端首屏很快,点开列表却卡顿 400 毫秒;另一个端首屏一般,但冷启动要 3 秒。单一阈值把这些差异全抹平了。

问题不是「哪个端慢」,而是没有分端、分指标的预算。下面出现的毫秒、百分比与内存数字都是构造数据,用来演示判定口径,不代表任何真实产品。

预算要分端,也要分指标

端的能力不同,预算必须不同。把 iOS 的 100 毫秒交互预算套到小程序上,等于要求它做到宿主根本给不了的帧率;反过来,用小程序的上限要求桌面端,又会让桌面端失去体验优势。

指标也不能混。启动、首屏、交互、内存对应四种不同的用户感受,也对应四套不同的优化手段:

指标用户感受主要优化手段单位
启动点开应用后多久能用延迟初始化、拆包、预热ms
首屏内容多久可见请求合并、骨架屏、服务端渲染ms
交互点击后多久有反馈主线程减负、列表复用、异步化ms
内存用久了会不会被杀缓存上限、图片解码、对象回收MB

一张预算表至少要有「端 × 指标」两个维度。再加一个维度:每条预算分软预算(target,排期与告警用)和硬上限(hard,发布门禁用)。只有软预算,团队会把它当建议;只有硬上限,优化没有提前量。

端启动 target/hard首屏 target/hard交互 target/hard内存 target/hard
Web900 / 15001200 / 2000150 / 300120 / 200
iOS700 / 1200900 / 1500100 / 200180 / 300
Android900 / 16001100 / 1900120 / 240220 / 360
小程序1100 / 19001500 / 2500180 / 320150 / 260
桌面1400 / 24001000 / 1800100 / 180300 / 500

表格本身不防错。软预算高于硬上限、少写一个指标、把某个值填成 0,都会让后面的判定悄悄失效。所以预算表必须能校验,超限必须能给出明细,而不是只返回一个布尔值。

把预算表写成可校验的代码

下面的模块把端、指标、软硬预算、基线、校验与超限明细都变成纯函数:

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

export type Endpoint = "web" | "ios" | "android" | "miniprogram" | "desktop";

export type Metric = "startup" | "first-screen" | "interaction" | "memory";

export type Unit = "ms" | "MB";

export type Budget = {
  target: number;
  hard: number;
};

export type BudgetTable = Record<Endpoint, Record<Metric, Budget>>;

export type Measurement = Partial<Record<Metric, number>>;

export const ENDPOINTS: Endpoint[] = ["web", "ios", "android", "miniprogram", "desktop"];

export const METRICS: Metric[] = ["startup", "first-screen", "interaction", "memory"];

export const UNITS: Record<Metric, Unit> = {
  startup: "ms",
  "first-screen": "ms",
  interaction: "ms",
  memory: "MB",
};

export const BUDGETS: BudgetTable = {
  web: {
    startup: { target: 900, hard: 1500 },
    "first-screen": { target: 1200, hard: 2000 },
    interaction: { target: 150, hard: 300 },
    memory: { target: 120, hard: 200 },
  },
  ios: {
    startup: { target: 700, hard: 1200 },
    "first-screen": { target: 900, hard: 1500 },
    interaction: { target: 100, hard: 200 },
    memory: { target: 180, hard: 300 },
  },
  android: {
    startup: { target: 900, hard: 1600 },
    "first-screen": { target: 1100, hard: 1900 },
    interaction: { target: 120, hard: 240 },
    memory: { target: 220, hard: 360 },
  },
  miniprogram: {
    startup: { target: 1100, hard: 1900 },
    "first-screen": { target: 1500, hard: 2500 },
    interaction: { target: 180, hard: 320 },
    memory: { target: 150, hard: 260 },
  },
  desktop: {
    startup: { target: 1400, hard: 2400 },
    "first-screen": { target: 1000, hard: 1800 },
    interaction: { target: 100, hard: 180 },
    memory: { target: 300, hard: 500 },
  },
};

export const BASELINES: Record<Endpoint, Record<Metric, number>> = {
  web: { startup: 820, "first-screen": 1000, interaction: 120, memory: 95 },
  ios: { startup: 640, "first-screen": 780, interaction: 88, memory: 160 },
  android: { startup: 830, "first-screen": 980, interaction: 105, memory: 200 },
  miniprogram: { startup: 1020, "first-screen": 1340, interaction: 160, memory: 135 },
  desktop: { startup: 1260, "first-screen": 900, interaction: 86, memory: 270 },
};

export type BudgetIssue = {
  path: string;
  message: string;
};

export function budgetFor(endpoint: Endpoint, metric: Metric, table: BudgetTable = BUDGETS): Budget | undefined {
  return table[endpoint]?.[metric];
}

export function validateBudgetTable(table: BudgetTable): BudgetIssue[] {
  const issues: BudgetIssue[] = [];
  for (const endpoint of ENDPOINTS) {
    const row = table[endpoint];
    if (!row) {
      issues.push({ path: endpoint, message: "缺少该端的预算行" });
      continue;
    }
    for (const metric of METRICS) {
      const budget = row[metric];
      const path = `${endpoint}/${metric}`;
      if (!budget) {
        issues.push({ path, message: "缺少该指标的预算" });
        continue;
      }
      if (!Number.isFinite(budget.target) || budget.target <= 0) {
        issues.push({ path, message: "软预算必须是正数" });
      }
      if (!Number.isFinite(budget.hard) || budget.hard <= 0) {
        issues.push({ path, message: "硬上限必须是正数" });
      }
      if (Number.isFinite(budget.target) && Number.isFinite(budget.hard) && budget.target > budget.hard) {
        issues.push({ path, message: "软预算不能高于硬上限" });
      }
    }
  }
  return issues;
}

export type Overrun = {
  endpoint: Endpoint;
  metric: Metric;
  unit: Unit;
  value: number;
  target: number;
  hard: number;
  excess: number;
  ratio: number;
  severity: "warn" | "fail";
};

export function overruns(
  endpoint: Endpoint,
  measurement: Measurement,
  table: BudgetTable = BUDGETS,
): Overrun[] {
  const out: Overrun[] = [];
  for (const metric of METRICS) {
    const value = measurement[metric];
    if (value === undefined) continue;
    const budget = budgetFor(endpoint, metric, table);
    if (!budget) continue;
    if (value <= budget.target) continue;
    out.push({
      endpoint,
      metric,
      unit: UNITS[metric],
      value,
      target: budget.target,
      hard: budget.hard,
      excess: value - budget.target,
      ratio: value / budget.target,
      severity: value > budget.hard ? "fail" : "warn",
    });
  }
  return out;
}

export function rankOverruns(items: Overrun[]): Overrun[] {
  return [...items].sort((a, b) => {
    if (b.ratio !== a.ratio) return b.ratio - a.ratio;
    if (a.severity !== b.severity) return a.severity === "fail" ? -1 : 1;
    return a.metric.localeCompare(b.metric);
  });
}

export function evaluateBudget(
  endpoint: Endpoint,
  measurement: Measurement,
  table: BudgetTable = BUDGETS,
): { endpoint: Endpoint; pass: boolean; overruns: Overrun[]; missing: Metric[] } {
  const items = rankOverruns(overruns(endpoint, measurement, table));
  return {
    endpoint,
    pass: items.every((item) => item.severity !== "fail"),
    overruns: items,
    missing: METRICS.filter((metric) => measurement[metric] === undefined),
  };
}

export function baselineFor(endpoint: Endpoint): Record<Metric, number> | undefined {
  return BASELINES[endpoint];
}

export type BaselineDrift = {
  metric: Metric;
  baseline: number;
  value: number;
  changePct: number;
};

export function driftFromBaseline(endpoint: Endpoint, measurement: Measurement): BaselineDrift[] {
  const baseline = baselineFor(endpoint);
  if (!baseline) return [];
  const out: BaselineDrift[] = [];
  for (const metric of METRICS) {
    const value = measurement[metric];
    if (value === undefined) continue;
    const base = baseline[metric];
    out.push({
      metric,
      baseline: base,
      value,
      changePct: ((value - base) / base) * 100,
    });
  }
  return out;
}

export function worstMetric(endpoint: Endpoint, measurement: Measurement): Metric | undefined {
  let worst: Metric | undefined;
  let worstRatio = 0;
  for (const metric of METRICS) {
    const value = measurement[metric];
    const budget = budgetFor(endpoint, metric);
    if (value === undefined || !budget) continue;
    const ratio = value / budget.target;
    if (ratio > worstRatio) {
      worstRatio = ratio;
      worst = metric;
    }
  }
  return worst;
}

export function softPassRate(endpoint: Endpoint, measurement: Measurement): number {
  const measured = METRICS.filter((metric) => measurement[metric] !== undefined);
  if (measured.length === 0) return 1;
  const passed = measured.filter((metric) => {
    const value = measurement[metric] as number;
    const budget = budgetFor(endpoint, metric);
    return budget !== undefined && value <= budget.target;
  });
  return passed.length / measured.length;
}

export function budgetCoverage(table: BudgetTable = BUDGETS): number {
  let count = 0;
  for (const endpoint of ENDPOINTS) {
    for (const metric of METRICS) {
      if (table[endpoint]?.[metric]) count += 1;
    }
  }
  return count;
}

export function formatOverrun(item: Overrun): string {
  const level = item.severity === "fail" ? "超硬上限" : "超软预算";
  return `${item.endpoint}/${item.metric} ${item.value}${item.unit} ${level} ${item.hard}${item.unit}(${item.ratio.toFixed(2)}×)`;
}

三个函数承担了三种职责,不能合并:

  • validateBudgetTable 在加载配置时运行,防止一张坏表进入判定。
  • evaluateBudget 在每次测量后运行,返回是否通过、超限明细和缺失指标。
  • driftFromBaseline 只做趋势参考,不参与门禁,避免基线把预算架空。

判据与反例

判据一:软预算只告警,硬上限才阻断。 evaluateBudget 的 pass 只对 severity === "fail" 敏感。软预算超了要有人看,但不应该让整条流水线停摆,否则团队会习惯性忽略告警。

判据二:超限按倍数排序。 同样超 100 毫秒,在 150 毫秒的交互预算上是 1.67 倍,在 1200 毫秒的首屏预算上只有 1.08 倍。前者更该先修。rankOverruns 按 value / target 降序,避免「谁声音大谁先改」。

判据三:缺失指标不能当成通过。 没有测内存,不等于内存没问题。missing 把未测项单独列出,发布检查应该要求关键指标齐全。

判据四:基线只描述过去,不定义未来。 基线漂移 10% 可能仍然在预算内,也可能已经逼近硬上限。两者要一起看,不能只看「比上版快」。

四个反例:

  1. 用全局阈值代替分端预算。 最弱端永远超标,最强端永远没有优化动力。
  2. 只看平均值,不看分位数。 平均首屏 900 毫秒,P95 可能是 2.4 秒;预算应该绑定 P75 或 P95 的口径。
  3. 把软预算当硬门禁。 每次发版都被几十条告警拦住,最后所有人学会跳过检查。
  4. 预算定完不维护。 新增端、换了低端机型、业务加了重模块,预算表还是旧的,判定结果自然失真。

分端基线不是一张排行榜

基线表记录每个端在当前版本的典型值,用来回答「这次改动是不是异常」,而不是给端排名:

端启动基线首屏基线交互基线内存基线
Web820100012095
iOS64078088160
Android830980105200
小程序10201340160135
桌面126090086270

注意小程序的首屏基线是 1340 毫秒,已经接近它 1500 毫秒的软预算。这意味着它的优化空间很小,任何新模块都要先算预算,而不是先写代码。桌面端启动基线 1260 毫秒,离 1400 毫秒的软预算也不远,但它的硬上限是 2400 毫秒,仍有缓冲。

基线要跟设备与场景绑定。拿开发机上的数据当基线,等于用最快的环境掩盖最慢的用户。

测试与输出

✔ 默认预算表覆盖全部端与指标
✔ 软预算高于硬上限被报错
✔ 超过软预算但未过硬上限只告警
✔ 超过硬上限判为失败
✔ 超限明细按超出比例降序排列
✔ 基线漂移按指标计算百分比
ℹ tests 21
ℹ pass 21
ℹ fail 0

这些用例覆盖了「表坏了」「值超了」「值没测」三类情况。没有它们,预算表只是一份容易过期的文档。

小结

性能预算的本质是把「快」拆成可验收的格子,而不是喊一个数字。

  • 按端分配预算,因为端的能力与用户预期不同。
  • 按启动、首屏、交互、内存四类指标分别设定软预算与硬上限。
  • 预算表要能校验,超限要能给出倍数与明细,缺失指标要单独列出。
  • 基线用于发现异常,不用于替代预算,更不能拿来给端排名。

先有格子,再谈优化。格子不清楚,优化就只能靠感觉。

阅读导航:上一节:3.3 动态配置与热更新的停止条件 · 下一节:4.2 归因:从慢到哪一行 。

常见错误与边界

  • 五个端共用一条阈值。最弱端长期超标后被放弃,最强端失去优化动力。
  • 只设硬上限,不设软预算。优化没有提前量,每次都在门禁前突击。
  • 把平均值当预算口径。平均值达标,P95 可能已经严重超标。
  • 预算表不做校验。软预算高于硬上限时,判定会静默失效。
  • 缺失指标按通过处理。没有测内存,不代表内存没有问题。
  • 把基线当目标。基线只是当前水平,不能替代产品与架构定义的预算。
  • 用开发机数据定预算。最快的环境会掩盖最慢用户的真实体验。
  • 预算长期不更新。新增端与低端机型后,旧表会给出错误的通过结论。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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