记一次 Python requests 高并发连接池耗尽导致间歇性 ConnectionError 的排查

内部工具在 200 并发定时任务下偶发 ConnectionError,排查发现 urllib3/requests 连接池配置不当 + Keep-Alive 超时策略激进共同导致。

问题背景

公司内部有一套运维数据采集工具,每天定时从 40 余台服务器拉取系统指标、日志片段和配置漂移信息,写入时序数据库供 Grafana 展示。脚本使用 Python + requests 实现,采用 concurrent.futures.ThreadPoolExecutor 做并发控制,最大并发数配置为 200。

2026 年 9 月中旬开始,运维值班群频繁收到「数据缺失告警」。查看日志发现,部分任务在执行到一半时抛出 requests.exceptions.ConnectionError,重试 3 次后仍失败,导致该批次数据永久丢失。问题具有明显间歇性:同一台服务器有时成功、有时失败,且失败率在早高峰(07:00-09:00)显著升高。

故障现象

典型错误日志如下:

1
2026-09-24 07:12:33,441 [ERROR] Failed to fetch metrics from 172.16.5.23: ConnectionError(MaxRetryError("HTTPSConnectionPool(host='172.16.5.23', port=8443): Max retries exceeded with url: /metrics (Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x7f8a1c2b3d40>: Failed to establish a new connection: [Errno 111] Connection refused'))"))

关键观察点:

  • 错误集中在 NewConnectionError,而非 ReadTimeout 或 ConnectTimeout
  • 目标服务器的 8443 端口实际是通的(用 curl 单发请求 100% 成功)
  • 并发数越高,失败率越高;并发 50 时基本正常,并发 200 时失败率约 8-12%
  • 失败的请求在 tcpdump 中完全看不到 SYN 包,说明是客户端本地直接拒绝建立连接

排查过程

第一步:确认不是网络或服务端问题

用 nmap 和 curl 反复验证目标服务器 8443 端口:

1
for i in {1..50}; do curl -k --connect-timeout 2 https://172.16.5.23:8443/metrics -o /dev/null -w "%{http_code}\n" 2>/dev/null; done

结果 50 次全部返回 200,排除服务端监听或防火墙问题。

第二步:检查本地文件描述符和连接数

怀疑是本地 ulimit 或 TCP 连接表打满:

1
2
3
ulimit -n          # 65535,充足
ss -s              # TCP 连接数峰值 1200 左右,远低于 65535
cat /proc/sys/net/ipv4/tcp_max_tw_buckets  # 262144,正常

排除资源耗尽。

第三步:抓包对比成功与失败请求

在失败时刻同时抓包:

1
tcpdump -i any host 172.16.5.23 and port 8443 -w /tmp/conn.pcap

分析 pcap 发现:失败请求在客户端完全没有 SYN 包发出,而成功请求正常完成三次握手。这说明问题出在 Python 进程内部,在调用 socket.connect() 之前就被拒绝了。

第四步:定位到 urllib3 连接池

翻阅 requests / urllib3 源码,重点关注 HTTPConnectionPool 和 PoolManager。关键参数:

  • pool_connections:默认 10
  • pool_maxsize:默认 10
  • pool_block:默认 False

当并发 200 而 pool_maxsize=10 时,urllib3 会尝试创建超过 10 个新连接。默认行为是「不阻塞」,但在极端情况下可能触发连接创建失败。

更关键的是 maxsize 达到上限后的连接复用策略。urllib3 默认会把空闲连接保持 Keep-Alive,但目标服务器的 Keep-Alive 超时只有 30 秒,而客户端的 pool_block=False 导致超过 maxsize 的请求直接新建连接,新建连接在服务器端看来是「新连接」,但实际上服务器可能已经把之前的连接标记为 TIME_WAIT 或直接拒绝。

第五步:复现与验证

写了一个最小复现脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

session = requests.Session()
adapter = requests.adapters.HTTPAdapter(
    pool_connections=200,
    pool_maxsize=200,
    max_retries=0
)
session.mount('https://', adapter)

def fetch(url):
    try:
        r = session.get(url, timeout=5, verify=False)
        return r.status_code
    except Exception as e:
        return str(e)

with ThreadPoolExecutor(max_workers=200) as executor:
    futures = [executor.submit(fetch, "https://172.16.5.23:8443/metrics") for _ in range(500)]
    for f in as_completed(futures):
        print(f.result())

在实验室环境 200 并发下,稳定复现 7-15% 的 ConnectionError。

解决方案

最终采用「双管齐下」方案:

  1. 调大连接池上限并开启阻塞:
1
2
3
4
5
6
7
adapter = requests.adapters.HTTPAdapter(
    pool_connections=200,
    pool_maxsize=200,
    pool_block=True,        # 关键:达到 maxsize 后阻塞而非直接新建
    max_retries=3
)
session.mount('https://', adapter)
  1. 显式设置 Keep-Alive 头并降低客户端超时:
1
2
3
4
5
headers = {
    "Connection": "keep-alive",
    "Keep-Alive": "timeout=30, max=100"
}
session.headers.update(headers)
  1. 增加连接池监控(可选但推荐):

在 HTTPAdapter 子类中覆写 init_poolmanager,加入连接池使用率日志,便于后续观察。

修改后,200 并发下失败率降至 0.2% 以下,基本可接受。

根因分析

根本原因是 urllib3 默认连接池配置(pool_maxsize=10)远小于实际并发需求(200),导致大量请求在连接池满后尝试新建连接,而目标服务器对新连接的接受速率有限,部分请求在客户端排队超时或直接被本地连接池拒绝建立。

次要因素是 Keep-Alive 策略不匹配:客户端期望长连接复用,服务器 30 秒超时后主动断开,导致「看似空闲」的连接实际已失效,新请求仍走「复用」路径失败后才尝试新建,进一步放大问题。

预防措施

  1. 连接池参数必须与并发度对齐:任何使用 requests/httpx 的高并发场景,pool_maxsize 至少应等于 max_workers。
  2. 开启 pool_block=True:避免在池满时静默创建新连接,改用阻塞等待,暴露真实瓶颈。
  3. 定期回收空闲连接:可使用 requests.adapters.HTTPAdapter 的 close() 或第三方连接池(如 urllib3.PoolManager 的 clear())。
  4. 增加客户端重试 + 退避:对 ConnectionError 做指数退避重试,降低瞬时峰值压力。
  5. 监控连接池使用率:在关键链路加入指标,及时发现池满情况。

总结

一次看似普通的「ConnectionError」告警,最终定位到 urllib3 连接池配置与实际并发严重不匹配的问题。调大 pool_maxsize 并开启 pool_block 后,问题彻底解决。

这个案例再次提醒:在高并发网络请求场景下,连接池参数不是「能跑就行」的默认值,而是必须与业务并发度对齐的核心配置。

延伸阅读

使用 Hugo 构建
主题 Stack 由 Jimmy 设计