同一个首屏,五个端五种结论
某内容产品的商品详情页做性能验收。团队只定了一条规则:首屏 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 |
|---|---|---|---|---|
| Web | 900 / 1500 | 1200 / 2000 | 150 / 300 | 120 / 200 |
| iOS | 700 / 1200 | 900 / 1500 | 100 / 200 | 180 / 300 |
| Android | 900 / 1600 | 1100 / 1900 | 120 / 240 | 220 / 360 |
| 小程序 | 1100 / 1900 | 1500 / 2500 | 180 / 320 | 150 / 260 |
| 桌面 | 1400 / 2400 | 1000 / 1800 | 100 / 180 | 300 / 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% 可能仍然在预算内,也可能已经逼近硬上限。两者要一起看,不能只看「比上版快」。
四个反例:
- 用全局阈值代替分端预算。 最弱端永远超标,最强端永远没有优化动力。
- 只看平均值,不看分位数。 平均首屏 900 毫秒,P95 可能是 2.4 秒;预算应该绑定 P75 或 P95 的口径。
- 把软预算当硬门禁。 每次发版都被几十条告警拦住,最后所有人学会跳过检查。
- 预算定完不维护。 新增端、换了低端机型、业务加了重模块,预算表还是旧的,判定结果自然失真。
分端基线不是一张排行榜
基线表记录每个端在当前版本的典型值,用来回答「这次改动是不是异常」,而不是给端排名:
| 端 | 启动基线 | 首屏基线 | 交互基线 | 内存基线 |
|---|---|---|---|---|
| Web | 820 | 1000 | 120 | 95 |
| iOS | 640 | 780 | 88 | 160 |
| Android | 830 | 980 | 105 | 200 |
| 小程序 | 1020 | 1340 | 160 | 135 |
| 桌面 | 1260 | 900 | 86 | 270 |
注意小程序的首屏基线是 1340 毫秒,已经接近它 1500 毫秒的软预算。这意味着它的优化空间很小,任何新模块都要先算预算,而不是先写代码。桌面端启动基线 1260 毫秒,离 1400 毫秒的软预算也不远,但它的硬上限是 2400 毫秒,仍有缓冲。
基线要跟设备与场景绑定。拿开发机上的数据当基线,等于用最快的环境掩盖最慢的用户。
测试与输出
✔ 默认预算表覆盖全部端与指标
✔ 软预算高于硬上限被报错
✔ 超过软预算但未过硬上限只告警
✔ 超过硬上限判为失败
✔ 超限明细按超出比例降序排列
✔ 基线漂移按指标计算百分比
ℹ tests 21
ℹ pass 21
ℹ fail 0
这些用例覆盖了「表坏了」「值超了」「值没测」三类情况。没有它们,预算表只是一份容易过期的文档。
小结
性能预算的本质是把「快」拆成可验收的格子,而不是喊一个数字。
- 按端分配预算,因为端的能力与用户预期不同。
- 按启动、首屏、交互、内存四类指标分别设定软预算与硬上限。
- 预算表要能校验,超限要能给出倍数与明细,缺失指标要单独列出。
- 基线用于发现异常,不用于替代预算,更不能拿来给端排名。
先有格子,再谈优化。格子不清楚,优化就只能靠感觉。
阅读导航:上一节:3.3 动态配置与热更新的停止条件 · 下一节:4.2 归因:从慢到哪一行 。
常见错误与边界
- 五个端共用一条阈值。最弱端长期超标后被放弃,最强端失去优化动力。
- 只设硬上限,不设软预算。优化没有提前量,每次都在门禁前突击。
- 把平均值当预算口径。平均值达标,P95 可能已经严重超标。
- 预算表不做校验。软预算高于硬上限时,判定会静默失效。
- 缺失指标按通过处理。没有测内存,不代表内存没有问题。
- 把基线当目标。基线只是当前水平,不能替代产品与架构定义的预算。
- 用开发机数据定预算。最快的环境会掩盖最慢用户的真实体验。
- 预算长期不更新。新增端与低端机型后,旧表会给出错误的通过结论。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。