40 QPS 的看板与一百个超时用户
「回声」的推荐文案服务把压测报告发到群里:峰值吞吐 40 QPS,平均延迟 300ms,看起来可以上线。上线第二天,客服收到一百多条“点了没反应”。值班同学把并发从 4 调到 8,吞吐只从 33 涨到 37,P95 反而没降。
问题不在压测造假,而在压测只回答了“系统每秒能完成多少请求”,没有回答“其中多少请求在用户能接受的时间内完成”。40 QPS 里可以有 30 个请求等了两秒,用户看到的就是超时。吞吐是容量视角,goodput 才是体验视角。
本节把三个概念分开:并发是瞬时在途数,吞吐是单位时间完成数,goodput 是在延迟 SLO 内完成的请求数。然后证明两件事:并发增加带来的吞吐增量会递减;批处理可以让吞吐几乎不变,却让 goodput 大幅下降。文中数字来自 ai-lab 的确定性调度器,标注为“示例样本上的运行结果”,不是生产实测。
三个概念:并发、吞吐与 goodput
| 概念 | 定义 | 单位 | 回答的问题 |
|---|---|---|---|
| 并发 | 某一时刻在途的请求数 | 个 | 系统同时压了多少活 |
| 吞吐 | 统计窗口内完成的请求数除以窗口秒数 | req/s | 系统每秒能交多少活 |
| goodput | 统计窗口内满足延迟 SLO 的完成数除以窗口秒数 | req/s | 每秒有多少活是“可用”的 |
| goodput 比例 | 满足 SLO 的完成数除以总完成数 | % | 交付里有多少比例达标 |
goodput 必须同时带两个参数:SLO 和统计窗口。脱离 SLO 的“goodput 95%”没有意义,因为把 SLO 从 300ms 改成 3 秒,同一个系统可以从 28% 变成 100%。脱离窗口的吞吐也没有意义,因为把窗口拉长到一天,任何系统都能算出漂亮的平均值。
吞吐与并发之间有一个恒等关系,可以直接当一致性检查:
并发 ≈ 吞吐 × 平均延迟
如果看板显示 100 个并发、40 req/s,那么隐含的平均延迟是 2.5 秒。此时若另一个面板说平均延迟 300ms,两个数字必有一个口径错了:要么并发是连接数不是在途请求,要么吞吐窗口与延迟窗口不是同一段。
并发增加,吞吐并不会线性增长
直觉上,并发翻倍,吞吐也翻倍。真实系统里,这个关系很快就会弯掉。原因不神秘:请求共享同一批 worker、同一块 GPU、同一条下游连接。并发低时,加并发能把空闲资源用起来;并发高时,多出来的请求只能排队,吞吐趋近服务能力上限,延迟继续上涨。
用固定到达序列做一次扫描,可以把这个弯点找出来。示例样本是 60 个请求、每 25ms 到达一个、每个请求输出 24 token,服务参数固定,SLO 取 300ms。结果如下:
| workers | 吞吐 req/s | P50 | P95 | 峰值并发 | goodput 比例 |
|---|---|---|---|---|---|
| 1 | 8.62 | 2755ms | 5212ms | 48 | 5.00% |
| 2 | 17.12 | 1040ms | 1964ms | 36 | 10.00% |
| 4 | 33.06 | 228ms | 340ms | 12 | 80.00% |
| 8 | 37.71 | 116ms | 116ms | 5 | 100.00% |
从 1 到 2,吞吐翻了近一倍;从 4 到 8,只涨了 14%。8 个 worker 时吞吐已经接近到达率上限(每 25ms 一个请求 = 40 req/s),再加 worker 也不会更快,只会让空闲更多。这就是“吞吐与并发的非线性”:容量规划要看拐点,而不是看单次翻倍的斜率。
注意 goodput 比例的变化比吞吐更剧烈:1 个 worker 时只有 5% 的请求在 300ms 内完成,8 个 worker 时才到 100%。如果只看吞吐,2 个 worker 的 17 req/s 似乎“也有产出”;从体验看,其中九成请求已经不可用。
批处理:吞吐先升,p95 后升
批处理是推理服务最常用的吞吐手段:把多个请求凑成一批,一次前向算完。它能提高吞吐,因为固定开销被摊薄;但它也会抬高每个请求的等待与单步耗时。用同一个调度器扫描批大小,服务参数为 4 个 worker、400ms 攒批窗口、SLO 300ms:
| maxBatch | 吞吐 req/s | P50 | P95 | goodput req/s | goodput 比例 |
|---|---|---|---|---|---|
| 1 | 33.06 | 228ms | 340ms | 26.45 | 80.00% |
| 2 | 37.57 | 122ms | 147ms | 37.57 | 100.00% |
| 4 | 37.29 | 159ms | 209ms | 37.29 | 100.00% |
| 8 | 37.29 | 233ms | 333ms | 28.59 | 76.67% |
| 16 | 36.21 | 381ms | 556ms | 10.26 | 28.33% |
这张表最值得看的是最后两行。批大小从 8 到 16,吞吐只从 37.29 降到 36.21,降幅 2.9%,看起来“几乎没变”;goodput 却从 76.67% 跌到 28.33%,可用吞吐从 28.59 掉到 10.26 req/s,跌了 64%。如果容量评审只看吞吐,会得出“16 也能用”的结论;从用户体验看,七成请求已经超时。
拐点在批大小 2:吞吐从 33.06 升到 37.57,P95 反而从 340ms 降到 147ms。原因是适度攒批提高了服务能力,把排队消化掉了。越过拐点后,批内等待与单步耗时开始主导,P95 单调上升。批处理不是“越大越好”,而是“先还排队债,再欠延迟债”。
与卷一的衔接
卷一 12.1 的观测字段是单请求粒度:每次请求记录耗时分段、用量与结果。本节把这些记录聚合成系统指标,新增的是 goodput 口径与并发扫描,不重复讲解观测字段本身。
卷一 13.3 的基线用成功率、P95、单次成本等五项指标判断“能不能上线”。本节补上它没有覆盖的一维:同样 100% 成功,系统可能只有 28% 的请求在 SLO 内。成功率回答“有没有结果”,goodput 回答“结果是否及时”。
实现
下面的模块包含三部分:确定性批调度器、goodput 汇总、峰值并发计算。输入是固定请求序列与固定服务参数,输出可以逐条复算,不依赖随机数或真实模型。
// file: src/perf/goodput.ts
// tier: L1
// verify: node --test test/perf/goodput.test.ts
import { AiError } from "../core/errors.ts";
import { percentile } from "../observe/baseline.ts";
/** 压测侧的一条请求:到达时刻与输出长度都由负载模型决定。 */
export interface LoadRequest {
requestId: string;
arriveAtMs: number;
outputTokens: number;
}
export interface ServerConfig {
workers: number;
/** 每个请求的固定开销(调度、序列化、连接)。 */
fixedMs: number;
/** 每输出一个 token 的基础解码耗时。 */
perTokenMs: number;
/** 同一批里每多一个请求增加的单步耗时,模拟带宽与注意力竞争。 */
perSiblingMs: number;
}
export interface BatchPolicy {
maxBatch: number;
/** 攒批窗口:从第一个待处理请求起,最多等这么久。 */
batchWindowMs: number;
}
export interface Completion {
requestId: string;
arriveAtMs: number;
startAtMs: number;
doneAtMs: number;
latencyMs: number;
outputTokens: number;
batchSize: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `goodput: ${message}`);
}
function checkInteger(value: number, label: string, minimum: number): void {
if (!Number.isInteger(value) || value < minimum) fail(`${label} 必须是不小于 ${minimum} 的整数`);
}
function validateConfig(config: ServerConfig): void {
checkInteger(config.workers, "workers", 1);
for (const [label, value] of [
["fixedMs", config.fixedMs],
["perTokenMs", config.perTokenMs],
["perSiblingMs", config.perSiblingMs],
] as const) {
if (!Number.isFinite(value) || value < 0) fail(`${label} 必须是非负有限数`);
}
}
/**
* 批服务耗时由三部分组成:固定开销、最长序列的解码、
* 以及同一批内并发请求带来的额外单步耗时。
* 用 max 而不是 sum,是因为解码要等最长的那条序列结束。
*/
export function batchServiceMs(
config: ServerConfig,
batch: readonly LoadRequest[],
): number {
if (batch.length === 0) fail("空批次没有服务耗时");
const longest = Math.max(...batch.map((item) => item.outputTokens));
return config.fixedMs + config.perTokenMs * longest + config.perSiblingMs * (batch.length - 1);
}
/**
* 确定性批调度器:固定请求序列 + 固定 worker 数 => 固定完成时刻。
* 同一个输入重复运行会得到逐条一致的 Completion,便于做可复现基线。
*/
export function simulateBatchScheduler(
requests: readonly LoadRequest[],
config: ServerConfig,
policy: BatchPolicy,
): Completion[] {
validateConfig(config);
checkInteger(policy.maxBatch, "maxBatch", 1);
if (!Number.isFinite(policy.batchWindowMs) || policy.batchWindowMs < 0) {
fail("batchWindowMs 必须是非负有限数");
}
if (requests.length === 0) return [];
for (const request of requests) {
if (!Number.isFinite(request.arriveAtMs) || request.arriveAtMs < 0) {
fail(`${request.requestId}: arriveAtMs 必须是非负有限数`);
}
checkInteger(request.outputTokens, `${request.requestId}.outputTokens`, 1);
}
const pending = [...requests].sort(
(left, right) => left.arriveAtMs - right.arriveAtMs || left.requestId.localeCompare(right.requestId),
);
const workerFreeAt = Array.from({ length: config.workers }, () => 0);
const completions: Completion[] = [];
let cursor = 0;
while (cursor < pending.length) {
const openAt = pending[cursor].arriveAtMs;
const deadline = openAt + policy.batchWindowMs;
const batch: LoadRequest[] = [];
while (
cursor < pending.length &&
batch.length < policy.maxBatch &&
(batch.length === 0 || pending[cursor].arriveAtMs <= deadline)
) {
batch.push(pending[cursor]);
cursor += 1;
}
let worker = 0;
for (let index = 1; index < workerFreeAt.length; index += 1) {
if (workerFreeAt[index] < workerFreeAt[worker]) worker = index;
}
const closeAt = batch[batch.length - 1].arriveAtMs;
const startAt = Math.max(closeAt, workerFreeAt[worker]);
const doneAt = startAt + batchServiceMs(config, batch);
workerFreeAt[worker] = doneAt;
for (const request of batch) {
completions.push({
requestId: request.requestId,
arriveAtMs: request.arriveAtMs,
startAtMs: startAt,
doneAtMs: doneAt,
latencyMs: doneAt - request.arriveAtMs,
outputTokens: request.outputTokens,
batchSize: batch.length,
});
}
}
return completions.sort(
(left, right) => left.arriveAtMs - right.arriveAtMs || left.requestId.localeCompare(right.requestId),
);
}
export interface RunSummary {
completed: number;
windowMs: number;
throughputRps: number;
tokenThroughput: number;
metSlo: number;
goodputRps: number;
/** 满足延迟 SLO 的请求比例,这才是“有效吞吐”的可比口径。 */
goodputRatio: number;
p50LatencyMs: number;
p95LatencyMs: number;
peakConcurrency: number;
}
/** 峰值并发:到达 +1、完成 -1;同一时刻先算完成,避免把交接算成两倍。 */
export function peakConcurrency(completions: readonly Completion[]): number {
const events: Array<{ atMs: number; delta: number }> = [];
for (const item of completions) {
events.push({ atMs: item.arriveAtMs, delta: 1 });
events.push({ atMs: item.doneAtMs, delta: -1 });
}
events.sort((left, right) => left.atMs - right.atMs || left.delta - right.delta);
let current = 0;
let peak = 0;
for (const event of events) {
current += event.delta;
peak = Math.max(peak, current);
}
return peak;
}
/**
* 统计窗口取「最早到达」到「最晚完成」。
* 吞吐与 goodput 必须共享同一个窗口,否则两个数字不可比。
*/
export function summarizeRun(completions: readonly Completion[], sloMs: number): RunSummary {
if (completions.length === 0) fail("没有完成记录,无法汇总");
if (!Number.isFinite(sloMs) || sloMs <= 0) fail("sloMs 必须是正有限数");
const firstArrival = Math.min(...completions.map((item) => item.arriveAtMs));
const lastDone = Math.max(...completions.map((item) => item.doneAtMs));
const windowMs = Math.max(1, lastDone - firstArrival);
const met = completions.filter((item) => item.latencyMs <= sloMs);
const tokens = completions.reduce((sum, item) => sum + item.outputTokens, 0);
return {
completed: completions.length,
windowMs,
throughputRps: (completions.length * 1000) / windowMs,
tokenThroughput: (tokens * 1000) / windowMs,
metSlo: met.length,
goodputRps: (met.length * 1000) / windowMs,
goodputRatio: met.length / completions.length,
p50LatencyMs: percentile(completions.map((item) => item.latencyMs), 0.5),
p95LatencyMs: percentile(completions.map((item) => item.latencyMs), 0.95),
peakConcurrency: peakConcurrency(completions),
};
}
simulateBatchScheduler 的批关闭条件是“攒满 maxBatch 或到达攒批窗口”,两者取先到。服务耗时用批内最长输出而不是总和,因为解码要等最长的那条序列结束;perSiblingMs 则模拟同一批内并发请求对带宽与注意力的竞争。
summarizeRun 强制吞吐与 goodput 共享同一个窗口。它不提供“只算成功请求”的选项,因为那会把失败请求从分母里删掉,得到一个永远好看的数字。
测试与示例样本结果
测试覆盖批服务耗时、确定性重复、并发饱和、批大小拐点、峰值并发与非法配置。执行:
node --test test/perf/goodput.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 7
ℹ pass 7
ℹ fail 0
ℹ duration_ms 140.059458
并发扫描的断言不是“workers 翻倍吞吐就翻倍”,而是三条更弱但更真实的性质:吞吐不下降、P95 不上升、goodput 比例不下降,以及“第一次翻倍的吞吐增量大于最后一次”。批大小测试则先找到 P95 最低的拐点,再断言拐点之后 P95 不再改善。这样即使换一台机器或换一组参数,测试仍然成立。
ADR-1.2:以 goodput 而不是吞吐作为容量门禁
- 决策:容量评审的主指标是“在 SLO 内的 goodput 比例”,吞吐只作为辅助;示例样本上选择
maxBatch=2,因为它在 100% goodput 下给出最低 P95。 - 备选一:以峰值吞吐为门禁。被否,因为 16 批的吞吐只低 2.9%,goodput 却只剩 28.33%。
- 备选二:以平均延迟为门禁。被否,因为平均值无法约束 p95,也无法表达“多少请求达标”。
- 后果:压测必须声明 SLO 与统计窗口;报告必须同时给出吞吐与 goodput,缺一不可。
小结
并发是瞬时在途数,吞吐是单位时间完成数,goodput 是单位时间内满足 SLO 的完成数。并发增加带来的吞吐增量会递减;批处理先靠摊薄固定开销提高吞吐,越过拐点后开始用 P95 换吞吐。最危险的区间不是吞吐下降,而是吞吐几乎不变、goodput 已经崩塌。
下一节把这套口径固化成可复现的压测基线:确定性 mock 推理器、三种负载模型,以及一份可以逐字段比对的报告。
阅读导航:上一节:1.1 首字、单 token 与整体延迟 · 下一节:1.3 压测基线与负载模型 。
常见错误与边界
- 只报吞吐不报 SLO。吞吐回答“能交多少”,不回答“交得及不及时”。
- goodput 不带统计窗口。窗口一换,数字就换,无法比较。
- 把连接数当并发。连接可能空闲,在途请求才是真实压力。
- 认为并发翻倍吞吐就翻倍。超过服务能力后,多出来的并发只会排队。
- 用平均延迟判断批大小。平均值会把批内长尾抹平,P95 才能看到拐点。
- 看到吞吐没降就认为批大小安全。goodput 可能已经跌掉一大半。
- 把失败请求从 goodput 分母里删掉。分母少了,比例自然好看。
- 用不同窗口分别算吞吐与延迟。两个数字不可比,还会互相矛盾。
- 在示例样本上选出的参数直接当生产结论。参数要随模型、硬件与流量复测。
- 把示例样本结果写成生产实测。数字必须有出处,来源不明的结论比没有结论更危险。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。