《互联网产品的 AI 架构·平台化与治理》10.3 平台自身的 SLO 与容量

平台说自己 99.99% 可用,业务方却每天遇到失败。本节把平台 SLO 拆成网关、配额与评测三个部件,用整数基点定义目标、按事件数计算错误预算与燃烧率,给出正常、评审、冻结三档动作,并在示例样本上跑出健康、风险、耗尽三种状态与六个月容量规划。

平台 99.99%,业务方每天在失败

平台月报写着「网关可用性 99.99%,达成 SLO」。同一周,客服团队的业务链路却有 0.42% 的请求失败。两边都没说谎,但两个数字放在一起,业务方会问:平台到底能不能用?

答案是:平台的 99.99% 只统计网关自己返回的响应,不统计网关依赖的配额服务、模型供应商与业务侧的超时。一个请求可能网关成功了,但配额服务超时,调用方仍然失败。平台 SLO 如果只覆盖自己最健康的那一段,它就不是承诺,只是宣传。

这一节把平台 SLO 拆成三个共享部件,定义每个部件的目标、错误预算、燃烧率与动作,再说明平台故障时业务侧应该看到什么。它与卷一 2 章的网关实现不同:那里讲一个请求怎么路由,这里讲很多请求、很多团队共用平台时,平台该承诺什么、什么时候停止变更。

三个部件,三种承诺

平台只对三个共享部件承诺 SLO。业务链路的端到端 SLO 由调用方自己承诺,平台提供的是「零件级」的可用性。

部件目标月度允许不可用统计口径
网关99.95%21.6 分钟网关收到并返回的请求;不含调用方超时
配额服务99.99%4.32 分钟配额判定请求;预授权命中不算失败
评测平台99.90%43.2 分钟评测任务完成;排队时间不计入

三个目标的严格程度不同,原因不是「网关更重要」,而是「失败后果不同」。网关不可用,请求直接失败,业务立刻可见;配额服务不可用,平台可以退到预授权额度,短时间不影响正确性,所以要求更高;评测平台不可用只影响发布,不影响线上流量,目标可以略低。

按月 43,200 分钟折算,99.95% 对应 21.6 分钟,99.99% 对应 4.32 分钟,99.90% 对应 43.2 分钟。这些数字是换算结果,不是生产实测;它们的作用是让「几个九」变成可以讨论的时间预算。

错误预算:用事件数,不用百分比

百分比的缺点是边界模糊。99.95% 到底是允许 500 次失败还是 501 次?用事件数就没有这个问题:预算 = 总事件 × (1 - 目标),向下取整。

目标用基点表示,9995 就是 99.95%。这样所有比较都是整数,不会出现 0.9995 * 1000000 = 499.9999999 这种浮点边界。示例样本里,100 万次请求、目标 9995,预算是 500 次;如果目标写成 0.9995,同样的计算可能得到 499 或 500,取决于运行时。

错误预算有三个用途。第一,判断当前是否达标:已用失败数超过预算就是耗尽。第二,预测窗口结束时的状态:按已过时间等比例外推。第三,决定变更节奏:预算充足时正常发布,燃烧率过高时评审,预算耗尽时冻结。

燃烧率与三档动作

只看「已用多少预算」不够,因为月初用掉一半和月末用掉一半,含义完全不同。燃烧率把时间维度加进来:

燃烧率 = (已用失败 / 错误预算) ÷ (已过时间 / 窗口长度)

燃烧率为 1 表示按当前速度刚好在窗口结束时用完预算;为 2 表示会在窗口一半时用完;为 0.96 表示略低于预算速度。

平台按燃烧率分三档动作:

状态条件动作谁执行
healthy燃烧率 < 2 且预算未耗尽normal平台团队
at-risk燃烧率 ≥ 2 且预算未耗尽review平台团队 + 值班
exhausted失败数 ≥ 预算freeze平台团队

freeze 不是「停服」,而是「冻结平台自身的变更」:停止配置放量、停止非紧急发布、把容量与回滚列为最高优先级。业务方的发布是否冻结,取决于评测平台的状态,不取决于网关预算。把两类冻结混在一起,会让一次网关抖动阻塞所有团队的正常迭代。

实现

下面的模块做三件事:按事件数计算错误预算与燃烧率、按增长与余量规划容量、给出平台故障时的业务降级规则。它不采集指标,也不发告警。

// file: src/gov/platform-slo.ts
// tier: L2
// verify: node --test test/gov/platform-eng.test.ts

import { AiError } from "../core/errors.ts";

/** 平台只对三个共享部件承诺 SLO;业务链路的 SLO 由调用方自己承诺。 */
export type PlatformComponent = "gateway" | "quota" | "eval";

export interface SloTarget {
  component: PlatformComponent;
  /** 用基点表示,9995 = 99.95%;整数比较避免浮点边界。 */
  targetBps: number;
  windowMinutes: number;
}

export interface SloWindow {
  component: PlatformComponent;
  totalEvents: number;
  badEvents: number;
  /** 已过去的窗口长度;用于计算燃烧率,必须小于等于 windowMinutes。 */
  elapsedMinutes: number;
  windowMinutes: number;
}

export type SloState = "healthy" | "at-risk" | "exhausted";
export type SloAction = "normal" | "review" | "freeze";

export interface SloReport {
  component: PlatformComponent;
  targetBps: number;
  actualBps: number;
  budgetEvents: number;
  badEvents: number;
  remainingEvents: number;
  consumedRatio: number;
  burnRate: number;
  projectedBadEvents: number;
  state: SloState;
  action: SloAction;
}

/**
 * 错误预算按事件数算:预算 = 总事件 × (1 - 目标)。
 * 燃烧率 = 已用预算比例 ÷ 已过窗口比例;2 倍燃烧率进入 review,预算用尽即 freeze。
 */
export function evaluateSlo(target: SloTarget, window: SloWindow): SloReport {
  if (target.component !== window.component) {
    throw new AiError("invalid-request", "platform-slo: component mismatch");
  }
  if (!Number.isInteger(target.targetBps) || target.targetBps <= 0 || target.targetBps > 10_000) {
    throw new AiError("invalid-request", `platform-slo: bad target ${target.targetBps}`);
  }
  if (!Number.isInteger(window.totalEvents) || window.totalEvents < 0
    || !Number.isInteger(window.badEvents) || window.badEvents < 0
    || window.badEvents > window.totalEvents) {
    throw new AiError("invalid-request", "platform-slo: bad event counts");
  }
  if (!(window.elapsedMinutes >= 0) || !(window.windowMinutes > 0)
    || window.elapsedMinutes > window.windowMinutes) {
    throw new AiError("invalid-request", "platform-slo: bad window");
  }
  const budgetEvents = Math.floor((window.totalEvents * (10_000 - target.targetBps)) / 10_000);
  const remainingEvents = budgetEvents - window.badEvents;
  const actualBps = window.totalEvents === 0
    ? 10_000
    : Math.floor(((window.totalEvents - window.badEvents) * 10_000) / window.totalEvents);
  const consumedRatio = budgetEvents === 0
    ? (window.badEvents > 0 ? 1 : 0)
    : window.badEvents / budgetEvents;
  const elapsedRatio = window.elapsedMinutes / window.windowMinutes;
  const burnRate = elapsedRatio === 0 ? 0 : consumedRatio / elapsedRatio;
  const projectedBadEvents = elapsedRatio === 0
    ? window.badEvents
    : Math.round(window.badEvents / elapsedRatio);
  const exhausted = window.badEvents >= budgetEvents && (budgetEvents > 0 || window.badEvents > 0);
  const state: SloState = exhausted ? "exhausted" : burnRate >= 2 ? "at-risk" : "healthy";
  const action: SloAction = state === "exhausted" ? "freeze" : state === "at-risk" ? "review" : "normal";
  return {
    component: target.component,
    targetBps: target.targetBps,
    actualBps,
    budgetEvents,
    badEvents: window.badEvents,
    remainingEvents,
    consumedRatio,
    burnRate,
    projectedBadEvents,
    state,
    action,
  };
}

/** 三个部件的目标来自平台对业务方的承诺,不由业务团队各自解释。 */
export const PLATFORM_SLO: readonly SloTarget[] = [
  { component: "gateway", targetBps: 9995, windowMinutes: 43_200 },
  { component: "quota", targetBps: 9999, windowMinutes: 43_200 },
  { component: "eval", targetBps: 9990, windowMinutes: 43_200 },
];

// ---- 容量规划:从业务峰值推到每个部件的实例数 ----

export interface CapacityComponent {
  component: PlatformComponent;
  /** 每千次业务请求需要该部件处理的次数;配额服务是 3 次。 */
  callsPerMilleRequests: number;
  /** 单个实例在目标延迟内能承受的请求数。 */
  rpsPerUnit: number;
}

export interface CapacityPlanInput {
  currentPeakRps: number;
  /** 月增长,基点表示,300 = 3.00%。 */
  monthlyGrowthBps: number;
  /** 预留余量,3000 = 30%。 */
  headroomBps: number;
  months: number;
  components: readonly CapacityComponent[];
}

export interface CapacityMonth {
  month: number;
  peakRps: number;
}

export interface CapacityNeed {
  component: PlatformComponent;
  requiredRps: number;
  units: number;
}

export interface CapacityPlan {
  months: CapacityMonth[];
  needs: CapacityNeed[];
}

/** 按月复利外推峰值,再乘部件调用倍率与余量;实例数一律向上取整。 */
export function planCapacity(input: CapacityPlanInput): CapacityPlan {
  if (!(input.currentPeakRps > 0) || !Number.isInteger(input.months) || input.months < 1) {
    throw new AiError("invalid-request", "platform-slo: bad capacity input");
  }
  if (input.monthlyGrowthBps < 0 || input.headroomBps < 0) {
    throw new AiError("invalid-request", "platform-slo: growth and headroom must be non-negative");
  }
  const months: CapacityMonth[] = [{ month: 0, peakRps: input.currentPeakRps }];
  let peak = input.currentPeakRps;
  for (let month = 1; month <= input.months; month += 1) {
    peak = Math.round((peak * (10_000 + input.monthlyGrowthBps)) / 10_000);
    months.push({ month, peakRps: peak });
  }
  const horizon = peak;
  const needs = input.components.map((component): CapacityNeed => {
    const raw = (horizon * component.callsPerMilleRequests) / 1_000;
    const requiredRps = Math.ceil((raw * (10_000 + input.headroomBps)) / 10_000);
    return {
      component: component.component,
      requiredRps,
      units: Math.ceil(requiredRps / component.rpsPerUnit),
    };
  });
  return { months, needs };
}

// ---- 平台故障时,业务侧看到的降级行为必须事先写死 ----

export type PlatformFailure = "unavailable" | "latency" | "partial";

export interface DegradationRule {
  component: PlatformComponent;
  failure: PlatformFailure;
  businessBehavior: string;
  /** true 表示业务可继续用旧配置/旧结果,false 表示必须拒绝新请求。 */
  failOpen: boolean;
  maxStaleMs: number;
}

export const DEGRADATION_RULES: readonly DegradationRule[] = [
  {
    component: "gateway", failure: "unavailable",
    businessBehavior: "SDK 切备用区域;无备用区域时返回 503 与 Retry-After,展示兜底文案",
    failOpen: false, maxStaleMs: 0,
  },
  {
    component: "quota", failure: "unavailable",
    businessBehavior: "只放行预授权额度内的请求,超出即拒绝;不做全量放行",
    failOpen: false, maxStaleMs: 60_000,
  },
  {
    component: "eval", failure: "unavailable",
    businessBehavior: "冻结发布与配置放量,线上按现有版本继续服务",
    failOpen: true, maxStaleMs: 0,
  },
];

export function degradationFor(
  component: PlatformComponent,
  failure: PlatformFailure,
): DegradationRule | undefined {
  return DEGRADATION_RULES.find(
    (rule) => rule.component === component && rule.failure === failure,
  );
}

evaluateSlo 拒绝跨部件比较:传入门关目标与配额窗口会直接报错。这不是防御性编程的洁癖,而是防止「拿网关的目标算配额的预算」这种看板拼接错误。

planCapacity 的输入是业务峰值,不是平台当前 QPS。配额服务每个业务请求要判三次(单次上限、团队份额、租户总额),所以它的 callsPerMilleRequests 是 3000,不是 1000。容量规划如果只按业务峰值乘一个拍脑袋的系数,通常会漏掉这种放大关系。

degradationFor 只覆盖「不可用」这一种故障。延迟升高、部分失败的处理不同:延迟升高时可以切备用区域或降档,部分失败时要按错误类型决定重试,不能一概而论。规则表要按故障类型分别写,而不是一个部件一条。

示例样本上的运行结果

用三个部件的各一个窗口跑一遍,结果如下。所有数字都是示例样本上的运行结果。

部件总事件失败预算剩余燃烧率外推失败状态动作
网关1,000,0002405002600.96480healthynormal
配额2,000,000300200-1003.00600exhaustedfreeze
评测50,0004050103.20160at-riskreview

网关窗口已过一半,失败 240,预算 500,燃烧率 0.96,外推全月 480,仍在预算内。配额窗口同样过了一半,失败 300,预算只有 200,已经透支 100,直接进入 freeze。评测窗口只过了四分之一,失败 40,预算 50,燃烧率 3.2;虽然还没耗尽,但按当前速度全月会到 160,进入 review。

这三个状态对应三种不同的会议。healthy 不需要开会,按周看趋势即可;at-risk 要在值班会上确认「是流量结构变了,还是某个版本引入的失败」;exhausted 要停变更并做事故复盘。把三档动作写成同一个「关注」,等于没有分级。

容量规划:从业务峰值推到实例数

容量规划的输入有四个:当前业务峰值、月增长率、预留余量、规划月数。输出是每个月的峰值预测,以及每个部件需要的实例数。

示例样本用 1,200 rps 当前峰值、每月 3% 增长、30% 余量、规划 6 个月。峰值预测如下:

月份0123456
峰值 rps1,2001,2361,2731,3111,3501,3911,433

六个月后峰值 1,433 rps。再乘部件调用倍率与 1.3 的余量,得到需求与实例数:

部件每千请求调用次数单实例 rps需求 rps实例数
网关1,0004001,8635
配额3,0008005,5897
评测2050381

三个数字值得注意。第一,配额服务的需求是网关的 3 倍,因为每个业务请求要判三层;如果容量规划只按网关的实例数配,配额会先被打满。第二,评测的调用倍率只有 20/1000,但单实例能力也低,最终仍需 1 个实例;小流量部件不能按比例缩到 0。第三,实例数一律向上取整,4.66 个实例就是 5 个,不能写成 4.66 然后按比例分摊。

30% 余量不是拍脑袋。它覆盖三件事:发布时的滚动重启、单可用区故障、以及预测误差。余量低于 20% 时,一次滚动发布就可能把容量打到临界;高于 50% 时,成本会明显浪费。示例取 30%,是一个可讨论的起点,不是唯一答案。

平台 SLO 与业务 SLO 的合成

业务方经常把平台 SLO 直接写进自己的对外承诺,这是危险的。平台部件是串联关系:一个请求要经过网关与配额,两个部件的可用性相乘,才是这条路径的上限。

示例中网关 99.95%、配额 99.99%,串联后的上限是 99.940005%,约 99.94%。如果业务链路还依赖一个 99.9% 的评测服务(发布时),它不影响线上请求;但如果业务在请求路径上再加一个 99.9% 的第三方服务,整条链路的上限会降到 99.84%。

这条规则可以写成一句话:业务 SLO 不能高于其依赖路径上所有平台部件可用性的乘积。平台团队在承诺 SLO 时,要把这个乘积算给业务方看,而不是只给单个部件的数字。

反过来,业务方也不应该要求平台为「业务侧超时」负责。调用方设置了 200ms 的 deadline,网关在 210ms 返回成功,业务仍然会记为失败。平台 SLO 的统计口径必须写清楚「谁的超时算谁的失败」,否则每次对账都会吵架。

平台故障时,业务侧怎么降级

SLO 定义的是「正常有多好」,降级规则定义的是「坏了怎么办」。两者必须一起发布,否则平台故障时,每个团队会各自发明一套兜底。

部件故障业务侧行为是否放行可容忍陈旧
网关不可用切备用区域;无备用时返回 503 与 Retry-After,展示兜底文案否0
配额不可用只放行预授权额度内的请求,超出即拒绝否60 秒
评测不可用冻结发布与配置放量,线上按现有版本继续是0

配额不可用时最重要的一条是「不做全量放行」。全量放行看起来保护了可用性,实际是把配额风险转成了成本风险:一个失控的调用方可以在配额服务恢复前花掉整个租户的月度预算。正确做法是退到预授权额度,额度内放行,额度外拒绝,并把拒绝原因明确告诉调用方。

评测不可用时可以 fail open,因为评测不在请求路径上。线上流量继续用现有版本,发布与配置放量暂停。这里的关键是「暂停」要自动执行,不能靠人记得;评测平台不可用时,发布流水线应该直接拒绝新的发布请求。

降级规则还要写清楚恢复顺序。配额服务恢复后,先恢复预授权额度之外的放行,再恢复配额扣减,最后才恢复配置变更;顺序反了,可能在配额账本还没对齐时就把新配置放出去。

容量与 SLO 的联动

容量不足会先表现为延迟升高,再表现为超时,最后才表现为可用性下降。如果 SLO 只定义可用性,容量问题会在很长一段时间里「不违反 SLO」,直到某天突然击穿。

所以平台至少要监控三个前置指标:峰值利用率、排队深度、尾延迟。经验阈值是:峰值利用率超过 70% 时启动扩容评估,排队深度连续 5 分钟超过阈值时降档,P95 超过目标 1.5 倍时进入 review。三个阈值都不直接等于 SLO 违反,但它们是 SLO 违反的先行指标。

容量规划也要与错误预算联动。如果某个部件连续两个窗口都在 at-risk,除了查失败原因,还要检查容量是否已经接近上限;有时候「失败变多」不是代码问题,而是实例数不够导致超时。

小结

平台 SLO 要拆到部件:网关 99.95%、配额 99.99%、评测 99.90%,每个目标都写清统计口径与月度允许不可用时间。错误预算用事件数与整数基点计算,燃烧率把时间维度加进来,三档动作分别是 normal、review、freeze。

示例样本上,网关 100 万事件、240 次失败,预算 500,燃烧率 0.96,状态 healthy;配额 200 万事件、300 次失败,预算 200,状态 exhausted;评测 5 万事件、40 次失败,燃烧率 3.2,状态 at-risk。容量规划从 1,200 rps、月增 3%、30% 余量出发,六个月后峰值 1,433 rps,网关 5 个实例、配额 7 个、评测 1 个。

阅读导航:上一节:10.2 SDK、CLI 与配置分发 · 下一节:11.1 谁拥有 AI 能力:平台团队与业务团队 。

常见错误与边界

  • 平台 SLO 只统计自己最健康的那一段。业务看到的失败与平台报表对不上。
  • 把百分比直接相乘或用浮点比较。边界上会出现 499 与 500 的差异,统一用整数基点。
  • 只看预算消耗,不看燃烧率。月初用掉一半与月末用掉一半含义完全不同。
  • 把 freeze 理解成停服。freeze 冻结的是平台变更,不是业务流量。
  • 网关预算耗尽就冻结所有团队发布。业务发布应由评测平台状态决定。
  • 业务 SLO 直接抄平台单部件 SLO。串联依赖的可用性上限是乘积,不是最小值。
  • 不写「谁的超时算谁的失败」。每次对账都要重新争论。
  • 容量规划只按业务峰值。配额服务每请求判三层,需求可能是网关的 3 倍。
  • 小流量部件按比例缩到 0。评测流量小,但仍需至少一个实例。
  • 只监控可用性。容量不足会先表现为延迟与排队,等 SLO 报警已经晚了。
  • 配额服务故障时全量放行。可用性保住了,成本风险被放大。
  • 降级规则只在事故时才找。没有事先写死的规则,每个团队会各自兜底。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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