平均四成利用率,为什么还会超时
「回声」的推理网关在周报上看起来非常健康:过去七天平均 CPU 利用率 41%,平均队列长度不到 2。但每周一上午十点,客服都会收到一批“AI 回复很慢”的反馈,持续三到五分钟。
值班同学查监控,发现那几分钟里排队请求从个位数涨到四百多,随后慢慢回落。自动伸缩确实触发了,但它用了 90 秒才把新实例拉起来;在这 90 秒里,新实例的容量是零,所有超出热实例能力的请求都变成了积压。
这个问题用平均值永远解释不了。平均值把周一上午的尖峰和凌晨的空闲混在一起,得出“容量充足”的结论。要解释它,需要三个不同的数字:观测到的最高速率、可持续的峰值速率、以及为估算误差留出的计划峰值。
文中业务量与延迟是案例假设值;除标注为本机测试输出外,计算结果是公式代入值或示例样本上的运行结果,不是生产实测。
峰值不能只看最大值
监控里最容易拿到的是“最高一分钟有多少请求”。这个数字有用,但不能直接当计划峰值,因为单个尖峰可能来自重试风暴、爬虫或一次误配置,把它当成常态会买多;反过来,只看日均又会买少。
可行的口径分三层:
| 口径 | 取法 | 回答的问题 |
|---|---|---|
| 观测峰值 | 所有窗口速率的最大值 | 系统最少要扛住多少 |
| 持续峰值 | 窗口速率的 P95 | 高频峰值大概是什么水平 |
| 计划峰值 | 持续峰值 ×(1 + 估算余量) | 容量计划应该按多少买 |
持续峰值用最近秩法,不插值。24 个一分钟窗口、P95 对应第 ceil(0.95 × 24) = 23 个值,也就是排序后的第 23 个真实窗口。插值会造出一个不存在的速率,虽然数字更平滑,但无法回到原始窗口复核。
在示例的 24 个窗口里,观测峰值是每分钟 760 次,即 12.667 RPS;持续峰值是每分钟 700 次,即 11.667 RPS。两者只差 8.6%,说明这个样本没有极端尖峰。如果两者差出数倍,就应该先查尖峰来源,而不是直接把最大值写进预算。
余量比例留多少
余量不是拍一个“30% 比较稳妥”。它至少覆盖三类误差:
- 估算误差:长度分布、重试率、峰值系数都可能偏低。示例取 15%。
- 实例粒度:容量按整实例增加,向上取整天然会产生一截空闲。
- 故障域:一个实例或一个可用区退出时,剩余容量仍要接住峰值。
前两类可以写进公式,第三类通常通过“N+1”或跨区冗余单独加。把三者混成一个百分比,最后没人说得清 30% 里哪一部分是为了故障。
示例把计划峰值定为 11.667 × 1.15 = 13.417 RPS,再留 30% 容量余量,得到 17.442 RPS 的需求。单实例可持续 2.5 RPS 时,峰值需要 6 个实例,计划需要 7 个,多出的 1 个就是余量。此时峰值利用率是 76.67%,有效余量约 30.43%。
这个结果说明余量最终会落在整实例上:想要 30%,实际可能得到 30.43%;想要 10%,在同样粒度下可能得到 20%。容量表应该同时写“目标余量”和“实际余量”,否则财务会以为最后一个实例是浪费。
冷启动把峰值变成积压
弹性扩容的核心矛盾是:扩容决策基于当前负载,新容量却要等冷启动结束才可用。在这段窗口里,系统只能用热实例扛住全部到达量。
用确定性模型描述:热实例 6 个,每个 2.5 RPS,热容量 15 RPS;到达 20 RPS;冷启动 90 秒。缺口是 5 RPS,90 秒累积 450 个请求的积压。新增 3 个实例后总容量 22.5 RPS,相对到达量的富余是 2.5 RPS,排空 450 个积压需要 180 秒。
也就是说,90 秒的冷启动会换来约 3 分钟的慢请求。如果每个请求的端到端预算是 2 秒,这 450 个请求中的大部分已经注定超时。扩容本身没有错,错在把“实例就绪”当成“问题结束”。
更危险的情况是扩容步长不够:同样 90 秒冷启动,只新增 1 个实例,总容量 17.5 RPS,仍低于 20 RPS 的到达量。积压不会排空,只会继续增长,直到流量自己回落。容量计划里的 addInstances 必须保证 总容量 > 到达量,否则扩容只是把崩溃推迟。
余量的成本
固定余量有明确的账单。示例里多出的 1 个实例按每小时 120,000 微元计费,一天 24 小时就是 2,880,000 微元;按 30 天算是 86,400,000 微元。这是为了在峰值时不排队而支付的确定性成本。
弹性容量则把成本换成风险:平时不付空闲实例的钱,但冷启动期间要用积压和超时来付。两种成本不能只比较金额,因为超时影响的是用户体验与客服工单,不是账单上的一个数字。
可行的组合不是二选一,而是分层:
- 保底容量覆盖“可预测的日常峰值”,按固定实例买。
- 弹性容量覆盖“超出保底的部分”,允许冷启动,但配合准入控制。
- 预热池覆盖“可预测的尖峰时刻”,例如每天上午十点前提前扩容。
实现
下面的模块实现四件事:从窗口样本估计峰值、按整实例规划容量、模拟冷启动期间的积压、计算固定余量的日成本。它不调用云厂商 API,也不依赖真实监控,输入是普通数组与数字。
// file: src/perf/capacity-peak.ts
// tier: L1
// verify: node --test test/perf/capacity.test.ts
import { AiError } from "../core/errors.ts";
function fail(message: string): never {
throw new AiError("invalid-request", `capacity-peak: ${message}`);
}
function finite(value: number, field: string, min = 0): number {
if (!Number.isFinite(value) || value < min) fail(`${field} must be a finite number >= ${min}`);
return value;
}
function integer(value: number, field: string, min = 0): number {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
return value;
}
export interface LoadWindow {
windowSeconds: number;
requests: number;
}
export interface PeakOptions {
/** 最近秩法的分位点,默认 0.95。 */
percentile?: number;
/** 在持续峰值之上再留的估算误差。 */
marginRatio?: number;
}
export interface PeakEstimate {
windows: number;
observedPeakRps: number;
sustainedPeakRps: number;
plannedPeakRps: number;
marginRatio: number;
}
/** 最近秩法取真实窗口,不用插值造出不存在的峰值。 */
function nearestRank(values: readonly number[], ratio: number): number {
const sorted = [...values].sort((left, right) => left - right);
const rank = Math.max(0, Math.min(sorted.length - 1, Math.ceil(ratio * sorted.length) - 1));
return sorted[rank];
}
/** 观测峰值容易被单个尖峰带偏,持续峰值取窗口速率的 P95,再乘估算余量。 */
export function estimatePeakRate(windows: readonly LoadWindow[], options: PeakOptions = {}): PeakEstimate {
if (windows.length === 0) fail("load windows must not be empty");
const percentile = options.percentile ?? 0.95;
const margin = options.marginRatio ?? 0.1;
if (!(percentile > 0 && percentile <= 1)) fail("percentile must be in (0, 1]");
if (margin < 0) fail("marginRatio must be >= 0");
const rates = windows.map((window, index) => {
if (!Number.isFinite(window.windowSeconds) || window.windowSeconds <= 0) {
fail(`windows[${index}].windowSeconds must be > 0`);
}
finite(window.requests, `windows[${index}].requests`);
return window.requests / window.windowSeconds;
});
const observed = Math.max(...rates);
const sustained = nearestRank(rates, percentile);
return {
windows: windows.length,
observedPeakRps: observed,
sustainedPeakRps: sustained,
plannedPeakRps: sustained * (1 + margin),
marginRatio: margin,
};
}
export interface CapacityInput {
peakRps: number;
headroomRatio: number;
/** 单实例在目标利用率下的可持续 RPS,不是理论峰值。 */
instanceRps: number;
minInstances?: number;
}
export interface CapacityPlan {
requiredRps: number;
peakInstances: number;
requiredInstances: number;
spareInstances: number;
spareRps: number;
utilizationAtPeak: number;
effectiveHeadroomRatio: number;
}
/** 实例是整数,余量最终体现为整实例的空闲容量。 */
export function planCapacity(input: CapacityInput): CapacityPlan {
finite(input.peakRps, "peakRps");
finite(input.headroomRatio, "headroomRatio");
finite(input.instanceRps, "instanceRps");
if (input.instanceRps <= 0) fail("instanceRps must be > 0");
const minInstances = integer(input.minInstances ?? 0, "minInstances");
const requiredRps = input.peakRps * (1 + input.headroomRatio);
const peakInstances = Math.ceil(input.peakRps / input.instanceRps);
const requiredInstances = Math.max(minInstances, Math.ceil(requiredRps / input.instanceRps));
const capacityRps = requiredInstances * input.instanceRps;
return {
requiredRps,
peakInstances,
requiredInstances,
spareInstances: requiredInstances - peakInstances,
spareRps: capacityRps - input.peakRps,
utilizationAtPeak: input.peakRps / capacityRps,
effectiveHeadroomRatio: capacityRps / input.peakRps - 1,
};
}
export interface ScaleUpInput {
warmInstances: number;
addInstances: number;
instanceRps: number;
coldStartMs: number;
arrivalRps: number;
backlogRequests?: number;
}
export interface ScaleUpResult {
warmCapacityRps: number;
totalCapacityRps: number;
deficitRps: number;
backlogAtReady: number;
drainSeconds: number | null;
drained: boolean;
}
function round3(value: number): number {
return Math.round(value * 1000) / 1000;
}
/**
* 冷启动期间新实例不贡献容量,超出的到达量全部变成积压。
* 就绪后若总容量仍低于到达量,积压只会继续增长。
*/
export function simulateScaleUp(input: ScaleUpInput): ScaleUpResult {
integer(input.warmInstances, "warmInstances");
integer(input.addInstances, "addInstances");
finite(input.instanceRps, "instanceRps");
if (input.instanceRps <= 0) fail("instanceRps must be > 0");
finite(input.coldStartMs, "coldStartMs");
finite(input.arrivalRps, "arrivalRps");
const initialBacklog = finite(input.backlogRequests ?? 0, "backlogRequests");
const warmCapacityRps = input.warmInstances * input.instanceRps;
const totalCapacityRps = (input.warmInstances + input.addInstances) * input.instanceRps;
const deficitRps = Math.max(0, input.arrivalRps - warmCapacityRps);
const backlogAtReady = initialBacklog + deficitRps * (input.coldStartMs / 1000);
const surplusRps = totalCapacityRps - input.arrivalRps;
if (backlogAtReady === 0) {
return {
warmCapacityRps, totalCapacityRps, deficitRps: round3(deficitRps),
backlogAtReady: 0, drainSeconds: 0, drained: true,
};
}
if (surplusRps <= 0) {
return {
warmCapacityRps, totalCapacityRps, deficitRps: round3(deficitRps),
backlogAtReady: round3(backlogAtReady), drainSeconds: null, drained: false,
};
}
return {
warmCapacityRps, totalCapacityRps, deficitRps: round3(deficitRps),
backlogAtReady: round3(backlogAtReady), drainSeconds: round3(backlogAtReady / surplusRps), drained: true,
};
}
export interface HeadroomCostInput {
spareInstances: number;
instanceMicrosPerHour: number;
hoursPerDay?: number;
}
/** 固定余量的代价:空闲实例按小时计费,换来确定性的峰值承载能力。 */
export function headroomCostMicros(input: HeadroomCostInput): number {
finite(input.spareInstances, "spareInstances");
integer(input.instanceMicrosPerHour, "instanceMicrosPerHour");
const hours = input.hoursPerDay ?? 24;
finite(hours, "hoursPerDay");
return Math.round(input.spareInstances * input.instanceMicrosPerHour * hours);
}
estimatePeakRate 同时返回观测峰值与持续峰值,调用方不能只取最大值而不看另一个。planCapacity 把目标余量换成整实例,并回报实际余量与峰值利用率。simulateScaleUp 只做线性积压模型,优点是每个数字都能手算复核,缺点是它不模拟排队论里的非线性延迟上升;用于容量判据足够,用于精确预测不够。
示例样本上的运行结果
示例脚本用 24 个一分钟窗口、15% 估算余量、30% 容量余量与 90 秒冷启动跑完整条链。执行:
node examples/perf/peak-plan.ts
本机一次运行的真实输出如下:
observed_peak_rps=12.667
sustained_peak_rps=11.667
planned_peak_rps=13.417
required_rps=17.442
instances=6/7
utilization_at_peak=76.67%
spare_rps=4.083
cold_deficit_rps=5
cold_backlog=450
drain_seconds=180
idle_cost_micros_per_day=2880000
这组数字给出三个判断。第一,观测峰值与持续峰值差距不大,样本里没有需要单独解释的尖峰。第二,7 个实例在峰值时仍有 4.083 RPS 的空闲容量,利用率 76.67%,余量是真实存在的。第三,冷启动造成的 450 个积压需要 180 秒排空,远超交互预算,必须配合预热或准入控制。
弹性策略的判据
把上面的量写成可判定的条件,比“感觉需要扩容”更可靠:
| 条件 | 判据 | 不满足时的动作 |
|---|---|---|
| 冷启动可接受 | coldStartMs < 交互预算 × 可容忍超时比例 | 加预热池或保底容量 |
| 扩容能排空 | (warm + add) × instanceRps > arrivalRps | 增大扩容步长或提前扩容 |
| 余量足够 | requiredInstances > peakInstances | 提高目标余量或降低实例粒度 |
| 余量不过量 | utilizationAtPeak ≥ 目标下限 | 降低余量或合并实例 |
第一条最容易被忽略。很多团队只监控“扩容有没有触发”,不监控“扩容期间有多少请求超时”。触发不等于及时,实例就绪也不等于积压消失。
小结
峰值规划要区分观测峰值、持续峰值与计划峰值。持续峰值取窗口速率的 P95,计划峰值再乘估算余量。余量最终落在整实例上,所以目标余量与实际余量必须同时记录。冷启动期间新实例容量为零,缺口会线性累积成积压;扩容步长必须保证就绪后的总容量高于到达量,否则积压永远排不空。
示例样本的结论是:13.417 RPS 的计划峰值需要 7 个实例,峰值利用率 76.67%;90 秒冷启动会留下 450 个请求的积压,需要 180 秒排空。下一节把容量映射到租户配额与预算,讨论共享池里的保底额度怎样变成并发上限。
阅读导航:上一节:8.1 从业务量到 token 需求 · 下一节:8.3 配额与预算的容量映射 。
常见错误与边界
- 用最高一分钟当计划峰值。单个尖峰可能来自重试风暴或误配置,不能直接当常态。
- 用日均容量判断是否够用。平均值会把尖峰与空闲混在一起,掩盖排队。
- P95 用插值。插出来的速率无法回到真实窗口复核。
- 余量只写一个百分比。估算误差、实例粒度与故障域被混在一起,没人说得清来源。
- 只看目标余量,不看实际余量。整实例取整会让两者相差很多。
- 以为扩容触发就安全。冷启动期间新实例容量为零,积压仍在增长。
- 扩容步长小于缺口。总容量仍低于到达量时,积压不会排空。
- 只比较空闲实例成本与账单。冷启动的代价是超时与工单,不在同一张账单上。
- 把示例积压模型当精确预测。线性模型适合做判据,不适合替代排队论分析。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。