☰
requests库从入门到生产实践:Session、连接池与超时重试机制详解
2026/10/14 22:31:43 网站建设 项目流程

最早我用 requests,也就是一行r = requests.get(url)的事。后来负责一个批量数据采集服务,几十个线程同时往外打请求,线上突然冒出 “Connection pool is full, discarding connection”,那时候我才意识到:requests 的“完整使用教程”绝不只是讲几个 API 方法,而是要把 Session、连接池、重试、超时这一整套底层机制都搞明白,否则入门越快,线上踩坑越深。

这篇内容我就按自己从入门到企业级的实际使用路径来写,从底层的连接管理机制,到基础请求链路上的高频痛点,再到超时、重试、连接池的配置调优,最后给出一套可以直接落地的生产级请求层封装思路。不管是刚接触 requests 的新手,还是已经在项目里大面积使用、想解决线上疑难杂症的同学,应该都能从里面找到对自己有用的东西。

1. requests的老底:Session、Adapter和连接池到底是干什么的

1.1 一次requests.get()调用背后发生了什么

很多人用了很久 requests,但从来没想过requests.get(url)这行代码背后到底干了什么。我简单梳理一下调用链:

r = requests.get("https://example.com/api")

它的内部逻辑大致是这样的:requests.get其实是requests.api.get,最终会走到sessions.Session.request。也就是说,即使你没用 Session,requests 也会在内部临时创建一个 Session 去完成这次请求。

再往下,Session 内部有一个 HTTPAdapter,HTTPAdapter 负责把请求交给底层的 urllib3 去发。urllib3 里维护着PoolManager,PoolManager 会为每个 host 维护一个连接池。连接池里有已经建立好的连接,也有按需新建连接的逻辑。

所以整条链路是:

requests.get -> Session.request -> HTTPAdapter.send -> urllib3 PoolManager -> 连接池 -> 真正的 TCP/HTTP 请求

理解这条链路特别重要,因为后面所有企业级配置,比如超时、重试、连接池大小,都不是在requests.get这一层完成的,而是在 Session 和 HTTPAdapter 这一层完成的。

1.2 为什么企业级代码里几乎不用模块级函数

模块级函数requests.get适合快速测试、写脚本、调试接口,但到了生产环境,几乎所有相对正规的代码都会用requests.Session。区别在哪里?Session 会在连接池层面复用连接。

HTTP 通信有个成本很容易被忽略:TCP 连接建立和 TLS 握手。尤其 HTTPS 场景,一次完整握手需要好几次网络往返。如果每个请求都从零开始建连接,高并发时吞吐量会被握手过程严重拖累。而用同一个 Session 发请求,底层连接可以保持 keep-alive,后续请求直接复用已有连接,握手成本几乎为零。

我自己实测过一个小接口的压测:同样 1000 个请求,每次新建连接的方式比复用 Session 的方式慢接近 3 倍,同时服务端看到的短连接数量高出一个量级。对高频调用方来说,这个差距是会直接体现在成本和服务压力上的。

Session 还承担了一个职责:跨请求状态保持。Cookie、默认 Headers、认证信息、代理配置,都可以挂在 Session 上,后续请求自动携带,不需要每个请求手动传一遍。比如登录一个 Web 服务后,Session 自动保存了 Cookie,之后访问业务接口直接复用即可。

1.3 HTTPAdapter 的挂载机制

Session 之所以能管理连接池和重试,核心是 HTTPAdapter。启动时要注意,默认的 Session 会为http://和https://两个协议各挂一个 HTTPAdapter:

import requests from requests.adapters import HTTPAdapter session = requests.Session() adapter = HTTPAdapter(pool_connections=20, pool_maxsize=20) session.mount("https://", adapter) session.mount("http://", adapter)

很多初学者挂载适配器时只 mount 了一个协议,等线上请求走另一个协议时就发现配置完全没生效。这种问题特别隐蔽,因为代码不报错,只是表现为连接数上不去或者重试策略不生效。

HTTPAdapter 内部其实是在创建一个 urllib3 的 PoolManager,然后把 pool_connections 和 pool_maxsize 这两个参数透传给它。重试配置则通过另一个Retry对象挂载进去。这也就是为什么很多人配置了session.mount后,发现重试没生效——大概率是 mount 的协议不对,或者 Retry 对象配置得不合理。

2. 基础链路与高频痛点:从构造请求到读取响应的完整闭环

2.1 参数传递和 Headers 的规范化处理

基础用法里最容易出错的是 URL 拼接。如果你还在用 f-string 手动拼 query 参数:

url = f"https://api.example.com/search?keyword={keyword}&page={page}"

那建议尽快改成 params 字典:

resp = requests.get( "https://api.example.com/search", params={"keyword": "python requests", "page": 1}, headers={"User-Agent": "my-service/1.0"} )

params 方式会自动帮你做 URL 编码。比如 keyword 里带了中文、空格、特殊字符,手拼的时候很容易拼出一个非法 URL,而 params 字典会把这些字符正确编码。这个差异在处理搜索类、关键词类接口时几乎天天能遇到。

Headers 方面有个小细节:requests 的 Headers 在大小写上是宽松处理的,你传user-agent和User-Agent效果一样。但要注意,如果你用同一个 Session 发请求,session.headers里设置的默认值可以被单次请求的 headers 覆盖,却不能通过传一个空值把它删除。想删除某个默认 header,需要先改session.headers这个字典本身。

2.2 响应对象的三个面孔:text、content、json

拿到响应对象后,很多新手分不清resp.text、resp.content、resp.json()的区别:

resp.text # 字符串形式,requests 会根据编码猜测逻辑自动解码 resp.content # 原始字节流 bytes resp.json() # 解析 JSON,本质是 resp.content 解码后再 json.loads

最容易踩坑的是resp.text的编码猜测。requests 在没有收到明确的Content-Type里的 charset 时,会尝试从响应头或 HTML 内容猜测编码,猜错就乱码。接口返回 UTF-8 中文时,如果响应头没写清 charset,resp.text偶尔会解成乱码。

我处理这类问题的方式很简单:对接口类响应,不依赖resp.text的自动解码,而是直接处理字节流:

content = resp.content try: data = json.loads(content.decode("utf-8")) except UnicodeDecodeError: data = json.loads(content.decode("gbk"))

或者更稳妥一点,解析前先显式指定编码:

resp.encoding = "utf-8" text = resp.text

resp.json()则在接口返回非 JSON 内容时直接抛异常。比如网关返回了一段 HTML 错误页,或者返回空内容,resp.json()会抛出requests.exceptions.JSONDecodeError。这块建议在封装层统一捕获,不要散落在业务代码里。

2.3 上传下载与流式处理

文件上传用 files 参数:

resp = session.post( url, files={"file": ("report.xlsx", open("report.xlsx", "rb"), "application/vnd.ms-excel")} )

甚至不用先读文件内容,传一个文件对象就行,requests 内部会处理 multipart 编码。

下载大文件是另一个高频场景。默认情况下,requests.get会把整个响应体读进内存。一个几百 MB 的文件,直接就能把进程内存打高。要改成流式:

with session.get(url, stream=True) as resp: with open("output.bin", "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk)

stream=True让 requests 只读响应头,不急着读 body,然后通过iter_content分块读。这里有个坑:如果你用stream=True但只读了一部分就退出,连接不会自动释放。所以必须用with或手动resp.close(),否则连接会一直挂在连接池里,直到被回收或者池子耗尽。

2.4 状态码与异常处理的边界

requests 有一个和中国式预期不同的设计:HTTP 层的错误状态码,比如 404、500、502,并不会抛异常。只有网络层面的故障,比如连接被拒、DNS 解析失败、超时,才会抛requests.exceptions.RequestException。

也就是说代码里不能只靠 try-except 兜底状态码,还需要主动判断:

resp = session.get(url) if resp.status_code >= 400: # 业务日志记录响应内容,走自己的异常处理 raise ApiError(f"HTTP {resp.status_code}: {resp.text[:500]}")

或者干脆用resp.raise_for_status(),它会在 4xx/5xx 时抛出HTTPError。生产代码更推荐自己判断,因为你可以同时把响应体前几百个字符一起记录到日志里,排查问题时比单纯一个状态码有用得多。

3. 企业级请求的三道保险:超时、重试与连接池参数调优

3.1 超时为什么必须用元组

入门时我写过很多requests.get(url)不带 timeout 的代码,后来才发现这是生产环境的大忌。不设 timeout,万一服务端一直不响应,请求就永远挂在那里,线程被占死,线程池很快被耗尽,整个服务跟着瘫痪。

企业级写法是拆开连接超时和读取超时:

session.get(url, timeout=(3.05, 10))

第一个值是连接超时,指从发起请求到 TCP 连接建立完成的最长等待时间;第二个值是读取超时,指两次读取数据之间的最长间隔,不是整个响应体的总耗时。对耗时较长的接口,读取超时可以放宽,但连接超时必须控制在几秒内。

为什么连接超时这么短?因为如果服务不可达,快速失败比长时间等待更合理。失败后可以走重试或者熔断,没必要让调用方挂在那边等一个大概率不会建立的连接。

3.2 Retry 策略:不是所有请求都适合重试

重试配置通过 urllib3 的 Retry 对象挂到 HTTPAdapter 上:

from urllib3.util.retry import Retry from requests.adapters import HTTPAdapter retry = Retry( total=3, connect=3, read=2, status=2, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504], allowed_methods=["GET", "HEAD", "OPTIONS"] ) adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter) session.mount("http://", adapter)

Retry 的各个参数含义:

  • total:所有类型的重试总次数上限;
  • connect:连接失败后的重试次数;
  • read:读取失败后的重试次数;
  • status:HTTP 状态码触发时的重试次数;
  • backoff_factor:退避因子,相邻两次重试间隔为backoff_factor * (2^n),n 从 0 开始;
  • status_forcelist:哪些状态码需要触发重试;
  • allowed_methods:哪些 HTTP 方法可以被重试。

不推荐重试 POST 等非幂等方法。设想一个支付回调通知接口,POST 请求因为网络抖动被重发,服务端又没有做幂等处理,就可能产生重复订单。生产环境要么让接口支持幂等键,要么明确把重试限定在 GET/HEAD/OPTIONS 上。

backoff_factor这个参数也值得多说一句。很多团队把重试间隔设为固定 1 秒,实际上退避指数增长的效果更好。第一次重试间隔 0 秒、第二次 0.5 秒、第三次 1 秒,类似这样,能在快速恢复和服务端压力之间取得平衡。

3.3 连接池扩容与参数之间的关系

HTTPAdapter 有两个连接池参数,很多人搞不清区别:

  • pool_connections:缓存不同 host 的连接池数量上限,可以理解为“池子的个数”;
  • pool_maxsize:每个 host 对应的连接池里,最多保存的连接数。

默认值各是 10。对大多数低并发场景够用,但一旦并发线程数超过 10,真正活跃的连接数也超过 10,就会出现连接池不够用的情况。urllib3 会在日志里输出 “Connection pool is full, discarding connection”,这个日志我在生产环境里见过太多次。

调整思路很简单:如果你的并发线程数是 50,单 host 场景下pool_maxsize至少设为 50,再留一点缓冲,可以设 64。pool_connections则看你访问的 host 数量,一般是网关、内网服务、第三方接口各一个域名,设 10 到 20 就够。

这里提醒一下,连接池参数要结合第 4 部分的线程数一起来设,单独调大连接池而不调整并发模型,效果很有限;反过来并发很高但连接池很小,就会出现上面的队列丢弃问题。

4. 并发吞吐实战:线程数、连接池上限与资源释放的匹配关系

4.1 用线程池替代 for 循环加 requests.get

写爬虫或者批量调用接口的时候,最容易出现这种代码:

for url in urls: resp = requests.get(url) handle(resp)

这种串行方式最大的问题是慢。每个请求耗时 100ms,1000 个请求就是 100 秒。如果单个请求是 1 秒,那就是 1000 秒,完全不可接受。

企业级做法至少要用线程池并发:

from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_one(url): with requests.Session() as session: session.headers.update({"User-Agent": "my-service/1.0"}) resp = session.get(url, timeout=(5, 20)) return resp with ThreadPoolExecutor(max_workers=16) as pool: futures = [pool.submit(fetch_one, url) for url in urls] for future in as_completed(futures): result = future.result()

注意这里我在函数内部创建了一个新 Session。Session 官方文档明确说过它不是设计为线程安全共享的。实际使用中,纯并发读的场景下共享 Session 一般也能跑,但我遇到过偶发的连接错乱和 Cookie 串号问题,排查成本很高。所以稳妥方案是“每线程各建会话”或“每个任务用短生命周期会话”。

4.2 连接数如何估算

我一般按这个逻辑估算连接池参数:

假设目标 QPS 是 500,单请求平均耗时 100ms。那么稳态并发大约需要 50 个同时在途请求。也就是说线程数取 50 左右,或者用协程模拟并发时限制信号量 50。此时单 host 的pool_maxsize就按 50 乘一个余量系数 1.2 到 1.5 去配置,取 64 到 75。多 host 场景再把pool_connections调到对应 host 数量。

线程数也不是越大越好。线程切换有开销,且线程数超过连接池上限后,多出来的线程即使空闲也拿不到连接,纯属空转。我自己实测过,50 个线程加 64 的连接池上限配置,在常规内网接口上已经能跑出接近理论极限的吞吐。

4.3 并发后资源释放容易被忽视

并发场景里,响应对象必须被及时关闭。很多人用resp = session.get(url)后就扔一边了,靠 Python 垃圾回收去关闭连接。这在低并发时看不出问题,并发一高,连接被持有不还,连接池很快就满了。

正确写法是能with就with:

with session.get(url, stream=False) as resp: data = resp.json()

如果代码结构比较复杂,至少也要在 finally 里resp.close()。同时注意,resp.json()之后如果不再需要响应内容,关闭响应对象能把底层连接释放回连接池,而不是真正断开,这个代价非常小,但很多人会忽略。

5. 一次生产故障排查实录:从“偶发超时”到定位根因的完整链路

5.1 现象与第一反应

我之前在某个数据同步服务里遇到一次线上故障。这个服务每 5 分钟会启动一批批量任务,向一个第三方开放平台拉取数据。上线两个星期后,开始出现偶发超时,一开始是几十个请求里有一个,后来发展到高峰期连续报错。

第一反应大多是“调大超时”。有同事把连接超时从 5 秒调到 30 秒,结果不但没有解决问题,报错反而更多了。因为请求堆积越来越多,服务端的处理压力反而更大了。这个方向从一开始就走错了。

5.2 从日志与打点一步步缩小范围

当时我们做了几件事:

第一,把报错的任务 ID 和对应请求 URL、耗时、状态码全部打到日志中心,横向拉出来看分布。发现报错集中在某一批跨网络的高延迟接口上,内网请求几乎全部正常。

第二,检查客户端连接状态。用ss -s和netstat看当时的连接数,发现同时存在大量 TIME_WAIT 和 ESTABLISHED 连接。TIME_WAIT 多说明短连接非常多,底层连接没有被有效复用。

第三,回到代码里查 Session 的使用方式。发现这个服务为了图省事,每个请求都走requests.get,模块级函数每次都创建临时 Session,自然每次都建新连接。

根因就比较清晰了:没有连接复用,加上并发任务一多,连接频繁建立和释放,连接池配置也没有跟上,最终在跨网络链路的高延迟接口上触发超时和连接池满的日志。

5.3 修复与验证

修复分三步:

  • 把模块级请求全部替换为一个共享的 Session,并在 Session 上挂载 HTTPAdapter;
  • 按并发线程数把pool_maxsize从默认 10 调到 64;
  • 显式配置超时元组(5, 30),并把 Retry 策略只允许 GET 重试。

改完之后连续压测了 30 分钟,错误率降到 0,ESTABLISHED 连接数稳定在预期范围内,TIME_WAIT 数量明显下降。这个案例说明,requests 本身的传输层配置,对大并发场景的影响往往比业务代码更关键。

5.4 顺带说一个 SSL 校验的衍生问题

排查过程中还发现一个隐藏问题:为了省事,代码里很多地方写了verify=False,并且屏蔽了 InsecureRequestWarning。这在生产环境非常危险,相当于放弃了服务端身份校验,中间人截获数据时客户端完全无感知。企业环境里如果内部 CA 签发的证书不被系统信任,正确做法是:

session.get(url, verify="/path/to/custom_ca.pem")

指定自己信任的 CA 证书文件路径,而不是直接关闭校验。遇到自签名证书时也同理。一套完备的请求层应该把这类参数收敛在配置中心,而不是散落在业务代码里。

6. 生产级请求层封装:把散落的requests调用收拢成一条稳定的管道

6.1 三层设计思路

用 requests 写到第三年,我最大的体会是:散落的requests.get是一个团队的负债。每个开发都有自己的写法,有人忘了超时,有人把 verify 关掉,有人重试策略互相矛盾。要让 requests 可靠,必须在团队内收敛出一个统一请求层。

我习惯分成三层:

  • 配置层:统一管理 base_url、超时、重试次数、连接池大小、是否走代理等参数;
  • 客户端层:负责创建 Session、挂载 Adapter、统一异常分类;
  • 业务方法层:提供 get_json、post_json 这类语义化的方法,业务代码只关注接口逻辑。

6.2 一个可以被团队直接用的 APIClient 骨架

下面给出一段核心结构,可以在此基础上扩展:

import json import logging import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry logger = logging.getLogger("api_client") class APIError(Exception): pass class TimeoutAPIError(APIError): pass class NetworkAPIError(APIError): pass class APIClient: def __init__(self, base_url, timeout=(5, 30), max_retries=2, pool_size=20, token_provider=None): self.base_url = base_url.rstrip("/") self.timeout = timeout self.token_provider = token_provider self.session = requests.Session() retry = Retry( total=max_retries, connect=max_retries, read=max_retries, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504], allowed_methods=["GET", "HEAD", "OPTIONS"] ) adapter = HTTPAdapter(pool_connections=10, pool_maxsize=pool_size) self.session.mount("https://", adapter) self.session.mount("http://", adapter) self.session.headers.update({"User-Agent": "api-client/2.0"}) def _refresh_token(self): if self.token_provider: token = self.token_provider() self.session.headers.update({"Authorization": f"Bearer {token}"}) def _request(self, method, path, **kwargs): url = self.base_url + path kwargs.setdefault("timeout", self.timeout) kwargs.setdefault("verify", True) start = time.monotonic() try: resp = self.session.request(method, url, **kwargs) except requests.exceptions.Timeout as exc: logger.warning("request timeout: %s %s", method, url) raise TimeoutAPIError(str(exc)) from exc except requests.exceptions.RequestException as exc: logger.warning("request failed: %s %s %s", method, url, exc) raise NetworkAPIError(str(exc)) from exc cost = time.monotonic() - start if resp.status_code == 401 and self.token_provider and not kwargs.get("_retried"): resp.close() self._refresh_token() kwargs["_retried"] = True return self._request(method, path, **kwargs) if resp.status_code >= 400: body_preview = resp.text[:500] logger.error("bad http response: %s %s %s body=%s", method, url, resp.status_code, body_preview) resp.close() raise APIError(f"HTTP {resp.status_code}") logger.info("api ok: %s %s status=%s cost=%.1fms", method, url, resp.status_code, cost * 1000) return resp def get_json(self, path, **kwargs): resp = self._request("GET", path, **kwargs) try: return resp.json() except requests.exceptions.JSONDecodeError: logger.error("invalid json from %s: %s", path, resp.text[:500]) raise APIError("invalid json response") finally: resp.close()

这段代码有几个设计点是刻意安排的:

401 自动刷新 token 并重放一次。很多内部系统用短时效 token,每次手动判断 401 再重发,业务代码很难看。在请求层统一处理,业务侧无感。注意只重放一次,避免循环重放风暴。

异常分类。把超时、网络异常、业务异常分开,上层就能针对不同类型做降级或者告警。比如超时异常可以触发熔断统计,网络异常可以触发重试,业务异常则直接上报。

日志统一处理。正常请求记录耗时和状态码,异常响应记录响应体前 500 字符。这个对排查第三方接口问题非常有效,能直接从日志里看到对方返回了什么内容,而不用再去抓包。

6.3 几个容易忽略的工程细节

生产封装层里还有几个细节值得单独说。

第一,别把 token 打印到日志。上面代码在日志里刻意没有打印 Authorization 头,这是必须的。很多内部服务出问题后把请求头完整打出来,结果 token 泄漏到日志系统里,等于把凭据暴露给了所有能看到日志的人。

第二,响应体要截断。有些接口异常时会把几 MB 的 HTML 错误页返回,直接全量打日志,几分钟就能把磁盘打爆。截断到 500 字符足够人工判断方向,又不影响存储。

第三,注意 requests 会读环境变量里的 HTTP_PROXY 和 HTTPS_PROXY。如果生产环境有全局代理配置,而你的服务应该直连,需要在 Session 上显式设置trust_env=False:

self.session.trust_env = False

这个坑很隐蔽,我曾经见过一个服务因为服务器上残留了代理环境变量,请求全部走了代理,延迟高到离谱。

6.4 什么时候需要换掉 requests

requests 不是万金油。它最大的短板是同步阻塞模型。如果你需要海量并发和 HTTP/2,或者想用异步 IO 把单机并发推到上千级别,requests 就不太够了。常见的替代方向有两个:一是 httpx,API 风格接近 requests,且支持 HTTP/2 和异步客户端;二是直接基于 urllib3 做更细粒度的连接池控制。但对绝大多数常规接口调用、爬虫任务、服务间通信来说,requests 加上一个统一封装层,已经足够稳定了。

最后再分享一个个人体会:工具库的进阶用法,不是靠背 API 记住的,而是在线上环境里被问题逼着一点点摸透的。requests 这套连接池和重试机制,初看没那么重要,但到了高并发环境,它就是决定服务稳定性的关键一环。团队里我后来定了个规矩,凡是请求外部接口的代码,一律走统一请求层,不允许散装requests.get出现在业务逻辑里。这样做之后,接口层的故障率直线下降,排障效率也高了很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询