14.3 吞吐与并发压测:找容量上限与拐点
14.1 量单次 run 有多慢,14.2 量单次 run 有多贵,但这两个指标都是单个请求的视角。真实服务要回答的是另一个问题:同一时刻来 100 个用户,服务还扛得住吗? 单请求 200ms 的服务,在 100 并发下可能变成每个请求 5 秒、错误率过半——因为服务内部有并发上限(连接池、信号量、模型供应商的速率限制),超过它的请求只能排队。排队不会凭空增加容量,只会把延迟堆起来。 本节要做的,就是找到「排队开始压垮服务」的那个点。
本节把 ResearchCopilot 推进到「知道自己的容量上限」:用纯标准库
asyncio写一个极简压测器,对第 8 章的并发受限服务跑 4 档并发,真跑打出每档的 QPS / P50 / P95 / 错误率,明确指认拐点,并给出容量规划的余量结论。
14.3.1 单请求快 ≠ 能扛并发
一个常见的误判是:单请求 40ms,那 QPS 不就是 25 吗,并发 10 个不就是 250?错。服务的吞吐由内部并发上限决定,而不是由单请求耗时除以并发数决定。第 8 章给 ResearchCopilot 装了信号量限并发——最多同时处理 N 个请求,其余的在门外排队。这个 N 就是容量上限:
- 并发请求数 ≤ N:请求立刻被处理,QPS = N / 单请求耗时,延迟稳定。
- 并发请求数 > N:只有 N 个在跑,其余排队,QPS 封顶在 N / 单请求耗时,延迟随并发线性上升。
所以「服务能扛多少」不是一个固定数字,而是一条曲线:并发低时 QPS 随并发上升,到某个点后封顶,再往上加并发只增加延迟、不增加吞吐。这条曲线的转折点就是拐点(knee),也就是服务真正的容量上限。
14.3.2 拐点:QPS 涨不动、延迟开始飙
把上面两段合成一张图(这里用表代替),形状是这样的:
| 并发区间 | QPS | 延迟 | 状态 |
|---|---|---|---|
| 未达上限 | 随并发线性上升 | 稳定 | 有富余,加并发有效 |
| 接近上限 | 上升变缓 | 开始抬升 | 临界,余量告急 |
| 超过上限 | 封顶不再涨 | 线性飙升 | 过载,加并发只堆延迟 |
拐点的判据是**「QPS 不再涨」**:当并发从 c 加到 2c,QPS 却几乎不变,说明已经撞到天花板,多出来的并发全变成了排队。此时再提升延迟容忍度、加机器,都救不了——瓶颈是内部并发上限,不是机器。
拐点之所以重要,是因为它是容量规划的分水岭:拐点以下,服务健康;拐点以上,延迟和错误率一起失控。生产系统必须把正常负载压在拐点以下,并留出应对突发流量的余量。
还有一点要提前说清:拐点是「当前配置」的拐点,不是「这台机器」的拐点。把第 8 章的信号量上限从 8 调到 16,拐点就跟着右移——它测的是「服务在当前并发配置下能扛多少」,而不是某个物理极限。所以拐点值必须和配置绑定记录,配置一变就要重测。
14.3.3 排队论直觉:利用率趋近 100% 时延迟趋于无穷
为什么过了拐点延迟会「飙」而不是「缓慢涨」?这背后是排队论最基本的直觉:当系统利用率 ρ 趋近 100% 时,平均等待时间趋于无穷。
- ρ = 50%:队列基本不积压,延迟约等于服务时间。
- ρ = 80%:队列偶发积压,P95 明显高于均值。
- ρ = 95%:队列持续积压,轻微波动就雪崩。
- ρ → 100%:任何一点抖动都会让队列无限增长,延迟发散。
这条规律解释了一个反直觉的现象:服务在 80% 负载时看起来还好,到 90% 就突然崩——不是慢慢变差,而是过了某个临界点后非线性恶化。所以容量规划从不把服务跑到满负荷,而是把正常峰值压在 60%~70% 利用率,给抖动和突发留缓冲。这条「不要跑满」的纪律,正是拐点测量的直接用途。
14.3.4 极简压测器:纯标准库 asyncio
压测器只需要四个能力:控制并发、发固定数量的请求、计时、统计分位数。纯 asyncio 就能写,不需要任何第三方库:
import asyncio, time
async def load_test(svc, concurrency: int, total: int):
client_sem = asyncio.Semaphore(concurrency) # 客户端并发闸门
lat: list[float] = []
errors = 0
async def one():
nonlocal errors
async with client_sem:
t0 = time.perf_counter()
try:
await svc.handle()
except Exception:
errors += 1
lat.append(time.perf_counter() - t0)
t0 = time.perf_counter()
await asyncio.gather(*[one() for _ in range(total)])
wall = time.perf_counter() - t0
lat.sort()
p50 = lat[len(lat) // 2] * 1000
p95 = lat[min(len(lat) - 1, int(len(lat) * 0.95))] * 1000
return total / wall, p50, p95, errors / total
四个设计点:
- 客户端并发闸门(
client_sem):用信号量把「同时在途」的请求数卡在concurrency。这是压测器最核心的一行——没有它,gather会一次性把所有请求全发出去,测出来的不是「并发 c 的表现」而是「无限并发的表现」。 - 墙钟吞吐:
total / wall就是 QPS。用总请求数除以总耗时,而不是「单请求耗时倒数」——后者在多并发下会被排队放大。 - 分位数而非均值:
lat.sort()后取 P50/P95。均值会掩盖慢尾,而慢尾正是过载最先暴露的地方。 - 错误率:
errors / total。过载时下游超时、连接被拒,都会让错误率跳起来——这是拐点最响的信号。
用纯标准库而不是 locust / wrk 这类专业压测器,是因为这一节要教的不是工具用法,而是「压测在测什么」。四个能力都在上面这几行里,读者能看清每个数字怎么来的;理解了原理,换专业工具只是换个壳。还有两个工程细节值得补:① 预热——真实服务有连接池、JIT、缓存冷启动,正式计时前先打一小段流量预热;② 固定请求数而非固定时长——固定请求数让每档的样本量一致,分位数才可比,固定时长则会让快的那档样本多、慢的那档样本少。
被测服务用第 8 章的并发受限形状:一个 Semaphore(8) 限制同时处理数,每个请求 40ms:
WORK = 0.040
LIMIT = 8
class Service:
def __init__(self):
self.sem = asyncio.Semaphore(LIMIT)
async def handle(self):
async with self.sem: # 服务端并发上限
await asyncio.sleep(WORK + random.uniform(0, 0.008))
return "ok"
LIMIT = 8 就是这台「机器」的容量上限。压测的目的,就是在不知道这个 8 的前提下,从外部把它测出来。
14.3.5 真跑:4 档并发找拐点
跑 5 / 10 / 20 / 40 四档并发,每档 400 个请求。真跑输出(Python 3.14.6;连跑 3 次,QPS 波动 < 2%、P95 波动 < 8%,取一次代表值):
服务端并发上限 8,单请求 ~40ms,每档 400 个请求
并发 QPS P50(ms) P95(ms) 错误率
5 110.3 45.2 48.7 0.0%
10 175.7 51.5 78.9 0.0%
20 176.8 111.3 129.1 0.0%
40 175.6 223.6 233.1 0.0%
这张表把拐点指认得清清楚楚:
- 并发 5 → 10:QPS 从 110 涨到 176(+60%),延迟只从 45ms 涨到 52ms。这一段并发是有效的,服务还有富余。
- 并发 10 → 20:QPS 几乎不动(175.7 → 176.8),延迟却从 52ms 翻到 111ms。QPS 停止增长的那一刻,就是拐点。
- 并发 20 → 40:QPS 还是 176,延迟继续翻到 224ms。多出来的 20 个并发,全部变成了排队,一点吞吐都没换来。
拐点落在 8 附近(介于 5 和 10 之间)——正好等于服务内部的 LIMIT = 8,被压测器从外部精确测了出来。这就是压测的价值:你不需要读服务的源码,也能从延迟曲线的转折把它算出来。
再看理论值核对:容量上限 8、单请求 40ms,理论 QPS = 8 / 0.04 = 200。实测封顶在 176,是 200 的 88%——差的那 12% 来自事件循环的调度开销与 random.uniform 的抖动。实测略低于理论值是正常的,如果你的实测远超理论值,反而说明压测器有问题(比如并发闸门没生效)。
压测数据必须先排除噪声,再下结论。把整轮扫描独立复跑三次:
三次独立复跑(QPS / P95ms)
并发 #1 #2 #3
5 110.7/ 48.6 110.1/ 48.9 111.1/ 48.6
10 175.7/ 77.0 175.9/ 80.8 176.3/ 79.9
20 176.9/ 129.0 175.9/ 131.4 175.9/ 130.2
40 176.9/ 234.3 176.6/ 232.2 176.7/ 231.4
三次的 QPS 波动都在 1% 以内,P95 波动在 5% 以内——噪声小到足以支撑结论。所以前面那张表可以放心用单次值。反过来,如果复跑三次 QPS 差了 30%,那就不能说「拐点在 8」,只能加大样本量或固定负载时长再测。测量有噪声时,先证明噪声可控,再谈结论——这是所有性能数据的前提。
有了稳定的数据,拐点检测本身可以写成规则:QPS 增量低于某个阈值(比如 10%)的那一档,就是拐点。
拐点检测(QPS 增量 < 10% 即视为封顶)
并发 5 → 10: QPS 110.7 → 175.7 (+ 58.7%)
并发 10 → 20: QPS 175.7 → 176.9 (+ 0.7%) ← 拐点
并发 20 → 40: QPS 176.9 → 176.9 (+ -0.0%) ← 拐点
规则把「用眼睛看曲线」变成了可自动化的判断:从并发 10 往上,QPS 增量掉到 1% 以下,拐点被钉在 10 这一档之前(即容量上限 8)。生产里这套检测可以接进 13.2 的看板——当线上压测的 QPS 增量连续低于阈值,就说明该扩容了,不必等人盯图。
14.3.6 超时的陷阱:QPS 会虚高
过载时最容易踩的坑是加超时后的 QPS 假象。给每个请求加 150ms 超时,同样的四档再跑一遍:
async with asyncio.timeout(0.150): # 超过 150ms 就放弃
await svc.handle()
真跑输出:
服务端上限 8,单请求 ~40ms,请求超时 150ms
并发 QPS P50(ms) P95(ms) 错误率
5 110.4 44.9 48.9 0.0%
10 176.0 52.9 77.9 0.0%
20 176.8 111.1 130.3 0.0%
40 262.7 151.2 152.6 86.8%
看并发 40 那一行:QPS 反而涨到了 262.7,比不加超时时还高。这是假象——超时的请求在 150ms 就被放弃、提前释放了客户端槽位,于是客户端能发出更多请求。表面吞吐上升,实际成功吞吐暴跌:有效 QPS = 262.7 × (1 − 0.868) ≈ 34.7,不到拐点吞吐(176)的五分之一。
这条教训非常硬:别只看 QPS,要同时看错误率。QPS 单独一个数字可以被超时「刷」得很漂亮,但只要错误率一起看,过载立刻现形。正确做法是同时报成功 QPS(total × (1 − 错误率) / wall)和错误率,两个指标合起来才说明问题。这也解释了为什么过载时「加超时」不是优化而是止损——它把「所有请求都慢」变成「一部分请求快速失败」,让健康的那部分能继续服务。
把「成功 QPS」也算出来,两种口径并排看:
attempt_qps, p50, p95, err = await load_test(Service(), 40, 400, 0.150)
success_qps = attempt_qps * (1 - err) # 只有成功的请求才算有效吞吐
print(f"尝试 QPS {attempt_qps:.1f} 成功 QPS {success_qps:.1f} 错误率 {err*100:.1f}%")
真跑输出:
尝试 QPS 262.7 成功 QPS 34.7 错误率 86.8%
两个数字放一起,真相立刻清楚:尝试吞吐 262.7 看着比拐点还高,有效吞吐却只有 34.7——比拐点吞吐(176)还低 80%。只看第一个数字会得出「加并发还能提升吞吐」的错误结论,直接导致线上继续加压、雪崩更快。报吞吐必须报成功吞吐,这是压测报告最基本的诚实。
14.3.7 容量规划结论:留多少余量
拿到拐点,容量规划就是一道算术题。假设拐点吞吐是 QPS_knee(本例 176),规划步骤:
| 步骤 | 做法 | 本例结果 |
|---|---|---|
| 1. 定峰值 | 统计业务的日峰值 QPS | 假设 100 |
| 2. 定余量 | 峰值不超过拐点的 60%~70% | 100 / 176 = 57% ✓ |
| 3. 定扩容线 | 峰值超拐点 70% 就加实例 | 触发线 123 |
| 4. 定告警 | 延迟 P95 或错误率逼近阈值 | P95 > 150ms 告警 |
结论是:单实例安全承载 ≈ 拐点 × 0.7。本例单实例能稳稳扛 ~120 QPS;业务峰值 100,所以一个实例够用但余量偏紧,建议上两个实例做冗余(同时也防单点故障)。为什么是 0.7 而不是 0.9?回到 14.3.3 的排队论:利用率到 90% 时,一点抖动就会雪崩,而真实流量的突发性远高于压测的均匀分布。
余量留多少还有一层考虑:拐点本身会随上游变化。模型供应商限流、数据库连接池被别的服务占用、缓存命中率下降——任何一个都会把拐点压低。所以 0.7 这个系数是对「拐点会缩水」的预判,不是保守。容量规划的目标不是把机器用满,而是让服务在流量翻倍时还有反应时间。
扩容的两种方式要分清:垂直扩容(把 LIMIT 从 8 调到 16)受限于单机的 CPU / 内存 / 下游连接数,迟早撞墙;水平扩容(多起几个实例 + 负载均衡)理论上线性叠加,但有共享瓶颈——如果所有实例共用一个 Postgres 连接池,那么池子打满时加实例也没用。所以扩容前必须先确认瓶颈是不是可水平分摊的:无状态的模型调用可以分摊,有状态的数据库连接池不行。这条判断决定了该加实例还是该优化下游,而它的依据,正是压测时逐下游定位到的那个先撞墙的组件。
14.3.8 验收与真实环境
本节把「服务能扛多少」从拍脑袋变成了可测的数字:
- 极简压测器:纯
asyncio,客户端并发闸门 + 固定请求数 + QPS/P50/P95/错误率。 - 拐点:QPS 在并发 8 附近封顶(110→176 后不再涨),延迟从 45ms 涨到 224ms。
- 超时陷阱:加超时后过载档 QPS 虚高到 262,但错误率 86.8%,有效 QPS 只剩 34.7。
- 容量结论:单实例安全承载 ≈ 拐点 × 0.7,留 30% 余量应对突发与拐点缩水。
这三块拼图之间是互相印证的:14.1 的占比表告诉你瓶颈在哪个阶段,14.2 的成本表告诉你钱花在哪个环节,14.3 的拐点告诉你这个服务在过载前能接住多少——同一套标识(run_id / 阶段 / 实例)把它们串起来,一次容量问题才能从「哪个实例的 QPS 封顶」一路下钻到「哪个阶段的延迟拖的」。孤立地看任何一张表,都只能看到问题的一个侧面。
验收清单:
- 压测器有客户端并发闸门(否则测的是无限并发)
- 报告同时包含 QPS、P50、P95、错误率,且 QPS 区分为「尝试」与「成功」
- 至少跑了 4 档并发,能看出 QPS 封顶的那一档
- 有明确的容量结论:单实例承载上限 + 扩容触发线 + 告警阈值
- 压测数据复跑过至少两次,噪声在可接受范围内
一个常见的验收误区是「压到拐点就收工」。压到拐点只回答了「容量是多少」,没回答「过载会怎样」。真正完整的一次压测要一路压到失败:看错误率是怎么起来的、延迟是缓慢恶化还是断崖式崩塌、服务能不能自动恢复。知道崩点在哪,和知道容量在哪,同样重要——前者决定你的告警阈值,后者决定你的扩容线。
真实环境 / 真实模型下会怎样:本节的「服务」是 asyncio.sleep 假装的,负载是均匀的、服务时间是固定的。真实世界有三个差异。第一,真实请求的耗时方差极大——模型调用 1s 还是 5s 取决于生成长度,而方差大的服务拐点更低、过载更突然,所以真实压测必须用真实流量回放而不是均匀假负载。第二,真实瓶颈往往不在你的代码里——模型供应商的速率限制、Postgres 连接池、Redis 带宽,任何一个都可能是先撞到的上限,压测要逐个下游定位,而不是只看端到端。第三,真实压测要压到失败——只测到「还好」没用,要一路加压到错误率跳起来,才能知道真正的崩溃点在哪。此外,真实容量是持续变化的:供应商会调限流、你的流量结构会变、依赖会升级,所以拐点不是测一次就完事,而是随每次大版本发布重测——这正是 17.2 灰度发布里「新版本先扛小流量」的依据。一句话收束本章:14.1 找到了慢在哪,14.2 找到了贵在哪,14.3 找到了扛不住在哪——性能与成本这三块拼图凑齐,一个 Agent 服务才谈得上「可控」。
阅读导航:上一节:14.2 成本优化:缓存、路由、小模型降级 + 评测兜底 · 下一节:15.1 输入侧:提示注入与越权工具拦截 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。