《互联网产品的 AI 架构·推理性能与成本工程》8.2 峰值、余量与弹性

平均利用率只有四成,周一上午却仍然超时。本节把峰值拆成观测峰值、持续峰值与计划峰值,说明余量比例该覆盖哪些误差,并用确定性模型计算冷启动期间的容量缺口、积压与排空时间;示例样本显示 90 秒冷启动会留下 450 个请求的积压,需要 180 秒才能排空。

平均四成利用率,为什么还会超时

「回声」的推理网关在周报上看起来非常健康:过去七天平均 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 用插值。插出来的速率无法回到真实窗口复核。
  • 余量只写一个百分比。估算误差、实例粒度与故障域被混在一起,没人说得清来源。
  • 只看目标余量,不看实际余量。整实例取整会让两者相差很多。
  • 以为扩容触发就安全。冷启动期间新实例容量为零,积压仍在增长。
  • 扩容步长小于缺口。总容量仍低于到达量时,积压不会排空。
  • 只比较空闲实例成本与账单。冷启动的代价是超时与工单,不在同一张账单上。
  • 把示例积压模型当精确预测。线性模型适合做判据,不适合替代排队论分析。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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