一个 API 服务在低负载下跑得好好的。请求量涨上来,吞吐量跟着涨;再涨,吞吐量不动了,P99 开始往上爬。继续加压,超时、连接池耗尽、503 接连出现。

这时候最自然的反应是去调参数:线程数加一点,连接池开大一点,事件循环换个更快的实现。这些调整有时候确实管用,有时候只是把问题挪了个位置,还有时候会让情况更糟。

本文要回答的是一个前置问题:在动手之前,怎么知道瓶颈在哪一层。至于「参数该设多少」,那是找到瓶颈之后的事。为了让结论可检验,我在一台 4 核虚拟机上搭了一个模拟下游处理时间的服务,把连接池、并发度、worker 数、队列容量逐个拉出来单独改,记录每一次的吞吐量、延迟分位数、队列深度和错误码。文中所有数字都是这台机器上跑出来的,环境和复现脚本放在文末。

先说一个贯穿全文的结论,它由后面的实验支撑:

系统的容量由「能并行执行多少工作」乘以「每份工作多快」决定,这个数在你改代码之前就定死了。超过它的那些请求不会让系统做更多事,只会去某个队列里排队。而排队这件事,在平均响应时间这个指标上是看不出来的。

一、问题的本质:并发增加,为什么延迟反而越来越高?

先看一组实测数字。服务端配置 32 个 worker、每个任务模拟 50 毫秒的下游处理时间,客户端并发度从 10 加到 400:

客户端并发度 成功吞吐量 P50 延迟 P99 延迟 服务端记录的排队时长
10 188.5 req/s 52.5 ms 56.2 ms 0.08 ms
25 470.2 req/s 52.3 ms 59.8 ms 0.06 ms
50 622.3 req/s 91.1 ms 104.0 ms 38.1 ms
100 620.3 req/s 153.9 ms 197.5 ms 98.7 ms
200 615.2 req/s 311.6 ms 356.2 ms 253.7 ms
400 613.0 req/s 623.6 ms 665.4 ms 567.9 ms

并发度从 10 涨到 50,吞吐量涨了 3.3 倍,延迟几乎没动。从 50 涨到 400,并发度翻了 8 倍,吞吐量反而略微下降(622 掉到 613),而 P50 延迟涨了 6.8 倍。

多出来的那 350 个并发没有让系统做更多事,它们排在了同一个队列里。表里最后一列就是证据:服务端自己记录的排队时长从 38 毫秒涨到 568 毫秒,跟总延迟的增长几乎是同一个数。

这就是过载。它跟参数调得好不好没有关系,根源是请求到达的速度超过了一项固定能力。

几个容易混在一起的概念

在往下走之前,先把几个词分清楚。它们经常被当作同义词用,而混用的代价是找错方向。

概念 含义 不能据此推断什么
并发 同一时间存在多个尚未完成的任务 不代表这些任务同时执行
并行 多个任务在同一时刻真正执行 不代表吞吐量会线性增长
吞吐量 单位时间内完成的工作量 不代表每个请求都很快
响应时间 请求从发出到完成的耗时 不等于实际执行时间
在途请求 已进入系统、尚未结束的请求数 不等于正在消耗 CPU 的请求数

并发是组织工作的方式,并行是执行状态,吞吐量和延迟是系统表现。三者之间没有必然的换算关系:一个系统可以有 1000 个并发连接却只在一个核上跑,也可以有 4 个并发任务却把 4 个核都占满。

最常见的误判是把「并发高」直接读成「CPU 忙」。后面第六组实验会看到一个极端的例子:32 个 worker 全部处于忙碌状态,CPU 也没闲着,但实际有效吞吐量只有理论值的 3%。

二、从一次 API 请求看系统中的等待

分析性能问题,第一步是把请求路径画出来,标出每一处可能产生等待的位置。指标要等有了这张图再看。

图一 一次 API 请求的路径与等待点

这张图想说的是:一次请求延迟里的每一毫秒,都归属于其中某一层。而这些层各自有自己的队列、自己的容量、自己的失败方式。

一次请求的总延迟可以粗略拆成:

1
2
T_total = T_客户端连接池 + T_网络 + T_服务端准入
+ T_队列等待 + T_执行 + T_下游连接池 + T_下游处理

这个式子是个便于分析的分解模型,不是精确公式。真实系统里这些阶段会重叠,连接会复用,下游可能并行发起。但它的价值在于引导出一个具体的问题:

上面哪一项正在变大?

如果客户端连接池等待变长,去看客户端的池上限和连接建立时间;如果服务端队列深度在涨而执行时间没变,说明到达速率已经超过处理能力;如果事件循环的调度延迟在涨,去看有没有同步阻塞调用。

只看 API 的平均响应时间,这些问题一个都回答不了。平均值把所有阶段搅在一起,恰好抹掉了最需要的信息。

后面几章会依次把这里面最容易误判的几层拉出来,用实验数据说清楚它们各自是怎么失败的。

三、异步模型、线程池与连接池:它们解决的是不同问题

这三个东西经常被放在一起讨论,好像它们是同一类东西的三个选项。实际上它们管的不是一回事:事件循环管怎么调度,worker 管能同时执行多少,连接池管能同时占用多少下游资源。搞混这三个,就会出现「加了线程数但吞吐量没涨」或者「连接池开到 1000 反而更慢」这类结果。

3.1 异步 I/O 不等于请求可以无限执行

拿 Python 的 asyncio 举例。协程遇到可挂起的异步操作会让出执行权,事件循环转去处理其他就绪任务。下面这段代码创建 100 个协程:

1
2
3
4
5
6
7
8
9
10
11
12
13
import asyncio


async def handle_request(request_id: int) -> None:
await asyncio.sleep(0.1)
print(f"request {request_id} finished")


async def main() -> None:
await asyncio.gather(*(handle_request(i) for i in range(100)))


asyncio.run(main())

asyncio.sleep() 不会阻塞事件循环线程,所以这 100 个任务几乎同时开始等待,整段代码大约 0.1 秒跑完。

但要注意:这里创建的是 100 个协程任务,不是 100 个并行执行的 CPU 工作线程。它们同时在等待,不是同时在做计算。

现在把 await asyncio.sleep() 换成 time.sleep()。任务数量、逻辑、服务时间都不变,唯一差别是那个 await:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import asyncio
import time


async def bad_handler() -> str:
time.sleep(1) # 同步阻塞,占住事件循环线程
return "done"


async def main() -> None:
await asyncio.gather(bad_handler(), bad_handler(), bad_handler())


asyncio.run(main())

这三个任务会依次执行,总耗时约 3 秒。因为 time.sleep() 不让出执行权,事件循环线程被占死,其他两个协程根本没机会开始。

这个差别在单个请求上可能看不出来,放到并发场景里就是数量级的差距。我把这一条做成了对照实验:同一个服务、同样的 32 个 worker、每个任务 20 毫秒,只把 await asyncio.sleep(SERVICE_TIME) 换成 time.sleep(SERVICE_TIME):

实现 成功吞吐量 P50 延迟 P99 延迟 服务端记录的排队时长
await asyncio.sleep 1483.2 req/s 129.2 ms 147.1 ms 103.6 ms
time.sleep 48.2 req/s 4141.7 ms 4163.8 ms 1197.4 ms

图二 异步让出与同步阻塞的唯一差别是那个 await

吞吐量差 30.8 倍,延迟差 32 倍。

关键在于怎么解释这个数:32 个 worker、每个任务 20 毫秒,理论上应该是 32 ÷ 0.02 = 1600 req/s。实测 48.2,接近于 1 ÷ 0.02 = 50。

也就是说,32 个 worker 在阻塞模式下退化成了 1 个。它们名义上都在「忙碌」,实际上排着队等同一个被占住的线程。/metrics 里的活跃 worker 数全程显示 32,这个指标完全没反映出问题。

这里有个值得记住的点:「worker 都在忙」不等于「系统在有效工作」。忙碌是一个状态,有用是另一回事。后面第五章会看到同一个陷阱的另一种形式。

Python 官方文档对 asyncio 的说明在参考资料里。如果确实需要调用阻塞型函数,可以用 asyncio.to_thread() 或专用执行器把它挪到线程里去。但这只是把阻塞挪了个地方,不会让任意 CPU 密集型 Python 代码自动获得线性加速,线程池本身也有容量和调度成本。

3.2 worker 数和服务时间共同决定容量

异步和阻塞只是其中一个变量。真正决定吞吐量天花板的,是能同时执行多少份工作以及每份工作要多久。对于一个固定服务时间、固定 worker 数的模型:

1
μ ≈ N / S

其中 N 是 worker 数,S 是单个任务的服务时间。这是个理想化估算,忽略了调度开销、内存分配、锁竞争等等。

实测验证一下。服务时间固定 50 毫秒,worker 数取 2、4、8、16、32:

worker 数 理论容量 N/S 实测成功吞吐量 偏差
2 40 req/s 38.7 req/s -3.2%
4 80 req/s 77.8 req/s -2.8%
8 160 req/s 154.9 req/s -3.2%
16 320 req/s 309.0 req/s -3.4%
32 640 req/s 617.2 req/s -3.6%

图三 容量天花板由 worker 数和服务时间决定

五档全部落在理论线下方 2.8% 到 3.6%,而且偏差随 worker 数缓慢增大(调度开销随并发数增长)。线性关系成立得很干净,没有出现「加 worker 没有收益」的区间。

这张表的价值在于它给出了动手之前就能算出的天花板。如果你的服务是 32 个 worker、每次下游调用 50 毫秒,那它不可能超过 640 req/s,无论客户端并发开多大、网络多快、客户端多强。想提高这个数,只有三条路:减少 S(让下游变快)、增大 N(加 worker,前提是下游扛得住)、或者减少每个请求要做的下游调用次数。

顺便说一句,这里的 3% 偏差本身就值得留意。「理论上限」和「实测上限」之间总有一条缝,缝里是调度器和内存分配的开销。做容量估算时把这条缝留出来,比事后解释为什么差 3% 要从容得多。

3.3 线程池限制的是工作执行能力

线程池和上面的 worker 是同一回事:它决定了能同时执行多少份工作。

任务到达速率持续高于线程池处理速率时,任务会等待空闲线程。增加线程数在一种情况下确实有效:工作线程大量时间花在等 I/O 上。如果线程都在等下游响应,加线程能让更多请求同时在途。

但线程不是越多越好,多出来的线程会带来几项开销:

  • 更多的调度和上下文切换成本
  • 每个线程的栈内存,以及线程相关的内核资源
  • 更激烈的锁竞争
  • 对数据库连接、文件描述符这类下游资源的额外压力

所以线程池的合理容量要结合任务类型、单任务执行时间、CPU 核数、下游容量和实际观测来定,没有通用公式。

这里还有一个常见的误判:线程池满了不等于 CPU 饱和了。线程可能在等数据库、等远程 HTTP 响应、等一个锁。判断依据应该是线程状态分布和 CPU 使用率一起看,单看任何一个都会得出错误结论。

3.4 连接池限制的是连接资源,不是整个服务的处理能力

连接池的作用是复用连接,并控制连接资源的创建与占用。它和 worker 数是两个独立的限制。

HTTP 客户端通常会限制同时打开的连接数。达到上限后,新请求要等连接释放,或者等超过池的超时时间后失败:

  • aiohttp 的连接器配置(limit、limit_per_host)
  • HTTPX 的资源限制(limits)
  • HTTPX 的超时机制(timeout)

连接池过小会造成不必要的等待,但过大同样有问题。如果 API 有 20 个实例,每个实例配 50 个数据库连接,那么数据库上的连接上限之和是 1000。 数据库能不能高效处理 1000 个并发连接是另一个问题,而它不会因为应用层把池开大就自动解决。

这里的关键是:调连接池不能只看应用自身,要把下游的连接预算一起算进来。

后面第五章会用实验说明连接池最隐蔽的一种失败方式:池子太小的时候,服务端的队列指标是完全干净的,延迟却高得离谱。 只看服务端监控会得出「服务端很闲」的结论,而真相是请求根本没到服务端。

四、队列积压:吞吐量不变,延迟为什么还会增长?

队列是理解高并发系统最重要的一环,也是最多被忽略的一环。

到达速率低于处理能力时,系统很快就能完成工作,队列基本是空的。到达速率接近处理能力时,短暂的流量波动或下游抖动就能让请求排上队。到达速率长期高于处理能力时,如果没有限制排队数量,工作就会持续堆积,而且不会自己恢复。

第一章那张表里,并发从 50 加到 400 吞吐量没变、延迟翻了 6.8 倍,原因全在队列。

4.1 Little 定律:把在途请求、吞吐量和延迟串起来

排队论里有一个简洁而有力的关系:

1
L = λ · W
  • L:系统中的平均任务数量(在途请求数)
  • λ:长期平均完成速率;稳定状态下它等于到达速率
  • W:任务在系统中的平均停留时间

这就是 Little 定律,来自 John D. C. Little 在 1961 年的论文。它的适用条件很宽:只要系统长期稳定、不丢任务、不凭空产生任务,关系就成立,不要求到达过程是泊松分布,也不要求服务时间服从某种分布。

用第一章的实测数据验证一下。客户端并发度控制在 C,那么稳定状态下在途请求数 L 就等于 C(每个客户端任务同时只有一次请求在途)。于是可以用 W = L / λ 反推平均停留时间,再跟实测的 P50 比:

客户端并发度 实测吞吐量 L ÷ λ(Little 定律算出的停留时间) 实测 P50 延迟
10 188.5 req/s 53.1 ms 52.5 ms
25 470.2 req/s 53.2 ms 52.3 ms
50 622.3 req/s 80.3 ms 91.1 ms
100 620.3 req/s 161.2 ms 153.9 ms
200 615.2 req/s 325.1 ms 311.6 ms
400 613.0 req/s 652.5 ms 623.6 ms

六档全部吻合,偏差在 10% 以内,而且方向一致:算出来的比实测的略大,多出来的部分是客户端的连接建立、HTTP 解析这些没有计入服务端的开销。

图四 并发加到 50 之后,多出来的并发全部变成排队

这张图把两件事放在一起看。柱子是吞吐量,从并发 50 开始就不再长高了。折线是延迟,从同一点开始近似线性地往上冲。而紫色虚线是服务端自己记录的排队时长,它和红色 P50 线几乎是平行的,中间隔着一段固定距离(那一段就是 50 毫秒的服务时间本身)。

队列深度和吞吐量都不变,延迟照样能一直涨。 因为延迟涨的不是执行时间,是等待时间。

Little 定律能干什么,不能干什么

它能告诉你的:给定吞吐量和在途请求数,平均停留时间必然是多少。这三个量里知道任意两个,第三个就被确定了。上面那张表就是反过来用它做校验的。

它不能告诉你的:

  • 请求具体堵在哪个队列。 它只说「平均停留 W」,不说这 W 里有多少在客户端等连接、多少在服务端排队。要回答这个必须分阶段埋点。
  • P99 延迟是多少。 它管的是平均值,跟尾延迟没有直接关系。一个系统可以平均延迟很低而 P99 很差。
  • 系统是不是健康的。 高吞吐 + 低延迟、低吞吐 + 高延迟,都可能满足 L = λW。

所以它的定位是一个关系校验工具,不是诊断工具。当你从监控里读出三个数、发现它们不满足 L = λW 时,那是很有价值的信号,说明统计口径有问题,比如漏计了某一类请求,或者统计窗口不一致。

4.2 无界排队为什么危险

直觉上会认为,队列大一点总没坏处:排着队至少请求还在,万一后面处理快了呢。实际情况相反。下面这条链条一旦启动,会自己加速:

  1. 下游处理时间变长
  2. API 的处理速度下降
  3. 新请求继续进入系统,队列开始增长
  4. 排队中的任务持续占用内存和其他资源
  5. 请求超过客户端截止时间,客户端放弃并发起重试
  6. 重试带来额外负载,下游处理时间进一步变长

回到第 3 步。关键在于队列里的任务不是免费的。每个等待中的请求都占着内存、占着客户端的连接、最终还会被认领一次。如果它等到被认领时已经过期,那次认领就是纯浪费。

Google SRE 的《Addressing Cascading Failures》详细讨论过这类资源耗尽与级联故障的机制。

所以一个系统不仅要回答「每秒能接多少请求」,还要回答「最多允许多少请求同时在途」「最多允许等多久」。这两个问题的答案,就是第五章要说的东西。

五、背压:让系统只接收自己能够处理的工作

背压这个词常被简化成「把并发限制设小一点」,但它真正要解决的问题是:让上游能感知下游的容量,别让工作无限制地涌进来。

常见手段有这些:

  • 有界队列:限制等待中的任务数量
  • 并发准入:限制同时执行的任务数
  • 快速拒绝:容量不足时立刻返回明确错误
  • 超时与截止时间:避免过期请求继续占用资源
  • 重试退避:避免失败请求立刻再制造压力
  • 降级与负载削减:过载时砍掉非关键工作

几个容易踩的坑:

信号量不等于背压。 信号量能限制同时执行的任务数,但如果大量请求都堵在「获取信号量」这一步,等待者本身就是一个巨大的无界队列,只是换了个名字。

有界队列也不是万能的。 队列太小会增加拒绝率,太大则会让请求在队列里等到超过业务截止时间。边界要根据请求的价值、执行成本、截止时间和下游容量来定。

拒绝要快。 一个马上就要过期的请求,晚一点拒绝不如早一点拒绝,早拒绝能立刻释放客户端连接和服务端资源。

有界队列与无界队列的实测对照

这一条我做了完整对照。服务端容量 640 req/s(32 worker × 50 毫秒),用开环方式恒定每秒投放 1000 个请求,持续 12 秒,也就是稳定过载 1.56 倍。唯一变量是队列容量:无界、64、256。

队列策略 成功完成 服务端超时(504) 拒绝(503) 峰值队列深度 有效吞吐量
无界 3379 8505 0 2016 281.6 req/s
有界 64 7651 0 4350 64 637.6 req/s
有界 256 7833 0 4168 256 652.8 req/s

无界队列少完成了 56% 的有效工作,而且一个请求都没有快速失败,全部拖到服务端超时才结束。

图五 无界队列的崩溃过程

上图是三种策略的瞬时完成速率,下图是队列深度。看红色那条无界队列的线:

  • 前 5 秒:完成速率稳定在 634 req/s,贴着容量跑,队列从 0 涨到 1791
  • 第 5 秒到第 6 秒:完成速率开始下滑,第一批超时出现
  • 第 6 秒之后:完成速率掉到 0,并一直保持到第 13 秒

从第 6 秒到第 13 秒,队列里平均躺着 2000 个请求,32 个 worker 全部处于忙碌状态,而这 7 秒内成功完成的请求数是零。

原因不复杂:队列按先进先出出队,队列深度以每秒约 360 个的速度增长,所以一个请求从入队到被认领的等待时间也在持续增长,很快就超过了服务端自己设的 2 秒截止时间。等到 worker 把它取出来时,请求早就过期了,直接跳过。worker 不闲,但它在处理一堆已经死掉的任务。

这就是第三章那个结论的另一种形态:「都在忙」不等于「在做有用的事」。

绿色那条有界队列的表现完全不同:

  • 完成速率 12 秒全程稳定在 630 req/s 附近
  • 队列深度稳定在 49 到 63 之间,从来不接近失控
  • 多出来的 4350 个请求立刻拿到 503

同样的硬件、同样的过载倍数、同样的 12 秒。有界队列做了 2.26 倍的有效工作,延迟 P50 是 144 毫秒,而无界队列的 P50 是 2001 毫秒。

队列容量的选择是一个取舍

对比「有界 64」和「有界 256」这两行:256 的吞吐量略高(652.8 对 637.6),但它的延迟也更高。这不是矛盾,队列容量决定了请求最多愿意等多久。

以 640 req/s 的处理速率算,64 个位置意味着最坏等待约 100 毫秒,256 个位置意味着最坏等待约 400 毫秒。如果业务的截止时间是 200 毫秒,那么 256 这个配置会让一批请求在队列里等到超时,白白占用 worker 去认领一堆注定失败的任务。

队列容量的正确算法是从截止时间倒推,而不是从「能存多少」倒推:

1
队列容量 ≈ (截止时间 - 预期执行时间) × 处理速率

这个值不需要精确,量级对了就行。要避免的是数量级上的错配,比如一个 200 毫秒截止时间的接口配了能等 5 秒的队列。

六、构建一个可复现实验

要把上面这些结论落到自己的系统上,第一步是搭一个能重复跑的对照环境。真实数据库、网络抖动、其他进程的干扰都会让第一轮分析变成猜谜,所以先做一个模拟下游处理时间的服务:固定数量的 worker、有限队列、可配置的处理时间。

它不打算复现任何生产框架的内部实现,只用来验证排队、准入、过载保护这些基本机制。

6.1 环境

需要 Python 3.11 或更高版本,以及 aiohttp:

1
2
3
4
5
6
python -m venv .venv
# Linux / macOS
source .venv/bin/activate
# Windows PowerShell
# .venv\Scripts\Activate.ps1
python -m pip install aiohttp

两个文件:server.py 是服务端,load.py 是闭环压测端,load_open.py 是开环压测端。

服务端接受四个环境变量:

变量 含义 默认值
WORKERS 并发执行槽位数 32
QUEUE_SIZE 等待队列容量,0 表示无界 64
SERVICE_TIME 每个任务模拟的下游处理时间(秒) 0.05
REQUEST_TIMEOUT 服务端等待自己任务完成的截止时间(秒) 2.0
BLOCKING 设为 1 时用 time.sleep 替代 await asyncio.sleep 0

6.2 服务端:有限队列与固定 worker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# @Moxin1044 2026-10-15
# server.py —— 模拟下游处理时间的 API 服务:固定 worker + 有限队列
#
# 设计意图:把「等待」和「执行」分离开来计量。
# worker 从队列取出任务后,先记下排了多久(queue_wait),再模拟下游处理(service)。
# 这两个量分开,才能回答「延迟是排队排出来的,还是执行执行出来的」。
#
# 环境变量:
# WORKERS 并发执行槽位数(模拟 worker 数)
# QUEUE_SIZE 等待队列容量,写 0 表示无界
# SERVICE_TIME 每个任务模拟的下游处理时间(秒)
# REQUEST_TIMEOUT 服务端等待自己任务完成的截止时间(秒)
import asyncio
import os
import time
from collections import deque

from aiohttp import web

# BLOCKING=1 时用 time.sleep 代替 await asyncio.sleep:
# 前者会占住事件循环线程,后者会让出执行权。这是第六组实验的开关。
BLOCKING = os.getenv("BLOCKING", "0") == "1"

WORKERS = int(os.getenv("WORKERS", "32"))
QUEUE_SIZE = int(os.getenv("QUEUE_SIZE", "64"))
SERVICE_TIME = float(os.getenv("SERVICE_TIME", "0.05"))
REQUEST_TIMEOUT = float(os.getenv("REQUEST_TIMEOUT", "2.0"))
BOUNDED = QUEUE_SIZE > 0

jobs = asyncio.Queue(maxsize=QUEUE_SIZE if BOUNDED else 0)
stats = {
"accepted": 0,
"rejected": 0,
"timed_out": 0,
"completed": 0,
"expired_before_run": 0,
"active": 0,
"max_queue_depth": 0,
}
# 记录排队等待时间,用来证明「时间花在排队上还是执行上」
recent_queue_wait = deque(maxlen=20000)
recent_service = deque(maxlen=20000)


async def worker():
while True:
future, enqueued_at = await jobs.get()
stats["active"] += 1
try:
# 请求可能已经被客户端放弃或服务端判超时,就别再执行过期工作
if future.cancelled():
stats["expired_before_run"] += 1
continue

queue_wait = time.perf_counter() - enqueued_at
recent_queue_wait.append(queue_wait)

t0 = time.perf_counter()
if BLOCKING:
time.sleep(SERVICE_TIME) # 占住事件循环,其他任务全排队
else:
await asyncio.sleep(SERVICE_TIME) # 模拟下游处理,让出执行权
recent_service.append(time.perf_counter() - t0)

if not future.done():
future.set_result({
"queue_wait_ms": round(queue_wait * 1000, 3),
"service_time_ms": round(SERVICE_TIME * 1000, 3),
})
stats["completed"] += 1
finally:
stats["active"] -= 1
jobs.task_done()


async def handle(request):
loop = asyncio.get_running_loop()
future = loop.create_future()

try:
jobs.put_nowait((future, time.perf_counter()))
stats["accepted"] += 1
except asyncio.QueueFull:
stats["rejected"] += 1
hdr = {"Retry-After": "1"}
return web.json_response({"error": "overloaded"}, status=503, headers=hdr)

depth = jobs.qsize()
if depth > stats["max_queue_depth"]:
stats["max_queue_depth"] = depth

try:
result = await asyncio.wait_for(asyncio.shield(future), timeout=REQUEST_TIMEOUT)
return web.json_response(result)
except asyncio.TimeoutError:
future.cancel()
stats["timed_out"] += 1
return web.json_response({"error": "request_timeout"}, status=504)


def _pct(seq, p):
if not seq:
return None
s = sorted(seq)
return round(s[round((len(s) - 1) * p)] * 1000, 3)


async def metrics(request):
return web.json_response({
**stats,
"queue_depth": jobs.qsize(),
"queue_capacity": QUEUE_SIZE,
"bounded": BOUNDED,
"worker_count": WORKERS,
"blocking": BLOCKING,
"service_time_seconds": SERVICE_TIME,
"request_timeout_seconds": REQUEST_TIMEOUT,
# 服务端自己看到的排队/执行分布,单位毫秒
"queue_wait_p50_ms": _pct(recent_queue_wait, 0.50),
"queue_wait_p95_ms": _pct(recent_queue_wait, 0.95),
"queue_wait_p99_ms": _pct(recent_queue_wait, 0.99),
})


async def start_workers(app):
app["workers"] = [asyncio.create_task(worker()) for _ in range(WORKERS)]


async def stop_workers(app):
for t in app["workers"]:
t.cancel()
await asyncio.gather(*app["workers"], return_exceptions=True)


app = web.Application()
app.router.add_get("/work", handle)
app.router.add_get("/metrics", metrics)
app.on_startup.append(start_workers)
app.on_cleanup.append(stop_workers)

if __name__ == "__main__":
web.run_app(app, host="127.0.0.1", port=8080, print=None)

有两处设计值得说明。

worker 从队列取出任务后,先记录排队时长,再执行。 这两个量分开计量,才能回答「延迟是排队排出来的还是执行执行出来的」而不用猜。第五章那张崩溃曲线就是靠这个定位到的原因。

BLOCKING 开关。 它把 await asyncio.sleep(...) 换成 time.sleep(...),服务时间、worker 数、任务逻辑全部不变。第三章那组 30.8 倍的对照就是用它做的,同一份代码,只有一个开关不同。

启动服务:

1
python server.py

另开一个终端看当前状态:

1
curl http://127.0.0.1:8080/metrics

/metrics 返回的不只是累计计数,还有队列深度、活跃 worker 数,以及服务端自己观测到的排队时长分位数。

6.3 客户端:闭环压测端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# @Moxin1044 2026-10-15
# load.py —— 闭环压测端:固定数量的协程任务,各自循环发请求
#
# 注意这是「闭环」模型:每个任务要等上一次请求结束才发下一次。
# 过载时它会因为响应变慢而自动降低实际发出速率,
# 于是掩盖真实流量下的排队现象。要研究饱和点得用 load_open.py。
import argparse
import asyncio
import json
import time

import aiohttp


def percentile(values, p):
if not values:
return None
values = sorted(values)
return values[round((len(values) - 1) * p)]


async def fetch_metrics(session, base):
try:
async with session.get(base.rstrip("/") + "/metrics") as r:
return await r.json()
except Exception:
return {}


async def run(args):
connector = aiohttp.TCPConnector(limit=args.pool, limit_per_host=args.pool)
timeout = aiohttp.ClientTimeout(total=args.timeout)
latencies = []
statuses = {}
errors = 0
gate = asyncio.Semaphore(args.concurrency)

base = args.url.rsplit("/", 1)[0]

async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
m0 = await fetch_metrics(session, base)

async def one_request():
nonlocal errors
async with gate:
started = time.perf_counter()
try:
async with session.get(args.url) as response:
await response.read()
latencies.append(time.perf_counter() - started)
statuses[response.status] = statuses.get(response.status, 0) + 1
except Exception as exc:
errors += 1
name = type(exc).__name__
statuses[name] = statuses.get(name, 0) + 1

started = time.perf_counter()
await asyncio.gather(*(one_request() for _ in range(args.requests)))
elapsed = time.perf_counter() - started

m1 = await fetch_metrics(session, base)

successful = sum(c for s, c in statuses.items() if isinstance(s, int) and 200 <= s < 300)
out = {
"wall_time_s": round(elapsed, 3),
"requests": args.requests,
"concurrency": args.concurrency,
"client_pool_limit": args.pool,
"successful_responses": successful,
"errors_or_exceptions": errors,
"statuses": {str(k): v for k, v in statuses.items()},
"successful_rps": round(successful / elapsed, 2) if elapsed else None,
"p50_ms": round(percentile(latencies, 0.50) * 1000, 2) if latencies else None,
"p95_ms": round(percentile(latencies, 0.95) * 1000, 2) if latencies else None,
"p99_ms": round(percentile(latencies, 0.99) * 1000, 2) if latencies else None,
"max_ms": round(max(latencies) * 1000, 2) if latencies else None,
# 服务端侧
"srv_accepted": m1.get("accepted", 0) - m0.get("accepted", 0),
"srv_rejected": m1.get("rejected", 0) - m0.get("rejected", 0),
"srv_timed_out": m1.get("timed_out", 0) - m0.get("timed_out", 0),
"srv_completed": m1.get("completed", 0) - m0.get("completed", 0),
"srv_expired_before_run": m1.get("expired_before_run", 0) - m0.get("expired_before_run", 0),
"srv_max_queue_depth": m1.get("max_queue_depth", 0),
"srv_queue_depth_end": m1.get("queue_depth", 0),
"srv_queue_wait_p50_ms": m1.get("queue_wait_p50_ms"),
"srv_queue_wait_p95_ms": m1.get("queue_wait_p95_ms"),
"srv_queue_wait_p99_ms": m1.get("queue_wait_p99_ms"),
}
print(json.dumps(out, ensure_ascii=False))


def main():
p = argparse.ArgumentParser()
p.add_argument("--url", default="http://127.0.0.1:8080/work")
p.add_argument("--requests", type=int, default=2000)
p.add_argument("--concurrency", type=int, default=100)
p.add_argument("--pool", type=int, default=100)
p.add_argument("--timeout", type=float, default=5.0)
args = p.parse_args()
asyncio.run(run(args))


if __name__ == "__main__":
main()

这个客户端是闭环的:固定数量的协程任务,每个任务等上一次请求结束才发下一次。它适合看「给定并发度下系统表现如何」,但有一个重要缺陷,第八章会用实验说明。

6.4 客户端:开环压测端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# @Moxin1044 2026-10-15
# load_open.py —— 开环压测端:按恒定速率投放请求,不等待上一次的响应
#
# 与 load.py 的关键差别:
# load.py 固定并发数,响应慢下来它就自动少发 —— 过载时反而给系统减负
# load_open 固定到达速率,响应再慢也照发 —— 才能看到真实的排队和积压
#
# 用法:
# python load_open.py --rate 700 --duration 8
# python load_open.py --rate 700 --duration 8 --max-inflight 200
import argparse
import asyncio
import json
import time

import aiohttp


def percentile(values, p):
if not values:
return None
values = sorted(values)
return values[round((len(values) - 1) * p)]


async def fetch_metrics(session, base):
try:
async with session.get(base.rstrip("/") + "/metrics") as r:
return await r.json()
except Exception:
return {}


async def run(args):
connector = aiohttp.TCPConnector(limit=args.pool, limit_per_host=args.pool)
timeout = aiohttp.ClientTimeout(total=args.timeout)
base = args.url.rsplit("/", 1)[0]

latencies = []
statuses = {}
errors = 0
dropped_by_backpressure = 0
inflight = 0
max_inflight = 0
sent = 0
pending = set()

sem = asyncio.Semaphore(args.max_inflight) if args.max_inflight > 0 else None

async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
m0 = await fetch_metrics(session, base)

async def one_request():
nonlocal errors, inflight, max_inflight
inflight += 1
max_inflight = max(max_inflight, inflight)
started = time.perf_counter()
try:
async with session.get(args.url) as response:
await response.read()
latencies.append(time.perf_counter() - started)
statuses[response.status] = statuses.get(response.status, 0) + 1
except Exception as exc:
errors += 1
name = type(exc).__name__
statuses[name] = statuses.get(name, 0) + 1
finally:
inflight -= 1

async def guarded():
# 开环 + 在途上限:到顶就不再投放。这是背压的一种,
# 不设的话压测端自己会先被内存和连接数拖垮。
nonlocal dropped_by_backpressure, sent
if sem and sem.locked():
dropped_by_backpressure += 1
return
sent += 1
task = asyncio.create_task(one_request())
pending.add(task)
task.add_done_callback(pending.discard)
if sem:
await sem.acquire()
task.add_done_callback(lambda _t: sem.release())

interval = 1.0 / args.rate
t_end = time.perf_counter() + args.duration
t_next = time.perf_counter()
while time.perf_counter() < t_end:
now = time.perf_counter()
if now < t_next:
await asyncio.sleep(t_next - now)
t_next += interval
await guarded()

if pending:
await asyncio.gather(*pending, return_exceptions=True)

m1 = await fetch_metrics(session, base)

elapsed = args.duration
successful = sum(c for s, c in statuses.items() if isinstance(s, int) and 200 <= s < 300)
out = {
"rate_target": args.rate,
"duration_s": args.duration,
"sent": sent,
"sent_rate_actual": round(sent / elapsed, 1),
"dropped_by_backpressure": dropped_by_backpressure,
"max_inflight": max_inflight,
"successful_responses": successful,
"successful_rps": round(successful / elapsed, 2),
"errors_or_exceptions": errors,
"statuses": {str(k): v for k, v in statuses.items()},
"p50_ms": round(percentile(latencies, 0.50) * 1000, 2) if latencies else None,
"p95_ms": round(percentile(latencies, 0.95) * 1000, 2) if latencies else None,
"p99_ms": round(percentile(latencies, 0.99) * 1000, 2) if latencies else None,
"srv_accepted": m1.get("accepted", 0) - m0.get("accepted", 0),
"srv_rejected": m1.get("rejected", 0) - m0.get("rejected", 0),
"srv_timed_out": m1.get("timed_out", 0) - m0.get("timed_out", 0),
"srv_completed": m1.get("completed", 0) - m0.get("completed", 0),
"srv_max_queue_depth": m1.get("max_queue_depth", 0),
"srv_queue_depth_end": m1.get("queue_depth", 0),
"srv_queue_wait_p50_ms": m1.get("queue_wait_p50_ms"),
"srv_queue_wait_p99_ms": m1.get("queue_wait_p99_ms"),
}
print(json.dumps(out, ensure_ascii=False))


def main():
p = argparse.ArgumentParser()
p.add_argument("--url", default="http://127.0.0.1:8080/work")
p.add_argument("--rate", type=float, default=600)
p.add_argument("--duration", type=float, default=8)
p.add_argument("--pool", type=int, default=2000)
p.add_argument("--timeout", type=float, default=5.0)
p.add_argument("--max-inflight", type=int, default=0, help="0 表示不限制")
args = p.parse_args()
asyncio.run(run(args))


if __name__ == "__main__":
main()

6.5 怎么跑

一次基线测试:

1
python load.py --requests 2000 --concurrency 50 --pool 50

逐步加客户端并发度:

1
2
3
python load.py --requests 5000 --concurrency 100 --pool 100
python load.py --requests 5000 --concurrency 200 --pool 200
python load.py --requests 5000 --concurrency 400 --pool 400

固定并发度 200,单独改连接池上限:

1
2
3
python load.py --requests 5000 --concurrency 200 --pool 20
python load.py --requests 5000 --concurrency 200 --pool 50
python load.py --requests 5000 --concurrency 200 --pool 200

开环方式:

1
python load_open.py --rate 1000 --duration 10

这些命令只定义实验配置,不预设结果。跑之前建议先做一件事:标定压测端自己的天花板。把服务时间压到接近 0,看客户端能跑到多少 req/s。如果这个数跟被测系统的容量接近,那测出来的就是压测端的能力,不是系统的能力。

本文的实验里,压测端在并发 200 时能到约 6750 req/s。所有结论都落在这条线以下,最接近的是第三章那组 1483 req/s,留了 4 倍余量。

6.6 每次测试至少要记录什么

指标 来源 分析目的
成功 RPS 压测端 实际完成的有效工作量
P50 / P95 / P99 压测端 区分典型请求与尾部请求
HTTP 503 数量 压测端 服务端主动拒绝的情况
HTTP 504 数量 压测端 服务端等待超时的情况
队列深度 服务端 /metrics 任务是否在积压
活跃 worker 数 服务端 /metrics 执行槽位是否持续被占用
服务端排队时长分位数 服务端 /metrics 延迟里有多少是排队
CPU、内存 操作系统 压测端与服务端谁先到资源边界

两个容易漏掉的:

失败要分类。 503(主动拒绝)和 504(等到超时)是完全不同的信号,混在一起算「错误率」会把第五章那个结论完全掩盖掉,有界队列的失败是可控的,无界队列的失败是失控的,但它们的「错误率」看起来差不多。

压测端和服务端要分别看资源。 同机压测时二者互相争抢,尤其是 CPU。本文的做法是用 taskset 把服务端钉在 0-1 核、压测端钉在 2-3 核,尽量隔离。即便如此,同机压测的干扰也无法完全消除,这一点在附录里会说明。

七、性能曲线应该怎么读?

真正有价值的压测结果是负载、吞吐量、延迟、错误率四者之间的关系,一个最高 QPS 数字说明不了什么。至少要画三张图:

  1. 负载—吞吐量曲线:横轴是并发度或目标速率,纵轴是成功 RPS
  2. 负载—延迟曲线:同一横轴下画 P50、P95、P99
  3. 负载—错误率曲线:区分服务端拒绝、服务端超时、客户端超时和其他异常

第一章那张表和第四章那张图合起来就是前两张。形态上可以分成三段:

低负载段:资源有余量,吞吐量随负载线性上升,延迟基本不变(就是服务时间加一点固定开销)。这一段加负载是划算的。

容量拐点附近:吞吐量增长开始变慢,延迟对负载变得敏感。实测里这个拐点出现在并发 50 附近,对应容量 640 req/s。

过载段:吞吐量封顶甚至轻微下降,延迟线性上升,错误开始出现。这一段加负载纯粹是自伤。

但真实曲线比这复杂

上面是理想形态。实际系统的曲线会被这些东西影响:

  • 缓存命中率随负载变化。 负载高时缓存被挤占,命中率下降,服务时间变长,容量跟着降。
  • JIT 预热和垃圾回收。 冷启动前几十秒的数据不能代表稳态;GC 停顿会在延迟曲线上制造尖峰。
  • CPU 限频。 高负载下 CPU 频率可能下降,容量随负载降低,出现「越忙越慢」的负反馈。
  • 请求分布变化。 压测用的均匀请求和生产的重尾分布完全不是一回事。
  • 下游波动。 下游不是恒定的,它的容量也在变。

所以不同工作负载可能出现多个不同的饱和点,一个系统在一种请求混合下 600 req/s 就饱和,换一种可能 1200 req/s 还游刃有余。不能只看一条曲线就断定「CPU 是瓶颈」或「连接池是瓶颈」。

闭环压测会掩盖真实的过载

这一条值得单独说,因为它是压测方法本身的问题,不是被测系统的问题。

load.py 是闭环的:固定并发数 C,每个任务等上一次请求结束才发下一次。它实际施加的到达速率是 C / 平均延迟,跟设定的并发数 C 完全是两回事。延迟一涨,它自己就减速了。

看一组对照。服务端配置 8 个 worker、50 毫秒服务时间,容量 160 req/s。两种压测方式,负载都远远超过容量:

压测方式 服务端实际收到 成功 504 超时 结论看起来像
闭环,并发 400 800 个(10 秒内) 313 487 系统在 80 req/s 就崩了
开环,恒定 1000/s 9870 个 368 9502 系统在 1000 req/s 的冲击下崩了

两种方式都把系统打垮了,但闭环压测低报了 12 倍的负载。它本意是施加 400 并发的压力,实际只把 800 个请求送到了服务端,因为响应太慢,客户端自己在等。

如果只用闭环压测来定容量,你会得出「这个系统在 80 req/s 的到达速率下就会崩溃」,从而去做一些完全没必要的优化。真相是它在 987 req/s 的持续冲击下崩溃。

结论:想研究饱和点和突发流量,必须用恒定速率的开环压测。 闭环压测适合回答「给定并发度下的延迟是多少」,不适合回答「系统能扛多少流量」。

开环压测自己要小心一件事:它会在过载时无限堆积在途请求,把压测机先拖垮。所以 load_open.py 有一个 --max-inflight 参数限制在途上限,这本身就是一种背压,不设的话压测端的内存和文件描述符会先耗尽。

八、故障边界:优化单个资源,为什么可能让整体更差?

前面几章都在讲「瓶颈在哪」。这一章讲另一件更容易踩的事:你以为的优化,可能只是把压力挪到了别处。

8.1 连接池调小,时间从服务端转移到了客户端

这是一组很能说明问题的实测。服务端容量 640 req/s(32 worker × 50 毫秒),客户端并发度固定 200,只改连接池上限:

连接池上限 成功吞吐量 P50 总延迟 其中:服务端排队 其中:服务时间 其中:客户端等连接
5 94.0 req/s 2164.4 ms 0.09 ms 50 ms 2114 ms
20 378.1 req/s 588.1 ms 0.06 ms 50 ms 538 ms
50 622.1 req/s 449.6 ms 38.1 ms 50 ms 362 ms
200 618.9 req/s 308.8 ms 253.7 ms 50 ms 5 ms

图六 连接池调小,时间从服务端转移到了客户端

看第一行和最后一行。

连接池 = 5 时,服务端记录的排队时长是 0.09 毫秒。服务端一点都不忙,队列是空的,worker 大部分时间闲着。但客户端测到的 P50 是 2164 毫秒,吞吐量只有 94 req/s,不到容量的 15%。

时间全部花在了「客户端等一个空闲连接」上。因为只有 5 个连接,200 个并发任务里同时最多只有 5 个能到达服务端。

连接池 = 200 时反过来:服务端排队时长 253.7 毫秒,客户端等连接只有 5 毫秒。瓶颈回到了服务端。

这两行合起来说明一件事:如果只看服务端的队列指标,连接池 = 5 那个配置看起来是最健康的(队列空、worker 闲)。而它实际上是表现最差的配置。

排查这类问题的方法很直接:把总延迟拆成「客户端等待 + 服务端排队 + 服务执行」三段,看哪一段占大头。服务端排队时长要自己埋点才算得出来,这就是 6.2 节那个设计的原因。

顺带一提,连接池 = 50 的吞吐量(622.1)已经和连接池 = 200(618.9)持平,说明 50 个连接就够喂满这个服务端了。池子再开大没有收益,只是让请求更多地堆在服务端队列里,延迟反而更低一点(因为服务端排队比客户端等连接更「高效」)。这也是为什么「加大连接池」经常看起来有效,它确实降低了延迟,但降的是等待的位置,不是等待的总量。

8.2 连接池扩容会把压力转移给数据库

上一节的另一端:如果下游数据库的处理能力没有提升,扩大连接池只是允许更多请求同时到达数据库。

数据库内部的锁竞争、内部排队、CPU 饱和都会因此加剧。表现是数据库的整体吞吐量下降,而每个应用实例都觉得自己没做错什么。

连接池应该和数据库容量、请求执行时间、事务持续时间以及全局连接预算一起设计。汇总是关键:10 个实例各配 100 个连接,数据库面对的是 1000 个并发连接,这个数不会因为每个实例「只配了 100」就变小。

8.3 增加线程可能加剧资源竞争

线程池扩容能增加阻塞型任务的并发执行能力,但同时也增加内存开销、调度成本和下游压力。

如果所有线程都在等一个已经过载的下游,增加线程不会解决问题,它只会让更多请求同时压在那个下游上。第三章那个 time.sleep 的实验是这条的极端形式:32 个 worker 全部在等同一个被占住的资源,增加 worker 数完全没有收益。

8.4 超时不意味着工作一定停止

客户端超时只代表客户端不再愿意等。它不代表:

  • 服务端停止处理这个请求
  • 下游数据库查询被取消
  • 已经占用的内存和连接被释放

这几件事需要分别设计。要区分清楚的至少有五层:

  • 客户端超时
  • 服务端处理截止时间
  • 下游调用超时
  • 请求取消的传播(客户端断开后,服务端知不知道)
  • 取消之后资源是否真的释放

最容易出问题的是最后两层。第五章那个无界队列的实验就是典型:客户端早已放弃,服务端仍在为一批过期请求消耗 worker。所以 6.2 节的实现里,worker 取出任务后先检查 future.cancelled(),已经过期的直接跳过,不再执行。

8.5 重试可能放大故障

下游过载时,如果所有调用方都立刻重试,系统会收到额外的请求流。重试要有明确的截止时间、退避策略、随机抖动和重试预算。

这里有一个反直觉但成立的原则:对于已经确定无法及时完成的请求,尽早失败比继续消耗资源更有利于整体恢复。 第五章的实测数据支持这一点,有界队列立刻拒绝掉 4350 个请求,反而把有效吞吐量保住了;无界队列让 8505 个请求缓缓超时,把有效吞吐量拉低了一半以上。

8.6 单机背压不等于全链路背压

服务端的有界队列保护的是当前这个进程。如果反向代理、消息队列、调用方或者中间的其他服务仍在无限制地积压工作,整个链路的等待总量只是换了个地方存放。

所以要沿着完整请求路径检查每一个队列的容量、超时、拒绝策略和资源释放行为。一条链路上任何一个无界队列都足以让整条链路的背压失效,因为上游会认为「我发出去了」,而实际上工作堆在了下游某个看不见的缓冲里。

九、如何把实验扩展到真实生产技术栈

模拟实验验证的是基本机制。把结论用到真实服务上,还需要在这四个方向上补实验。

连接池实验

用真实的 HTTP 客户端或数据库驱动,分别记录这几项而不是只想看总耗时:

  • 连接获取等待时间(从请求发起到拿到连接)
  • 连接建立时间(含 TCP 握手、TLS)
  • 下游处理时间
  • 总请求延迟

把整个请求耗时直接当成「连接池等待时间」是最常见的错误。第八章那张表说明了为什么,同一个 2164 毫秒的延迟里,客户端等连接和服务端排队是两件完全不同的事,处理方向也相反。

线程池实验

构造两种可控负载,分别改线程数:

  • 阻塞 I/O 型(大量等待下游)
  • CPU 密集型(几乎不等待)

观察线程活跃状态、CPU 使用率、上下文切换次数、队列等待时长和延迟分布。这两种负载的最优线程数完全不同,CPU 密集型的合理值通常在核数附近,阻塞型的可以高很多。同一套参数套用到两种负载上必然有一边是错的。

事件循环实验

对照正常异步 I/O 和「事件循环里混进了同步阻塞调用」两种情况,记录事件循环调度延迟和请求尾延迟。

第三章那组实验用的是极端形式(整个处理过程都阻塞)。真实系统里更常见的是局部阻塞:一个同步的文件读写、一次没走线程池的 DNS 解析、一个用了同步库的加密操作。这类问题的特征是尾延迟突然变差而平均延迟没变,因为只有在恰好的时机才会撞上。

做这个实验前要先确认测试环境本身不是瓶颈:客户端和服务端同机、客户端先跑满 CPU,都会让结论失去意义。

背压实验

在相同负载下比较四种策略,观察过载期间的在途请求数、队列深度、内存、成功吞吐量和恢复时间:

  • 无界等待
  • 有界队列
  • 并发准入(信号量)
  • 快速拒绝

恢复时间这一项经常被忽略,但它很重要。第五章那张崩溃曲线里,无界队列在负载停止后还要花几秒清理积压的 2000 个过期任务,这段时间系统对外仍然不可用。有界队列没有任何积压要清理,负载一停就能立刻恢复。

所有实验的共同要求

  • 先预热,再进入固定观测窗口
  • 保留原始数据、配置、代码版本和环境信息
  • 每种配置重复多次,记录均值、分位数和波动范围
  • 一次只改一个主要变量

最后一条听起来是废话,但实践中经常被违反。把并发度和连接池同时调大,然后看到吞吐量上升,你无法知道是哪一个起了作用,也无法知道如果只调其中一个会怎样。

十、结论:真正的瓶颈是系统无法及时完成的那部分工作

回到开头那个问题:高并发 API 服务的瓶颈究竟在哪里。

这次实验给出的答案落在一个结构上:

1
容量 = 能并行执行的工作数 ÷ 每份工作的耗时

这个数在改代码之前就定死了。实测里五档 worker 数的吞吐量全部落在这个公式上下 4% 以内。超过容量的请求不会让系统做得更多,它们只会去某个队列里排队,而排队这件事在平均响应时间上是看不出来的。

各组件的关系可以用一句话概括:

组件 它决定什么 它不决定什么
事件循环 任务如何被调度 能同时执行多少
worker / 线程池 能同时执行多少份工作 下游能承受多少
连接池 能同时占用多少下游连接 服务端的处理能力
队列 暂时处理不完的工作放在哪 系统能不能恢复
背压 超出能力时如何拒绝 系统能处理多少

没有哪个组件能脱离其他组件单独优化,第八章那张表就是一个例子:把连接池从 200 调到 5,服务端队列指标变得最干净,整体表现却最差。

具体的工程方法可以落成六步:

  1. 画出请求路径,标出每一处可能发生等待的位置
  2. 建立可证伪的假设,明确「哪项指标支持这个判断、哪项指标否定它」
  3. 控制变量做实验,一次只改一个主要参数
  4. 同时看吞吐量、尾延迟、队列深度和错误分类,不要只追最高 QPS
  5. 限制过载时的在途工作量,用有界队列、截止时间、快速拒绝和重试预算保护系统
  6. 保留可复现证据,把配置、原始数据、脚本和分析结果纳入版本管理

最后要回答的问题不是「连接池该设成多少」或者「线程池该有多少线程」,而是:

在给定的资源预算、请求成本和下游能力下,这个系统能持续完成多少有效工作?当需求超过这条线时,它能不能控制排队、及时拒绝过期工作,并在负载恢复后迅速回到稳定状态?

第七章那个闭环与开环的对照,最后一列「恢复时间」的缺失,都是这个问题的一部分。而它们都比「最高能跑多少 QPS」更接近真实的容量边界。


附录 A:实验环境

所有数据来自这台机器:

1
2
3
4
5
6
7
8
9
10
操作系统        Arch Linux
内核 Linux 7.2.7-arch1-1
虚拟化 KVM(QEMU)
CPU Intel Core i5-4590T @ 2.00GHz,4 核
内存 5.8 GiB
磁盘 98 GB,ext4
Python 3.14.7
aiohttp 3.14.5
网络 本机回环,实测约 3.1 GB/s
文件描述符上限 4096

进程绑核:用 taskset 把服务端钉在 0-1 号核、压测端钉在 2-3 号核,减少同机争抢。

压测端天花板标定:把服务端处理时间压到 0.1 毫秒后,压测端在并发 200 时达到约 6750 req/s。本文所有结论都在这条线以下,最接近的一组是 1483 req/s。

必须跟着结论一起讲的限制

同机压测的干扰无法完全消除。 服务端和压测端共享同一台 4 核机器,即使做了绑核,L2/L3 缓存、内存带宽、中断处理仍然是共享的。表里的绝对数字含有这部分噪声,但所有对照实验的两个对比组在同样的干扰下运行,所以结论的方向是可靠的,绝对数值只在这个环境下成立。

关键配置的重复性。 第二章那张并发度曲线的每一档都跑了两遍,两遍的成功吞吐量差异小于 0.5%(例如并发 400 档:612.1 与 613.9;并发 100 档:620.4 与 620.2)。延迟分位数的差异在 1% 以内。表中填的是两次的均值。

无界队列实验的一次性。 第五章那张崩溃曲线只跑了一次。它展示的是一个动态过程,重复跑时间点上会有偏移(比如速率掉到零的确切秒数可能是 6.1 秒也可能是 6.5 秒),但形态和量级是稳定的。

没有做 CPU 绑频和关闭其他服务。 实验期间机器上仍有其他常驻进程,没有做 CPU 隔离或实时优先级设置。这会让绝对吞吐量比空载机器略低。

附录 B:复现步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 1. 环境
mkdir concbench && cd concbench
python -m venv .venv && source .venv/bin/activate
python -m pip install aiohttp

# 2. 把 6.2 到 6.4 节的三个源文件存成 server.py / load.py / load_open.py

# 3. 标定压测端天花板(这一步别跳过)
WORKERS=64 QUEUE_SIZE=0 SERVICE_TIME=0.0001 python server.py &
python load.py --requests 8000 --concurrency 200 --pool 200

# 4. 容量天花板:改 worker 数看吞吐量是否等于 N/S
WORKERS=32 QUEUE_SIZE=0 SERVICE_TIME=0.05 python server.py &
python load.py --requests 3200 --concurrency 200 --pool 200
curl -s http://127.0.0.1:8080/metrics # 看服务端记录的排队时长

# 5. 加并发:固定容量,只改 concurrency
python load.py --requests 3200 --concurrency 10 --pool 10
python load.py --requests 3200 --concurrency 50 --pool 50
python load.py --requests 3200 --concurrency 400 --pool 400

# 6. 连接池:固定并发 200,只改 pool
python load.py --requests 3200 --concurrency 200 --pool 5
python load.py --requests 3200 --concurrency 200 --pool 200

# 7. 背压:过载下比较有界与无界
WORKERS=32 QUEUE_SIZE=0 SERVICE_TIME=0.05 REQUEST_TIMEOUT=2.0 python server.py &
python load_open.py --rate 1000 --duration 12 --max-inflight 2000
WORKERS=32 QUEUE_SIZE=64 SERVICE_TIME=0.05 REQUEST_TIMEOUT=2.0 python server.py &
python load_open.py --rate 1000 --duration 12 --max-inflight 2000

# 8. 事件循环:同一个服务,只改 BLOCKING
WORKERS=32 QUEUE_SIZE=0 SERVICE_TIME=0.02 BLOCKING=0 python server.py &
python load.py --requests 8000 --concurrency 200 --pool 200
WORKERS=32 QUEUE_SIZE=0 SERVICE_TIME=0.02 BLOCKING=1 python server.py &
python load.py --requests 8000 --concurrency 200 --pool 200

复现时最值得自己动手改的三处:把 SERVICE_TIME 换成你真实下游的 P50 时延,看容量公式算出来的上限跟你的监控对不对得上;把 QUEUE_SIZE 设成你线上队列的值,跟 64 和 256 对比一下;给你的服务加上「服务端排队时长」这个埋点,本文好几处结论都依赖它,而绝大多数服务默认没有这个指标。

附录 C:完整实验数据

容量天花板(服务时间 50 毫秒,队列无界,闭环并发 200):

worker 理论 N/S 实测吞吐量 偏差 P50 P99
2 40 38.7 -3.2% 2592.0 ms 5028.8 ms
4 80 77.8 -2.8% 2539.9 ms 2550.4 ms
8 160 154.9 -3.2% 1276.4 ms 1286.3 ms
16 320 309.0 -3.4% 621.9 ms 666.6 ms
32 640 617.2 -3.6% 309.3 ms 354.5 ms

worker = 2 那档的 P99 超过了客户端 5 秒超时,所以有 4 个请求记为 504。

连接池对照(worker 32,服务时间 50 毫秒,并发固定 200):

连接池 吞吐量 P50 P99 服务端排队 P50
5 94.0 2164.4 ms 2234.7 ms 0.09 ms
20 378.1 588.1 ms 683.8 ms 0.06 ms
50 622.1 449.6 ms 508.8 ms 38.1 ms
200 618.9 308.8 ms 354.3 ms 253.7 ms

事件循环对照(worker 32,服务时间 20 毫秒,并发 200):

实现 吞吐量 P50 P99 服务端排队 P50
await asyncio.sleep 1483.2 129.2 ms 147.1 ms 103.6 ms
time.sleep 48.2 4141.7 ms 4163.8 ms 1197.4 ms

参考资料

  1. Python 官方文档:asyncio — Asynchronous I/O:事件循环与协程的正式语义,asyncio.to_thread() 的适用场景。

  2. aiohttp 官方文档:Client Connectors:limit 与 limit_per_host 的确切含义,以及连接获取的排队行为。

  3. HTTPX 官方文档:Resource Limits:连接池上限的配置方式。

  4. HTTPX 官方文档:Timeouts:超时分层(连接、读写、池、总时长)的设计。

  5. John D. C. Little, A Proof for the Queuing Formula: L = λW, Operations Research, 1961:Little 定律的原始证明。

  6. Google SRE Book:Handling Overload:过载识别与负载削减策略。

  7. Google SRE Book:Addressing Cascading Failures:资源耗尽、长等待、超时与级联故障的关系。

  8. Linux Kernel Documentation: Scaling in the Linux Networking Stack:内核视角下高连接数时代的扩展问题。

本文所有数据均为本机实测,三个实验脚本的源码在第六章给出,可自行编译复现。