问题背景
公司内部有一套运维数据采集工具,每天定时从 40 余台服务器拉取系统指标、日志片段和配置漂移信息,写入时序数据库供 Grafana 展示。脚本使用 Python + requests 实现,采用 concurrent.futures.ThreadPoolExecutor 做并发控制,最大并发数配置为 200。
2026 年 9 月中旬开始,运维值班群频繁收到「数据缺失告警」。查看日志发现,部分任务在执行到一半时抛出 requests.exceptions.ConnectionError,重试 3 次后仍失败,导致该批次数据永久丢失。问题具有明显间歇性:同一台服务器有时成功、有时失败,且失败率在早高峰(07:00-09:00)显著升高。
故障现象
典型错误日志如下:
|
|
关键观察点:
- 错误集中在
NewConnectionError,而非ReadTimeout或ConnectTimeout - 目标服务器的 8443 端口实际是通的(用 curl 单发请求 100% 成功)
- 并发数越高,失败率越高;并发 50 时基本正常,并发 200 时失败率约 8-12%
- 失败的请求在 tcpdump 中完全看不到 SYN 包,说明是客户端本地直接拒绝建立连接
排查过程
第一步:确认不是网络或服务端问题
用 nmap 和 curl 反复验证目标服务器 8443 端口:
|
|
结果 50 次全部返回 200,排除服务端监听或防火墙问题。
第二步:检查本地文件描述符和连接数
怀疑是本地 ulimit 或 TCP 连接表打满:
|
|
排除资源耗尽。
第三步:抓包对比成功与失败请求
在失败时刻同时抓包:
|
|
分析 pcap 发现:失败请求在客户端完全没有 SYN 包发出,而成功请求正常完成三次握手。这说明问题出在 Python 进程内部,在调用 socket.connect() 之前就被拒绝了。
第四步:定位到 urllib3 连接池
翻阅 requests / urllib3 源码,重点关注 HTTPConnectionPool 和 PoolManager。关键参数:
pool_connections:默认 10pool_maxsize:默认 10pool_block:默认 False
当并发 200 而 pool_maxsize=10 时,urllib3 会尝试创建超过 10 个新连接。默认行为是「不阻塞」,但在极端情况下可能触发连接创建失败。
更关键的是 maxsize 达到上限后的连接复用策略。urllib3 默认会把空闲连接保持 Keep-Alive,但目标服务器的 Keep-Alive 超时只有 30 秒,而客户端的 pool_block=False 导致超过 maxsize 的请求直接新建连接,新建连接在服务器端看来是「新连接」,但实际上服务器可能已经把之前的连接标记为 TIME_WAIT 或直接拒绝。
第五步:复现与验证
写了一个最小复现脚本:
|
|
在实验室环境 200 并发下,稳定复现 7-15% 的 ConnectionError。
解决方案
最终采用「双管齐下」方案:
- 调大连接池上限并开启阻塞:
|
|
- 显式设置 Keep-Alive 头并降低客户端超时:
|
|
- 增加连接池监控(可选但推荐):
在 HTTPAdapter 子类中覆写 init_poolmanager,加入连接池使用率日志,便于后续观察。
修改后,200 并发下失败率降至 0.2% 以下,基本可接受。
根因分析
根本原因是 urllib3 默认连接池配置(pool_maxsize=10)远小于实际并发需求(200),导致大量请求在连接池满后尝试新建连接,而目标服务器对新连接的接受速率有限,部分请求在客户端排队超时或直接被本地连接池拒绝建立。
次要因素是 Keep-Alive 策略不匹配:客户端期望长连接复用,服务器 30 秒超时后主动断开,导致「看似空闲」的连接实际已失效,新请求仍走「复用」路径失败后才尝试新建,进一步放大问题。
预防措施
- 连接池参数必须与并发度对齐:任何使用 requests/httpx 的高并发场景,
pool_maxsize至少应等于max_workers。 - 开启
pool_block=True:避免在池满时静默创建新连接,改用阻塞等待,暴露真实瓶颈。 - 定期回收空闲连接:可使用
requests.adapters.HTTPAdapter的close()或第三方连接池(如urllib3.PoolManager的clear())。 - 增加客户端重试 + 退避:对
ConnectionError做指数退避重试,降低瞬时峰值压力。 - 监控连接池使用率:在关键链路加入指标,及时发现池满情况。
总结
一次看似普通的「ConnectionError」告警,最终定位到 urllib3 连接池配置与实际并发严重不匹配的问题。调大 pool_maxsize 并开启 pool_block 后,问题彻底解决。
这个案例再次提醒:在高并发网络请求场景下,连接池参数不是「能跑就行」的默认值,而是必须与业务并发度对齐的核心配置。