最早我用 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。也就是说即使你没用 Sessionrequests 也会在内部临时创建一个 Session 去完成这次请求。再往下Session 内部有一个 HTTPAdapterHTTPAdapter 负责把请求交给底层的 urllib3 去发。urllib3 里维护着PoolManagerPoolManager 会为每个 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://两个协议各挂一个 HTTPAdapterimport requests from requests.adapters import HTTPAdapter session requests.Session() adapter HTTPAdapter(pool_connections20, pool_maxsize20) 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 fhttps://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 中文时如果响应头没写清 charsetresp.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.textresp.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, streamTrue) as resp: with open(output.bin, wb) as f: for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk)streamTrue让 requests 只读响应头不急着读 body然后通过iter_content分块读。这里有个坑如果你用streamTrue但只读了一部分就退出连接不会自动释放。所以必须用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(fHTTP {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( total3, connect3, read2, status2, backoff_factor0.5, status_forcelist[500, 502, 503, 504], allowed_methods[GET, HEAD, OPTIONS] ) adapter HTTPAdapter(max_retriesretry) session.mount(https://, adapter) session.mount(http://, adapter)Retry 的各个参数含义total所有类型的重试总次数上限connect连接失败后的重试次数read读取失败后的重试次数statusHTTP 状态码触发时的重试次数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)这种串行方式最大的问题是慢。每个请求耗时 100ms1000 个请求就是 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_workers16) 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就withwith session.get(url, streamFalse) 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 分钟错误率降到 0ESTABLISHED 连接数稳定在预期范围内TIME_WAIT 数量明显下降。这个案例说明requests 本身的传输层配置对大并发场景的影响往往比业务代码更关键。5.4 顺带说一个 SSL 校验的衍生问题排查过程中还发现一个隐藏问题为了省事代码里很多地方写了verifyFalse并且屏蔽了 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_retries2, pool_size20, token_providerNone): self.base_url base_url.rstrip(/) self.timeout timeout self.token_provider token_provider self.session requests.Session() retry Retry( totalmax_retries, connectmax_retries, readmax_retries, backoff_factor0.5, status_forcelist[500, 502, 503, 504], allowed_methods[GET, HEAD, OPTIONS] ) adapter HTTPAdapter(pool_connections10, pool_maxsizepool_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: fBearer {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(fHTTP {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_envFalseself.session.trust_env False这个坑很隐蔽我曾经见过一个服务因为服务器上残留了代理环境变量请求全部走了代理延迟高到离谱。6.4 什么时候需要换掉 requestsrequests 不是万金油。它最大的短板是同步阻塞模型。如果你需要海量并发和 HTTP/2或者想用异步 IO 把单机并发推到上千级别requests 就不太够了。常见的替代方向有两个一是 httpxAPI 风格接近 requests且支持 HTTP/2 和异步客户端二是直接基于 urllib3 做更细粒度的连接池控制。但对绝大多数常规接口调用、爬虫任务、服务间通信来说requests 加上一个统一封装层已经足够稳定了。最后再分享一个个人体会工具库的进阶用法不是靠背 API 记住的而是在线上环境里被问题逼着一点点摸透的。requests 这套连接池和重试机制初看没那么重要但到了高并发环境它就是决定服务稳定性的关键一环。团队里我后来定了个规矩凡是请求外部接口的代码一律走统一请求层不允许散装requests.get出现在业务逻辑里。这样做之后接口层的故障率直线下降排障效率也高了很多。