在 Python 网络编程这条路上,很多人都是从requests.get(url)开始的。三行代码能拉回一个网页,确实让人上头。可一旦进了真实项目,各种问题就冒出来了:服务偶尔超时、连接池莫名其妙用完、脚本跑着跑着像死了一样,或者反过来,你的请求太猛直接被对方网关拒绝。这些问题通常不全是代码逻辑的锅,更多是网络请求的工程基础没打好。这篇文章我就把自己这几年在 Python HTTP 请求上踩过的坑、沉淀下来的方法和设计思路,从头到尾捋一遍,从 DNS 解析讲到连接复用到错误日志,让你以后写网络请求时不只“能跑”,还“扛得住”。
1. 一次 HTTP 请求的完整旅程:你到底在跟谁打交道
1.1 从 URL 到 IP 地址:DNS 解析里的隐形瓶颈
你在代码里写https://api.example.com/data,Python 并不会直接拿着这个域名去发请求。它要先把域名解析成一个 IP 地址——可能是 IPv4 的192.0.2.10,也可能是 IPv6 的2001:db8::1。这一步由操作系统自带的解析器完成,解析顺序通常是本地缓存、系统配置的 DNS 服务器、再到更上层的权威服务器逐级查询。
很多开发者遇到“请求超时”第一反应是服务器问题,但实际有不少案例是 DNS 拖慢了整体耗时。你可以用socket.getaddrinfo自己验证一下解析速度:
import socket import time start = time.perf_counter() info = socket.getaddrinfo("api.example.com", 443, proto=socket.IPPROTO_TCP) print("解析耗时:", time.perf_counter() - start) for item in info: print(item[4][0])我在一台内部 DNS 配置混乱的机器上实测,解析一个常见域名能卡住两三秒,这还没开始建 TCP 连接。换到配置正常的网络环境,解析时间会降到几十毫秒。假如你只是在本地写着玩,可能感受不到差异,但部署到云服务器、容器或者边缘设备后,DNS 这个环节会无限放大。
这里给两条实务建议:第一,测试网络问题时要能区分“DNS 慢”“连接慢”“响应慢”。把代码里临时改成直接访问 IP 地址,加一个自定义Host请求头,如果速度变快,那问题基本出在 DNS 上。第二,容器化部署时,记得检查容器内的 DNS 配置,很多镜像默认配置在特定网络环境下效率很差。适当配置本地 DNS 缓存或者换成更靠谱的公共 DNS,都能显著改善整体延迟。
1.2 TCP 三次握手与 TLS 协商:为什么你会卡在“连接中”
域名解析拿到 IP 地址后,客户端要和服务器建立 TCP 连接。这个过程大家可能都听过:客户端发一个 SYN 包,服务器回 SYN-ACK,客户端再确认 ACK,这是三次握手。如果是 HTTPS,紧接着还要进行 TLS 握手,客户端和服务器要协商版本、交换证书、生成会话密钥,这一步涉及非对称加密,耗时远高于简单的 TCP 握手。
很多人会问:为什么我的请求偶尔快偶尔慢?这往往不是服务器业务代码的问题,而是 TLS 协商和 TCP 连接不稳定。尤其是跨地域的网络链路,每次握手都要经历多次往返,ICMP 或者 TCP 丢包会直接导致握手失败或超时。
Python 的requests底层用的是urllib3,而urllib3默认会维护连接池,同一主机复用已经建立好的连接,避免每次请求都重新走一遍握手流程。这个机制非常关键。如果你直接封装http.client或者每次都新建requests.get,等于放弃了连接复用,在高并发场景下操作系统会堆积大量TIME_WAIT状态的连接,最终端口耗尽。这一点后面讲连接池时还会展开。
1.3 发送请求体与接收响应:HTTP 协议没有想象中简单
连接建立好之后,客户端才真正把请求行、请求头和请求体发送过去。这里头有个容易忽略的点:一次 HTTP 请求并不意味着只有一次网络读写。对于大响应,服务器会分多个包返回,客户端需要按 TCP 流的顺序组装;而请求头里的Transfer-Encoding: chunked又代表了分块传输,需要按块解析。
理解这个过程有助于你排查 Partially Read 之类的问题。请求一多,连接迟早会被服务器主动关闭,或者被中间设备断开。如果代码没有正确处理底层连接重置,你就会看到ConnectionResetError或者RemoteDisconnected。这一类问题不是重试就能彻底解决的,它要求你设置合理的连接池大小、超时参数和重试策略,这四章我会逐一展开。
2. 请求库选型实战:requests、httpx、aiohttp 的差异与适用场景
很多初学者只认requests,因为它确实简单。真正动手开发一段时间后,你会发现选型会直接影响并发能力、协议支持和后续可维护性。这里我结合自己的项目经验做一个走心对比。
2.1 requests:老牌选手,可靠但也传统
requests是目前 Python 世界用得最多的 HTTP 客户端库。它 API 设计得非常舒服,session = requests.Session()解决了连接复用,session.get、session.post顺手又直观。如果你只是写脚本、调用 REST API、做中小规模的采集任务,选它没有任何问题。
但它有几个天然短板:
- 不支持 HTTP/2。现在很多 API 和网站已经用上了 HTTP/2,虽然对功能没有绝对影响,但性能和部分高级特性(如多路复用)在 HTTP/1.1 上表现不出来。
- 无原生异步支持。
requests的函数都是同步阻塞的,协程里直接调用会卡住事件循环,需要配合线程池或别的手段使用。 - 对连接池、重试这类底层控制虽然有支持,但 API 藏在
urllib3里,很多人根本不知道还有HTTPAdapter这种高级用法。
所以我在只需要快速交付的任务里会直接用requests,但一旦涉及高并发或者协议特性要求,就会换别的库。
2.2 httpx:紧跟时代的替代品,同步异步都能打
httpx是我现在的主力库。它在 API 风格上尽量接近requests,所以迁移成本很低。它的核心优势在于原生支持 HTTP/2 和异步接口,一个库同时给了同步httpx.Client和异步httpx.AsyncClient,实测在并发请求场景下比requests配合线程池更轻量。
刚上手时多花半小时熟悉它的Client和Transport配置,后面会省下大量踩坑时间。需要注意,httpx的 HTTP/2 支持依赖额外安装httpcore的相应实现,而且目前对某些老版本服务器存在兼容问题。如果你只是简单地pip install httpx,默认还走 HTTP/1.1,需要显式开启 HTTP/2。
2.3 aiohttp:异步 Web 场景的常青树
aiohttp是异步 HTTP 客户端和服务端的一体化框架。它长期存在于 Python 异步生态里,稳定性和社区认可度都很高。如果你要写高并发的爬虫,或者需要跟 WebSocket 打交道,aiohttp比httpx更合适,因为它原生支持 WebSocket,文档里也有大量连接池、会话复用的最佳实践。
它的缺点是 API 偏底层,写起来比requests啰嗦。连接管理和超时控制需要自己多写几行。我一般只在 WebSocket 场景和重度异步采集任务里才使用aiohttp。
2.4 选型对照表
| 库 | 同步/异步 | HTTP/2 | WebSocket | 生态成熟度 | 适用场景 |
|---|---|---|---|---|---|
| requests | 同步 | 否 | 否 | 极高 | 中小规模脚本、API 对接 |
| httpx | 同步+异步 | 支持 | 否 | 高 | 现代接口、异步高并发 |
| aiohttp | 异步 | 否 | 支持 | 高 | 高并发爬虫、WebSocket |
选型没有绝对的银弹,我在同一套系统里就混用过多种库:简单轮询任务用requests,报表批量拉取用httpx,实时消息连接用aiohttp。工具服务于场景,不要为了炫技而换库。
3. 请求头不是玄学:User-Agent、Accept 系列字段的工程细节
3.1 User-Agent 为什么不能随意设置
很多网站的网关会分析进来的请求特征,判断发起者是不是一个正常的浏览器或官方客户端。如果你的User-Agent是python-requests/x.y.z,就等于把身份写在脸上。有些服务会直接拒绝这类请求,返回 403 或其他错误;有些则会在日志里单独标记,作为风险流量观察。
这不是让你伪造身份去骗人,而是保证请求在正常合规的范围内尽量贴近真实场景。如果目标服务明确仅允许特定客户端,你的 UA 应该设置为对应的客户端标识。如果该接口允许通用浏览器访问,就设置成一个真实的浏览器 UA,例如:
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36这里有个小坑:有些开发者直接在网上复制一个过时或者明显拼接错误的 UA。服务端识别模块不傻,UA 里的操作系统版本、Chrome 版本、内核版本相互矛盾时,反而更容易触发风控。所以要么用一个公认完整合理的字符串,要么设置成一个简洁、明确的官方 SDK UA,比如MyApp/1.0 (Business Intelligence)。一个前后一致的标识,比一个花里胡哨的假浏览器 UA 更可靠。
3.2 Accept、Accept-Encoding 与压缩的坑
Accept头表示你愿意接收的内容类型。比如请求 JSON 接口,就应该设置Accept: application/json。如果设置了text/html而服务端只提供 JSON,某些框架会直接返回 406 或者返回一个页面而不是数据。
Accept-Encoding则更关键。它决定了服务器会不会压缩响应。requests和httpx默认会自动处理 gzip 解压,但如果你自己封装底层传输层,收到的原始字节可能是压缩过的。你不解压直接读取,轻则拿到乱码,重则因长度校验失败导致解析异常。建议明确设置:
Accept-Encoding: gzip, deflate, br注意 brotli 压缩。requests并没有内置解压 br 的能力,需要额外装brotli包。如果你用httpx处理大量响应,会发现没有装这个包时服务器返回的 br 压缩数据无法正常解压,表现为响应体解析异常。
3.3 Content-Type 和 Connection:请求体语义的关键
POST 请求要特别注意Content-Type。给 JSON API 发请求,必须设置Content-Type: application/json,并且把请求体序列化成 JSON 字符串。很多人图省事直接传data={"key": "value"},结果服务端收到的是表单编码格式,解析失败。用requests的话,应该用json={"key": "value"},它会自动帮你设置正确头并序列化。
Connection: keep-alive是另一个容易忽略的头。虽然 HTTP/1.1 默认保持连接,但某些中间设备会强制要求显式声明。如果你用requests.Session(),它会自动维护连接,无需手动干预。如果自己封装或使用底层接口,务必把 keep-alive 显式加上,同时设置合理的连接复用参数,这会显著降低握手频率。
4. 稳定性三板斧:超时、重试与连接池
4.1 超时设置不是“玄学”,而是保命底线
requests.get(url)如果不传timeout,它在理论上可能一直挂起。网络环境再复杂一点,一个请求卡个十分钟也不稀奇。生产环境里这会导致线程堆积、资源耗尽,甚至整个程序崩溃。所以每个 HTTP 请求都必须设置超时。
timeout参数在底层分成两个值:连接超时和读取超时。连接超时是建立 TCP/TLS 连接时允许等待的最长时间;读取超时是服务端返回响应数据时,两次数据包之间允许的最大间隔。它的含义不是“整个请求在 N 秒内必须完成”,而是“每次读操作不能超过 N 秒”。这一点非常关键,否则你会遇到一个 5 秒超时却运行了 60 秒的请求,因为服务器每隔几秒发一段数据,每次都刚好在超时线以内。
requests.get(url, timeout=(3.05, 10))这个(3.05, 10)里的 3.05 是连接超时,10 是每块数据的读取超时。我习惯把连接超时设置得相对短,因为一个连接如果 3 秒内建不起来,大概率后面也不会顺利;而读取超时根据响应体量设置,一般给 10 到 30 秒。大文件下载场景下,如果数据持续稳定传输,读取超时其实不会触发,所以不必过于保守。
4.2 重试策略:盲目重试只会放大雪崩
“报错就重试”是新手常干的。问题在于,如果服务端已经过载,你立刻重试只会再次增加压力。正确做法是区分请求类型和错误类型。
首先,幂等请求(如 GET、HEAD、PUT、DELETE)可以安全重试,而 POST 这类可能产生副作用的请求,除非能确保接口有幂等设计,否则不要盲目重试,避免重复提交数据。
其次,要根据错误码选择重试策略。连接错误、读取超时、429 Too Many Requests、500、502、503、504这些属于可重试错误。而400、404、403、422这类客户端错误,重试通常也没用,因为问题出在请求本身。
requests底层urllib3提供了Retry类,可以通过HTTPAdapter直接挂载:
from urllib3.util.retry import Retry from requests.adapters import HTTPAdapter session = requests.Session() retries = Retry( total=4, connect=3, read=2, status=3, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET", "HEAD"], ) session.mount("https://", HTTPAdapter(max_retries=retries))backoff_factor=0.5表示重试间隔按指数退避增长:第一次 0.5 秒,第二次 1 秒,第三次 2 秒,依次类推。指数退避能有效避免请求风暴,这是所有健壮系统都必须遵循的节奏。
4.3 连接池与会话复用:少建立连接就是少犯错
requests.Session()比裸调requests.get更靠谱,原因就在于它内部维护了连接池。连接池可以复用同一个目标主机的 TCP/TLS 连接,避免反复握手,还减少底层 TIME_WAIT 状态堆积。HTTPAdapter的pool_connections和pool_maxsize控制连接池大小。默认值是 10,在并发较高的场景下可以适当调大:
adapter = HTTPAdapter(pool_connections=20, pool_maxsize=20) session.mount("https://", adapter)如果你的程序是单线程顺序请求,pool_maxsize设成 10 足够。但如果你用线程池发大量并发请求,连接池太小会导致线程都在等待空闲连接,表现为吞吐量上不去。注意连接池不是越大越好,过大的连接数会让目标服务器和本机资源的压力同时上升。
5. 并发请求的正确打开方式:线程池、协程与流量控制
5.1 用线程池给 requests 提速
requests是同步阻塞的,但可以用ThreadPoolExecutor把多个请求并行发出。这种方式最接近原有的代码逻辑,改动量小。核心是控制并发线程数,不要无脑开几十上百个线程。
from concurrent.futures import ThreadPoolExecutor, as_completed urls = [f"https://api.example.com/{i}" for i in range(100)] def fetch(url): return requests.get(url, timeout=(3, 10)).status_code with ThreadPoolExecutor(max_workers=8) as executor: future_map = {executor.submit(fetch, url): url for url in urls} for future in as_completed(future_map): url = future_map[future] try: status = future.result() print(url, status) except Exception as exc: print(url, exc)这里最关键的不是代码,而是max_workers的取值。目标服务能力未知时,从 4 到 8 起步比较合理。并发不是免费的,它把压力从客户端转移到了服务端,也把失败模式从“单个失败”变成了“批量失败”。假如 100 个请求同时发出去,目标服务一个超时,可能连带 20 个请求一起超时。
5.2 协程与异步客户端:高并发场景的轻量方案
当你需要更高吞吐量时,切换到httpx或aiohttp的异步模式是更优解。协程的并发开销远低于线程,上千个并发请求占用资源可能只有几十个线程的一半。
用httpx举例:
import asyncio import httpx async def fetch(client, url): resp = await client.get(url) return resp.status_code async def main(): limits = httpx.Limits(max_connections=20, max_keepalive_connections=10) async with httpx.AsyncClient(limits=limits) as client: tasks = [fetch(client, f"https://api.example.com/{i}") for i in range(100)] results = await asyncio.gather(*tasks) print(results) asyncio.run(main())哪怕异步很轻量,并发仍然要设上限。async不是免死金牌,目标服务照样会把你连在一起看。我经常在异步代码里额外加一个信号量:
sem = asyncio.Semaphore(20) async def limited_fetch(client, url): async with sem: return await client.get(url, timeout=10)这样能确保并发峰值始终是 20,而不是因为 gather 了一百个任务真的同时打出一百个请求。
5.3 流量整形:不要让你的机器变成攻击源
这一条在团队协作或者公共网络环境里尤其重要。我们写程序追求效率,但也要考虑服务端的承载能力。一个正经的请求系统,应当能识别目标服务的实时压力,并动态调整请求速率。
简单的做法是维护一个最小间隔时间。比如每个请求完成后固定等待 0.1 秒,或者用令牌桶算法控制每秒请求数。Python 里可以用ratelimit之类的第三方库,也可以手写一个时间戳队列。重点是,这个限制不应该只在测试环境生效,生产环境同样要有。否则你的一次全量数据刷新,可能就把对方的服务打得响应不可用,最终双方都得不偿失。
6. HTTP 错误排查与日志记录:别等到线上出问题再开盲盒
6.1 状态码速查手册
日常开发中我们需要快速判断错误属于哪一类。这里整理了一张高频状态码排查表:
| 状态码 | 含义 | 排查方向 |
|---|---|---|
| 301/302 | 重定向 | 是否跟随重定向,目标地址是否正确 |
| 400 | 请求语法错误 | 检查请求头、请求体格式 |
| 401 | 未认证 | 检查 Token 或 API Key |
| 403 | 拒绝访问 | 检查权限、UA、访问频次、附加参数 |
| 404 | 资源不存在 | 检查 URL 路径、版本号 |
| 429 | 请求太频繁 | 检查并发与频率限制,准备退避重试 |
| 500 | 服务器内部错误 | 需要等待或联系服务方 |
| 502/503/504 | 网关/服务不可用或超时 | 检查上游服务状态,考虑退避重试 |
遇到 403 时不要马上怀疑“我被特殊对待了”,先看看是不是 UA 被策略引擎识别成异常客户端。遇到 429 时,检查自己的请求频率,同时务必要读响应头里的Retry-After字段,它能明确告诉你需要等待多少秒再重试。
6.2 降级策略:别让单个异常打垮整个任务
即使做了重试,网络故障和超时还是会发生。一个稳定的系统必须有降级方案。比如“拉取数据失败时先用缓存数据顶上”,或者“某个批次失败后先跳过,最后统一补偿”。我在做批量数据同步时,会把每个任务放进队列,失败超过最大重试次数的任务单独进入死信队列,隔离排查,而不是让主程序一直卡在那个坏请求上。
6.3 日志里到底该记什么
很多人的日志只有 exception 堆栈,出了问题只能看到“连接超时”四个字,连是哪个 URL 都不知道。我的做法是记录一组固定的上下文字段:
- 时间戳、请求 ID
- 请求方法、完整 URL
- 请求头里的关键内容(如 UA,注意别记录敏感信息)
- 状态码、响应耗时
- 异常类型与消息(必要时才记完整堆栈)
用 Python 的logging配合LoggerAdapter或extra字段,可以很轻松地统一格式:
import logging logging.basicConfig(level=logging.INFO, format="%(asctime)s %(request_id)s %(levelname)s %(message)s") logger = logging.getLogger("http_client") def log_request(request_id, method, url, status, duration): logger.info("request finished", extra={ "request_id": request_id, "method": method, "url": url, "status": status, "duration_ms": duration, })建议不要直接把响应体完整打进日志。接口可能包含账号信息或其他敏感字段,日志泄漏带来的风险远大于排查故障的收益。
7. 合规红线与长期主义:开发者的护栏到底在哪里
7.1 只访问你有权限访问的资源
无论是普通爬虫、API 对接还是内部系统调用,第一原则都是权限边界。接口文档允许的调用方式和频率,你要严格执行;robots.txt 表达的内容抓取意愿,能尊重就尊重。不要为了拿数据去强行访问未授权的内部接口,也不要尝试扫描不属于你的网络空间。公共接口和开放数据已经足够支撑绝大多数业务需求,把需求定义清楚,往往比挖空心思拿更多数据更重要。
7.2 稳定压倒一切:与其钻研捷径,不如夯实基本功
经常有朋友问我,访问某些服务时遇到限制怎么办。我的第一反应不是教人怎么绕过限制,而是提醒他先分析自己的请求模式是不是有问题。大量 429 往往是因为并发太猛或者缺少退避策略,先把限速、缓存、指数退避做好,绝大多数限制问题都会被化解掉。一次平稳的重试机制比任何“奇技淫巧”都更高级,也更经得起时间考验。系统设计的长期主义,靠的是扎实的请求工程能力,而不是某一天突然发现某个“独门秘笈”失效后手忙脚乱。
7.3 用户数据保护不是口号
日志里别记身份证号、手机号、Token 这类敏感信息。传递给第三方的报文里,尽量只包含本次业务必要的最小字段。团队内部协作时,代码仓库里不要出现真实的密钥。保证数据合规既是法律要求,也是工程素养。一个好的工程师,写出来的请求不仅快和稳,还自带分寸感。
最后还想再说一件事:我在实际操作中最深的感受是,网络请求的问题绝大多数不是“不够快”,而是“不够稳”。DNS 配置、连接池大小、超时时间、重试退避、日志上下文,随便哪一个没考虑好,都会在某个深夜以线上事故的形式找你。把这些基础工程扎扎实实做好,比临时抱佛脚找什么高级技巧都管用。另外,生产环境里一定要把关键请求的请求 ID、状态码、耗时这些字段完整打出来,不要等出错时再看一个孤零零的堆栈。就这一条,能帮你省下大量排查时间。