把并发调大之后,系统更慢了
「回声」的网关在晚高峰出现排队,值班同学的第一反应是把每实例并发从 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 |
|---|---|---|---|---|---|---|---|
| 无界队列 | 240 | 0 | 0 | 84 | 1220ms | 48 | 13.86/秒 |
| 有界 + 拒绝 | 200 | 40 | 0 | 200 | 280ms | 8 | 39.52/秒 |
| 降级 | 240 | 0 | 88 | 240 | 170ms | 5 | 48.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。完成的请求如果都超时,等于没有完成。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。