《互联网产品的 AI 架构·推理性能与成本工程》2.2 并发上限与背压

从一次「把并发调大就好了」的线上误判出发,说明并发上限如何用 Little 定律与目标利用率推导、信号量如何限制在途请求、背压如何向上游传播,以及降级与拒绝的本质区别。文中给出可运行的 TypeScript 信号量与压力决策模块,并在 240 条固定样本上对比无界队列、有界拒绝与降级三种策略的真实运行结果。

把并发调大之后,系统更慢了

「回声」的网关在晚高峰出现排队,值班同学的第一反应是把每实例并发从 8 调到 32。调完之后,吞吐没有上升,P99 反而更差,供应商侧还开始返回 429。他以为是网关线程池不够,又调大了一次。

问题不在并发太小,而在于并发从来没有被定义清楚。32 个在途请求意味着 32 份模型上下文、32 条上游连接、32 份后处理内存,以及供应商侧同一个配额下的 32 个竞争者。超过某个点之后,每多一个并发,都是在给系统增加抖动,而不是增加吞吐。

本节讨论三件事:并发上限怎么算、信号量怎么实现、过载时到底该背压、降级还是拒绝。文中数字来自 ai-lab 的确定性过载模拟,标注为示例样本上的运行结果。

并发上限从哪里来

最常用的估算是 Little 定律:在途请求数等于到达率乘以平均停留时间。把停留时间换成 P95 服务时间,得到的是偏保守的负载估计:

offered = 到达率(请求/秒) × P95 服务时间(毫秒) / 1000

如果每秒到达 50 个请求,每个请求 P95 服务 100ms,那么在途负载就是 5 个 Erlang。这个数字还不是并发上限。系统需要在平均利用率低于 100% 的情况下运行,才能吸收到达抖动和服务时间波动。把负载除以目标利用率,再向上取整,就是需要的槽位:

concurrency = ceil(offered / targetUtilization)

同样 5 Erlang 的负载,目标利用率 80% 需要 7 个槽位,70% 需要 8 个。利用率目标越低,同样的流量需要越多并发,也意味着越高的内存与成本。这个公式给出的不是「最优并发」,而是「在给定抖动容忍度下的最小并发」,最终仍要用压测确认。

并发不是越多越好

推理服务的并发上限还受三类资源约束。第一是显存与 KV cache:每个在途请求都占一份上下文,超过显存后要么排队,要么触发换出,延迟会突然变差。第二是供应商配额:本地并发再高,上游的 RPM/TPM 限制不会跟着变。第三是后处理与连接池:模型返回只是链路的一段,JSON 解析、审核、写库都要占资源。

因此并发上限应当按「模型 × 供应商 × 区域」分别设置,而不是一个全局数字。网关层可以有总并发,路由层还要有每个模型的上限;两者是嵌套关系,不是二选一。

信号量只负责计数

信号量做的事情很简单:拿到许可才能进入,离开时归还。它不负责排队策略,也不负责判断请求该不该等。一个没有等待上限的信号量,会把过载从「上游超时」转换成「进程内无限排队」,性质和 2.1 的无界队列一样。

实现上有一个容易写错的细节:释放时如果已有等待者,应当把槽位直接转交给它,而不是先减一再加一。后者会在两个原子操作之间产生瞬时超卖,测试里很难复现,生产上表现为偶发的并发越界。

背压、降级与拒绝

这三个词经常被混用,但它们对用户和上游的影响完全不同。

背压是「慢一点」。请求仍进入有界队列,同时通过 429 或 Retry-After 告诉上游当前容量不足。它适合可重试、对延迟不敏感的调用,例如后台任务或带退避的客户端。

降级是「换个便宜的方式给结果」。请求不再排队,直接走小模型、缓存或模板,结果仍然可用,只是质量下降。它适合交互场景:用户愿意接受稍差的答案,不愿意接受空白。

拒绝是「现在真的做不了」。当预估等待已经超过请求预算,或者队列达到硬上限时,明确失败比让用户等到超时更诚实。拒绝必须带原因和重试建议,否则调用方只能盲目重试,把过载放大。

判断顺序建议是:先看等待预算,再看能否降级,最后才拒绝。等待预算还够时进入有界队列;预算过半且系统饱和时转降级;预算耗尽或队列满时拒绝。这个顺序让「降级」成为主动选择,而不是超时后的补救。

实现

下面的模块包含三部分:并发上限推导、同步信号量、压力决策。信号量只做计数,压力决策只做判断,两者都不直接发 HTTP 响应;调用方拿到决策后决定回什么状态码。

// file: src/perf/backpressure.ts
// tier: L1
// verify: node --test test/perf/scheduling.test.ts

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

export interface ConcurrencyInput {
  /** 到达率,单位请求/秒。 */
  arrivalRps: number;
  /** 单请求 P95 服务时间,单位毫秒。 */
  p95ServiceMs: number;
  /** 允许的平均利用率,必须小于 1,给抖动留余量。 */
  targetUtilization: number;
  minConcurrency?: number;
  maxConcurrency?: number;
}

export interface ConcurrencyPlan {
  /** 用 Little 定律算出的在途请求数,即 Erlang 负载。 */
  offeredLoad: number;
  /** 在目标利用率下需要的并发槽位。 */
  targetConcurrency: number;
  /** 计划并发相对负载的余量。 */
  headroom: number;
}

/**
 * 并发上限不是拍脑袋的固定值。先算 offered = 到达率 × 服务时间,
 * 再按目标利用率放大;利用率越低,同样的负载需要越多槽位来吸收抖动。
 */
export function deriveConcurrencyLimit(input: ConcurrencyInput): ConcurrencyPlan {
  if (!Number.isFinite(input.arrivalRps) || input.arrivalRps < 0) {
    throw new AiError("invalid-request", "backpressure: arrivalRps 必须是非负有限数");
  }
  if (!Number.isFinite(input.p95ServiceMs) || input.p95ServiceMs <= 0) {
    throw new AiError("invalid-request", "backpressure: p95ServiceMs 必须是正有限数");
  }
  if (!(input.targetUtilization > 0 && input.targetUtilization < 1)) {
    throw new AiError("invalid-request", "backpressure: targetUtilization 必须落在 (0, 1)");
  }
  const min = input.minConcurrency ?? 1;
  const max = input.maxConcurrency ?? Number.MAX_SAFE_INTEGER;
  if (!Number.isInteger(min) || min <= 0 || !Number.isInteger(max) || max < min) {
    throw new AiError("invalid-request", "backpressure: 并发上下界非法");
  }
  const offeredLoad = (input.arrivalRps * input.p95ServiceMs) / 1000;
  const raw = Math.ceil(offeredLoad / input.targetUtilization);
  const targetConcurrency = Math.max(min, Math.min(max, Math.max(1, raw)));
  return { offeredLoad, targetConcurrency, headroom: targetConcurrency - offeredLoad };
}

/**
 * 同步信号量:只限制在途数量,不负责排队策略。
 * 等待队列也不设上限时,信号量会把过载转成无限内存;调用方必须先做准入。
 */
export class Semaphore {
  readonly limit: number;
  private active = 0;
  private readonly waiters: Array<() => void> = [];

  constructor(limit: number) {
    if (!Number.isInteger(limit) || limit <= 0) {
      throw new AiError("invalid-request", "backpressure: 信号量上限必须是正整数");
    }
    this.limit = limit;
  }

  get inFlight(): number {
    return this.active;
  }

  get waiting(): number {
    return this.waiters.length;
  }

  tryAcquire(): boolean {
    if (this.active >= this.limit) return false;
    this.active += 1;
    return true;
  }

  /** 等待一个槽位。等待者按调用顺序唤醒,不区分优先级。 */
  acquire(): Promise<void> {
    if (this.active < this.limit) {
      this.active += 1;
      return Promise.resolve();
    }
    return new Promise<void>((resolve) => {
      this.waiters.push(resolve);
    });
  }

  release(): void {
    if (this.active <= 0) {
      throw new AiError("invalid-request", "backpressure: release 次数多于 acquire");
    }
    const next = this.waiters.shift();
    if (next === undefined) {
      this.active -= 1;
      return;
    }
    // 槽位直接转交给等待者,active 不变,避免出现瞬时超卖。
    next();
  }
}

export type PressureLevel = "normal" | "elevated" | "critical";
export type PressureAction = "accept" | "queue" | "degrade" | "reject";

export interface PressureInput {
  inFlight: number;
  limit: number;
  waiting: number;
  oldestWaitMs: number;
  /** 请求还能接受的最长排队时间。 */
  waitBudgetMs: number;
}

export interface PressureDecision {
  level: PressureLevel;
  action: PressureAction;
  reason: "ok" | "queue-building" | "wait-budget-half" | "wait-budget-exceeded" | "queue-full";
  /** 拒绝时回给上游的 Retry-After,单位毫秒。 */
  retryAfterMs: number;
}

/**
 * 背压、降级、拒绝是三件事:
 * - 背压:允许进入有界队列,同时向上游发出变慢信号;
 * - 降级:不再排队,改走更便宜但仍有结果的路径;
 * - 拒绝:明确失败,避免用户等到超过预算才收到超时。
 */
export function decidePressure(input: PressureInput): PressureDecision {
  if (!Number.isInteger(input.limit) || input.limit <= 0) {
    throw new AiError("invalid-request", "backpressure: limit 必须是正整数");
  }
  const queueFull = input.waiting >= Math.max(8, input.limit * 2);
  const hardLimit = Math.max(1, Math.floor(input.waitBudgetMs / 2));
  if (input.waiting > 0 && (input.oldestWaitMs >= input.waitBudgetMs || queueFull)) {
    return {
      level: "critical",
      action: "reject",
      reason: queueFull ? "queue-full" : "wait-budget-exceeded",
      retryAfterMs: Math.max(1, input.waitBudgetMs),
    };
  }
  if (input.inFlight >= input.limit && input.oldestWaitMs >= hardLimit) {
    return { level: "elevated", action: "degrade", reason: "wait-budget-half", retryAfterMs: 0 };
  }
  if (input.inFlight >= input.limit || input.waiting > 0) {
    return { level: "elevated", action: "queue", reason: "queue-building", retryAfterMs: 0 };
  }
  return { level: "normal", action: "accept", reason: "ok", retryAfterMs: 0 };
}

Semaphore.release 在存在等待者时不减少 active,而是把许可直接交给下一个等待者。这个细节保证任意时刻在途数量都不会超过上限。decidePressure 把 queue-full 放在 wait-budget-exceeded 之前,因为队列满时估算单个请求的剩余预算没有意义,容量本身就是硬约束。

测试与运行结果

测试覆盖并发推导、信号量获取与释放、四类压力决策,以及三种过载策略的对比。执行:

node --test test/perf/scheduling.test.ts

本机一次运行的真实输出摘要如下,耗时随机器变化:

ℹ tests 14
ℹ pass 14
ℹ fail 0

过载样本是 240 条请求在 4.8 秒内到达,单请求服务 100ms,4 个并发槽位的容量约为 40 请求/秒,到达率约为 50 请求/秒,负载是容量的 1.25 倍。三种策略的结果如下,均为示例样本上的运行结果:

策略完成拒绝降级按时完成P95 延迟最大队列goodput
无界队列24000841220ms4813.86/秒
有界 + 拒绝200400200280ms839.52/秒
降级240088240170ms548.48/秒

无界队列完成了全部 240 条请求,但只有 84 条在 500ms 截止时间内完成,P95 延迟 1220ms,goodput 只有 13.86 请求/秒。它看起来「没有丢请求」,实际上把大部分容量花在了排队上。

有界拒绝完成 200 条、拒绝 40 条,完成的请求全部按时,P95 延迟 280ms,goodput 39.52 请求/秒,接近 4 个槽位的理论上限。降级策略完成全部 240 条,其中 88 条走降级路径,P95 170ms,goodput 48.48 请求/秒。降级吞吐最高,是因为它把单请求服务时间减半;代价是 36.7% 的请求质量下降,这个比例必须进入产品指标,而不能只出现在技术看板。

这组数字也说明三个指标不能互相替代:拒绝率低不代表系统健康,延迟低也不代表交付质量不变。选择哪种策略,取决于业务能接受哪一种代价。

小结

并发上限来自负载与目标利用率,不是拍脑袋的数字;信号量只保证在途数量不越界,等待上限仍要单独设置。背压让上游慢下来,降级让用户拿到稍差的结果,拒绝让失败尽早发生。三者按等待预算排序,而不是按实现方便排序。

下一节讨论当请求来自多个租户时,怎样在共享容量下按权重分配,并防止低权重租户被饿死。

阅读导航:上一节:2.1 队列、优先级与准入 · 下一节:2.3 多租户公平调度 。

常见错误与边界

  • 把并发当成吞吐旋钮。超过资源上限后,并发越高,抖动越大。
  • 用平均服务时间算并发。P95 才是排队与超时的主要来源。
  • 不设目标利用率。100% 利用率意味着任何抖动都会直接变成排队。
  • 一个全局并发上限管所有模型。不同模型、供应商、区域的容量完全不同。
  • 信号量等待队列不设上限。过载被转换成进程内无限排队。
  • 释放信号量时先减后加。等待者会被瞬时超卖,问题偶发且难查。
  • 把背压当成拒绝。背压是让上游慢下来,拒绝是明确不做。
  • 把降级当成免费。降级比例必须和质量指标一起看。
  • 拒绝不带原因与重试建议。调用方只能盲目重试,进一步放大过载。
  • 只看完成数,不看 goodput。完成的请求如果都超时,等于没有完成。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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