WARNING urllib3.connectionpool:
Connection pool is full, discarding connection: proxy.example.com
Connection pool size: 10
看到这条日志时,请求不一定失败,代理 IP 也不一定失效。urllib3 表达的是:本次使用的连接超过了当前连接池可保留的数量,请求结束后,这条额外连接没有进入池中复用。若日志持续出现,程序会反复建立和关闭连接,代理认证、TCP 及 TLS 握手的开销随之增加。
这篇文章只解决客户端连接池问题:连接如何取得、复用和归还,Requests 与 aiohttp 的参数怎样设置,以及如何从日志判断池是否太小。业务需要多少并发,应先由任务耗时和成功 QPS 确定;连接池负责承接这个并发,不负责替代并发计算。
一、一次代理请求在客户端经历什么
一个 URL 进入程序后,通常会经过下面几个环节:
- 任务在队列中等待线程、协程或 Worker 调度。
- 并发控制器决定它能否进入网络阶段。
- 客户端从对应连接池取得空闲连接,或建立新连接。
- 请求经代理网关转发到目标服务。
- 响应被读取或关闭后,符合复用条件的连接回到池中。
连接池卡住发生在第三步;连接泄漏则常常源于第五步。任务队列里有多少 URL、程序允许多少任务同时运行、连接池保存多少连接,是三个不同的数。队列可以存放 10,000 个任务,但如果网络并发限制为 20,同一时刻通常只需围绕这 20 个在途请求安排连接容量。
代理链路还包含“客户端到代理”和“代理到目标”两段连接。根据 MDN 的 HTTP/1.x 连接管理说明,Connection、Keep-Alive等控制属于逐跳机制。因此,客户端复用到代理网关的连接,并不能单独证明代理到目标服务也使用相同的连接或出口地址。
二、Requests 的三个关键连接池参数
Requests 通过 HTTPAdapter管理底层连接池。Requests API 文档中最需要分清的是下面三个参数:
| 参数 | 控制对象 | 应该怎样理解 |
|---|---|---|
pool_connections |
客户端缓存的连接池数量 | 它影响可保留多少组连接池,不代表所有连接的总数 |
pool_maxsize |
每个连接池可保存的连接数 | 配合默认的 pool_block=False时,它通常不是严格并发上限 |
pool_block |
池内没有空闲连接时的处理方式 | False允许创建池外临时连接;True让请求等待已有连接归还 |
urllib3 的连接池实现显示,在非阻塞模式下,即使连接数已经超过 maxsize,客户端仍可创建额外连接;只是用完后不会把它保存在已满的池中。因此,pool_maxsize=10和“最多只能同时发出 10 个请求”并不是同一件事。
运行示例前,请把环境变量 IPWEB_TEST_URL设置为自有系统、测试环境或已经获得授权的目标地址。
import os
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
PROXY_URL = os.environ["IPWEB_PROXY_URL"]
TARGET_URL = os.environ["IPWEB_TEST_URL"]
def build_session():
retry = Retry(
total=3,
connect=2,
read=1,
status=2,
backoff_factor=0.5,
status_forcelist=(429, 502, 503, 504),
allowed_methods=frozenset({"GET", "HEAD", "OPTIONS"}),
respect_retry_after_header=True,
)
adapter = HTTPAdapter(
pool_connections=10,
pool_maxsize=20,
pool_block=True,
max_retries=retry,
)
session = requests.Session()
session.mount("http://", adapter)
session.mount("https://", adapter)
session.proxies.update({
"http": PROXY_URL,
"https": PROXY_URL,
})
return session
with build_session() as session:
with session.get(
TARGET_URL,
timeout=(5, 20),
) as response:
response.raise_for_status()
body = response.content
print(response.status_code, len(body))
示例将 pool_connections设为 10,是为了缓存多组连接池;将 pool_maxsize设为 20,是为了让单个高频路由最多保留 20 条可复用连接。两个参数解决的问题不同,不需要写成相同数值。max_retries也不属于池容量,这里只为可安全重放的方法配置有限重试。
还有一个容易被忽略的边界:Requests 的 timeout=(connect, read)只配置连接和读取超时。当 pool_block=True且所有连接都被占用时,请求等待池内连接的时间,并不会自动等同于 ConnectTimeout。如果业务必须限制任务从开始到结束的总时长,需要在任务调度或执行器一层另设总截止时间,并验证同步请求被取消后的行为,不能只依赖这组 timeout参数。
三、连接没有归还时会发生什么
池大小合理,连接仍可能被慢慢占满。常见原因是启用了 stream=True,却没有完整读取响应体,也没有显式关闭响应。Requests 高级用法文档说明,流式响应只有在内容被消费或响应被关闭后,连接才会释放回池中。
import os
LARGE_FILE_URL = os.environ["IPWEB_LARGE_FILE_TEST_URL"]
with session.get(
LARGE_FILE_URL,
stream=True,
timeout=(5, 20),
) as response:
response.raise_for_status()
for chunk in response.iter_content(chunk_size=64 * 1024):
if chunk:
process(chunk)
IPWEB_LARGE_FILE_TEST_URL也应指向自有或已获授权的大文件测试接口。with可以确保离开代码块时关闭响应;如果业务只读取响应头或部分内容,也要显式调用 response.close()。否则新的请求不断进入,旧连接却迟迟不归还,使用 pool_block=True的程序就可能长期等在连接池入口。
Session 的生命周期同样重要。每个请求都新建一个 Session会失去连接复用,频繁触发 DNS、TCP、代理认证和 TLS 握手;Session 长期复用却不关闭响应,又会把连接逐渐耗尽。运行中可观察三类信号:连接池已满警告是否持续出现、新建连接比例是否异常升高、进程打开的套接字或文件描述符是否只增不减。出现 Too many open files时,调高系统上限只能暂时延后故障,还应检查响应、Session 和无界任务是否泄漏。
Keep-Alive 也会影响动态代理的实际换 IP 行为。有的代理按请求轮换,有的按连接或会话分配出口。是否更换出口应分别测试复用连接、新建连接和更换会话标识后的结果,不能只根据客户端开启 Keep-Alive 就下结论。
四、aiohttp 怎么控制连接数量与排队
aiohttp 的 ClientSession自带连接池,TCPConnector(limit=...)限制打开的连接总数,limit_per_host=...限制同一端点的连接数。aiohttp 客户端连接池文档建议在一组请求之间复用 Session,而不是为每个 URL 临时创建一个。
下面故意把连接上限设得低于任务并发,仅用于演示连接池排队和 connection_pool_wait的观测方法;这些参数不是生产环境推荐值。运行前请把 IPWEB_TEST_URL设置为自有系统、测试环境或已经获得授权的目标地址。
import asyncio
import os
import time
import aiohttp
PROXY_URL = os.environ["IPWEB_PROXY_URL"]
TARGET_URL = os.environ["IPWEB_TEST_URL"]
CONCURRENCY = 20
async def queued_start(session, trace_config_ctx, params):
trace_config_ctx.queued_at = time.perf_counter()
async def queued_end(session, trace_config_ctx, params):
waited = time.perf_counter() - trace_config_ctx.queued_at
print(f"connection_pool_wait={waited:.3f}s")
async def fetch(session, semaphore, url):
async with semaphore:
async with session.get(url, proxy=PROXY_URL) as response:
response.raise_for_status()
body = await response.read()
return response.status, len(body)
async def main():
urls = [TARGET_URL] * 40
semaphore = asyncio.Semaphore(CONCURRENCY)
timeout = aiohttp.ClientTimeout(
total=30,
connect=8,
sock_connect=5,
sock_read=20,
)
connector = aiohttp.TCPConnector(
limit=10,
limit_per_host=10,
keepalive_timeout=15,
)
trace_config = aiohttp.TraceConfig()
trace_config.on_connection_queued_start.append(queued_start)
trace_config.on_connection_queued_end.append(queued_end)
async with aiohttp.ClientSession(
connector=connector,
timeout=timeout,
trace_configs=[trace_config],
) as session:
results = await asyncio.gather(
*(fetch(session, semaphore, url) for url in urls),
return_exceptions=True,
)
print(results)
asyncio.run(main())
运行时最多有20个任务进入网络阶段,但同一时间只允许使用10条连接,因此其中一部分请求会等待连接归还,并依次触发 queued_start和 queued_end。40个测试任务让这一过程可以持续多个批次,便于观察输出;实际项目仍应根据自己的并发与压测结果设置连接上限。
aiohttp Tracing Reference明确给出了 on_connection_queued_start和 on_connection_queued_end事件:前者在请求开始等待可用连接时触发,后者在连接可用时触发。两者时间差可以直接作为池等待指标。ClientTimeout.connect还包含取得连接与建立连接所花的时间,而 sock_connect只描述新套接字连接阶段。
Requests 没有对应的统一“池等待耗时”字段。实际项目可把任务总耗时与连接、读取阶段的耗时分开记录,再结合 urllib3 调试日志判断;需要精确到单次池获取时,可以自定义 HTTPAdapter或在更底层埋点。仅凭一个总 timeout 无法判断时间究竟消耗在任务队列、连接池还是目标响应。
五、连接池到底开多大:固定并发,只改变池大小
连接池实验必须控制变量。假设已通过代理 IP 并发数与 QPS 的计算方法确定当前业务要验证 20 个全局并发,本轮测试就固定并发为 20,只改变 pool_maxsize。目标地址、请求样本、代理路由、超时、重试和测试时长都保持一致。
| 全局并发 | pool_maxsize |
本轮观察重点 | 需要记录的数据 |
|---|---|---|---|
| 20 | 5 | 小池是否产生明显等待 | 池等待、复用率、成功 QPS、p95、错误类型 |
| 20 | 10 | 池等待是否随容量增加而下降 | 同上,按相同稳定窗口记录 |
| 20 | 20 | 池容量与最大在途请求数对齐后的表现 | 同上,保留原始日志 |
| 20 | 30 | 继续扩池是否仍有收益 | 同上,并与上一档比较 |
这组数字是实验档位,不是性能结论,也不是产品承诺。测试时可以让 pool_connections固定为 10,避免同时改变两项参数。若池从 5 增至 10 时等待下降、成功 QPS 上升,说明连接池确实在限制吞吐;若从 20 增至 30 后等待和有效吞吐几乎不变,20 已能承接这组并发,继续扩池没有明显意义。
如果池扩大后连接耗时、读取耗时或错误率反而上升,瓶颈通常已经转移到本地资源、代理入口、带宽或目标服务。测试结果还要做业务校验:HTTP 200 可能返回登录页、验证页或缺少关键字段的内容,可用代理成功率与有效数据的分层口径单独统计网络成功和有效结果。
六、从日志判断连接池卡在哪里
频繁出现 Connection pool is full
请求可能仍然成功,但额外连接无法留在池内复用。检查 pool_maxsize是否低于同一路由的实际在途请求数、是否为每个请求新建 Session,以及响应是否及时关闭。偶发一两条不必直接判定代理故障;持续出现并伴随新建连接升高,才说明需要调整。
pool_block=True 后请求长时间没有返回
这通常意味着连接都在使用中,后续请求正在等连接归还。Requests 的连接和读取 timeout 不会自动给这段池等待设置同等时限。需要检查慢响应、未关闭的流式响应和任务层总截止时间,并记录池等待发生前后的任务时间。
连接复用率低,新建连接持续增加
重点查看 Session 是否被重复创建、服务器是否主动关闭连接、代理会话是否要求重新建连,以及响应体是否完整读取。此时单纯扩大池容量可能只是保存更多无法稳定复用的连接。
Too many open files 或套接字数量只增不减
优先排查响应与 Session 泄漏、无界线程或协程、异常分支没有关闭资源等问题。系统文件描述符上限可以作为容量条件检查,但不能代替修复泄漏。
Keep-Alive 后出口 IP 没有按预期轮换
对照代理服务的出口分配规则,分别测试同一连接、重新连接和更换会话标识。连接池负责复用客户端连接,出口是否轮换仍取决于代理网关的产品机制。
若日志核心变成 ConnectTimeout、ReadTimeout、407 或 429,排查重点已经不只在连接池。连接超时要核对代理入口和网络路径,读取超时要看目标响应与传输,407 要检查认证,429 则应降低频率并遵守目标服务规则。不要为了掩盖这些错误继续放大连接池或无限重试。
连接池容量够用后,成功 QPS 仍不增长,就应回到代理并发、带宽和目标响应时间等链路指标继续定位。若重试导致请求数和流量同时上升,可结合代理流量消耗过快的排查方法核算每条有效结果的实际请求次数,而不是把额外流量都归因于连接池。
七、代理 IP 连接池常见问题
1. 出现 Connection pool is full,请求一定失败了吗?
不一定。在 Requests 默认非阻塞模式下,请求可能使用了池外临时连接并正常完成,只是该连接结束后没有被保留复用。应结合状态码、异常和有效结果判断。
2. pool_connections 应该设置成多少?
它取决于客户端需要缓存多少组连接池,而不是单个任务有多少并发。若程序只反复访问少数固定路由,需求通常不高;路由或目标组合很多时,再通过连接池淘汰和新建连接情况调整。
3. pool_maxsize=100 能把并发限制在100吗?
只设这个参数不能保证。Requests 默认 pool_block=False,池满后仍可创建临时连接。要形成清晰的容量边界,还需要 pool_block=True以及线程池、信号量等任务并发控制。
4. pool_block=True 后,timeout 会限制池等待吗?
Requests 的 timeout=(connect, read)不等同于连接池等待超时。对总任务时长有硬要求时,应在任务执行层设置截止时间,并测试取消后底层同步请求是否仍在运行。
5. 每个请求创建一个 Session,会不会更安全?
通常会降低连接复用效率,并增加解析、建连、认证和握手开销。更合适的做法是在明确的任务范围内复用 Session,同时保证响应与 Session 最终关闭。
6. 使用 Keep-Alive 后,动态代理还会换 IP 吗?
要看代理服务按请求、连接还是会话分配出口。复用连接可能影响部分产品的轮换行为,实际结果应按服务规则用出口 IP 查询验证。
7. 怎样判断连接池已经足够大?
固定业务并发,只提高池容量;当池等待已处于可接受范围,继续扩池也不再改善有效吞吐和 p95,当前容量通常已经够用。若错误或延迟继续上升,应检查连接池之外的链路。
合适的连接池大小来自固定业务并发下的对照测试:它能够承接在途请求,让连接及时归还,并且在继续扩容时不再带来可测的有效吞吐收益。日志、池等待和业务有效结果三项一起看,比照搬某个固定参数更可靠。