2019 年底,一块监控大屏从最初 8 个组件增长到 31 个。每个组件挂载后自己请求数据,切换时间范围时同时刷新。一次打开页面会发出 47 个请求,浏览器连接排队,后端聚合接口 CPU 抬升,最关键的告警数字反而最后出现。
团队最初的优化是给每个图加 loading,再把某些请求延迟几百毫秒。这只让拥堵错开一点,没有回答哪些数据应该先到、同一份查询为什么执行多次、页面最多允许消耗多少资源。
图 1:性能预算不是一个总分,而是决定关键资源先得到服务的分配规则。
先把组件请求改成查询描述
原来每个组件直接调用接口,调度层看不到它们是否相同。我们把查询变成稳定结构:
type QuerySpec = {
metric: string;
dimensions: string[];
filters: Record<string, string | number>;
range: { from: number; to: number };
interval: "1m" | "5m" | "1h";
};
function queryKey(spec: QuerySpec) {
return stableStringify({
...spec,
dimensions: [...spec.dimensions].sort(),
filters: sortObject(spec.filters),
});
}两个图表查询相同 metric、范围和筛选时,只发一次请求,各自从结果选择展示字段。稳定 key 还用于短期缓存和取消过期请求。
预算按用户价值分层
不是每个图都属于首屏。我们和产品一起把组件分成三层:
| 优先级 | 内容 | 目标 | 策略 |
|---|---|---|---|
| P0 | 当前告警数、核心健康状态 | 2 秒内可读 | 首批请求,失败明确展示 |
| P1 | 主要趋势与分组 | 4 秒内完成 | P0 后调度,可复用聚合结果 |
| P2 | 长尾明细与辅助图 | 进入视口后加载 | 可取消、可降低精度 |
总请求并发限制为浏览器和服务端都能承受的数值,而不是组件数量。P0 排队时可以抢占尚未开始的 P2,已经过期的时间范围请求直接取消结果消费。
数据量预算要落到每个查询
十万点折线图即使接口很快,解析和绘制也会卡主线程。查询提交前估算点数:
function estimatedPoints(spec: QuerySpec) {
const intervalMs = { "1m": 60_000, "5m": 300_000, "1h": 3_600_000 }[spec.interval];
const buckets = Math.ceil((spec.range.to - spec.range.from) / intervalMs);
return buckets * Math.max(1, expectedSeries(spec));
}超过预算时,页面不是默默截断,而是提高聚合粒度、限制系列数量,或要求用户缩小范围。图表旁会标明“已按 5 分钟聚合”,避免用户把降采样结果当原始明细。
刷新频率不能由组件各自决定
过去每个组件都有 setInterval,切到后台标签页仍然刷新,恢复前台时又一起发请求。我们改成页面级时钟:只在可见时运行;同一刷新周期合并查询;上一次还没完成就不叠加;连续失败采用退避。
const refreshPolicy = {
visibleIntervalMs: 30_000,
hidden: "pause",
maxConcurrent: 6,
failureBackoff: [30_000, 60_000, 120_000],
};每个超预算项都要能找到负责人
我们在开发环境显示查询面板,记录 queryKey、调用组件、排队时间、服务端时间、响应字节数、点数和是否命中复用。新增组件如果让 P0 完成时间超过预算,评审不能只说“我的图只多一个请求”。
上线后的指标也按页面版本观察:P0 可读时间、取消请求数、查询复用率、服务端 P95 和主线程长任务。优化后最明显的不是总请求数变成一个漂亮数字,而是核心状态稳定地先出现,时间范围切换不再把页面拖死。
这块大屏让我意识到,组件化容易分散责任。每个组件局部都“合理”,合在一起却可能超出系统容量。预算的价值,是让每一份查询和每一次渲染都说明自己为什么值得占用这部分资源。