作为一名常年和爬虫、高并发数据工程打交道的程序员,这个报错你一定不陌生:
ProxyError: HTTPSConnectionPool(host='example.com', port=443): Max retries exceeded
或者在深夜跑数时突然蹦出来的:
ConnectionResetError: [Errno 104] Connection reset by peer
大多数人在遇到这类问题时,习惯性的动作是打开搜索引擎,盲目地换几个代理 IP 或者加几行重试代码,运气好问题解决了,运气不好就继续在工位上抓耳挠腮。
今天,我们不搞玄学。直接脱掉 Requests 的外衣,去它的源码底层看一看:
一个 HTTP 请求到底经历了什么?连接池是怎么复用的?代理又是在哪一步被强行塞进去的?
干货满满,建议先赞后看。
一、 Requests 架构全景图:它真的只是个“壳”
很多人以为 Requests 承载了所有网络通信逻辑,但读过源码后你会发现,Requests 本质上是一个优秀的 高层封装 ,它把脏活累活全委派给了底层的 urllib3 。 它的核心调用链路如下:requests.get()
↓
Session.request()
↓
Session.send()
↓
PreparedRequest
↓
HTTPAdapter.send() ← 握手 urllib3
↓
ConnectionPool ← 掌管连接池 (urllib3)
↓
socket ← Python标准库
为了让大家更直观地理解,我把各层级的职责梳理成了一张表:
| 层级 | 组件 | 职责 |
|---|---|---|
| 高层封装 | requests | 提供极其友好的用户 API,封装 Cookie、认证和自动重试机制。 |
| 请求构造 | PreparedRequest | 将复杂的 Python 参数转换成符合 HTTP 标准的字节格式。 |
| 适配器层 | HTTPAdapter | 作为桥梁接入 urllib3,处理连接池与代理的路由。 |
| 连接池层 | ConnectionPool | 管理长连接(Keep-Alive),负责复用 TCP socket 减少握手开销。 |
| 传输层 | socket | 真正执行底层网络字节流的发送与接收。 |
二、 核心步骤拆解:从写下代码到字节流出发
Step 1: 请求入口 — Session.send()
当我们调用 requests.get() 时,它只是个快捷方式,底层会立刻转交给 Session 类来处理。# requests/sessions.py 核心逻辑简化
class Session(SessionRedirectMixin):
def request(self, method, url, ...):
# 1. 注入默认请求头(比如大家熟知的 User-Agent)
headers = headers or {
}
headers.setdefault("User-Agent", "python-requests/2.31.0")
# 2. 创建 PreparedRequest 对象
req = PreparedRequest()
req.prepare_request(headers, data, ...)
# 3. 协调发送
response = self.send(req, **kwargs)
return response
技术内幕: Session 是请求的管理容器。它自己不负责网络 I/O,它的核心职责是协调:选择正确的适配器、处理 Cookie 状态的维持以及应对重定向。
Step 2: 规范化格式 — PreparedRequest
在网络世界里,服务器只认符合 HTTP 协议标准的文本。 PreparedRequest 的工作就是把你传的字典、字符串等“散装参数”组装好。class PreparedRequest(RequestEncodingMixin):
def prepare_url(self, url, params):
# 对 URL 进行标准编码,拼接 Query String
if params:
url = url + "?" + urlencode(params)
self.url = url
无论是 URL 编码、Headers 的大小写不敏感处理(
CaseInsensitiveDict
),还是 Body 的序列化,都在这一步完成。在进入具体的发送流程前,任何请求都必须被转化为一个
PreparedRequest
实例。
Step 3 & 4: 进军底层 — HTTPAdapter 与 ConnectionPool
这是最精彩的部分。Requests 默认的适配器 HTTPAdapter 内部持有一个 urllib3.PoolManager() 。 当请求发起时, PoolManager 会根据目标网站的 (scheme, host, port) 作为 Key,去寻找对应的 HTTPConnectionPool 。# urllib3/poolmanager.py 核心逻辑
class PoolManager:
def get_pool(self, host, port, scheme):
key = (scheme, host, port)
if key not in self.pools:
# 如果没有,就为这个 host 专门建一个连接池
self.pools[key] = HTTPConnectionPool(host, port, ...)
return self.pools[key]
- 为什么要搞个连接池? 如果不搞,你每一次 requests.get() 都要经历:创建 socket -> TCP 三次握手 -> 发数据 -> 四次挥手销毁。在高并发爬虫场景下,频繁握手会让效率低到令人发指。
- 复用机制: 有了连接池,当请求来临时,先看池里有没有空闲的旧连接,有就直接拿来发数据,省去了 TCP 握手的三次交换时间。
Step 5: 最终决战 — socket 层的网络 I/O
在 HTTPConnection 内部,Python 终于揭开了它最底层的网络面纱:class HTTPConnection:
def connect(self):
# 建立底层的 TCP 连接
self.sock = socket.create_connection((self.host, self.port), timeout)
if self.scheme == "https":
self.sock = ssl.wrap_socket(self.sock, ...) # 加密握手
连接建立后,它会将协议行、请求头拼接成标准的文本串,通过
self.sock.sendall()
变成电信号发往远端服务器。
三、 爬虫代理的隐秘角落:代理到底在哪个层面介入?
作为爬虫程序员,我们天天都在和代理 IP 打交道。以下是一个接入 16YUN爬虫代理 的典型生产环境示例:import requests
from requests.auth import HTTPProxyAuth
# 亿牛云代理配置信息
proxy_host = "http.proxy.16yun.cn"
proxy_port = 8080
proxy_user = "your_username"
proxy_pass = "your_password"
# 组装代理 URL(将用户名密码内嵌)
proxy_url = f"http://{proxy_user}:{proxy_pass}@{proxy_host}:{proxy_port}"
proxies = {
"http": proxy_url,
"https": proxy_url,
}
# 针对需要显式 Proxy 认证的对象
auth = HTTPProxyAuth(proxy_user, proxy_pass)
try:
response = requests.get(
"https://httpbin.org/ip",
proxies=proxies,
auth=auth,
timeout=10,
verify=False # 某些隧道代理使用自签证书时需注意
)
print(f"返回IP: {response.json()}")
except requests.exceptions.ProxyError as e:
print(f"代理连接失败,可能代理服务器宕机或认证过期: {e}")
那么,当你传入 proxies 字典时,底层发生了什么?
答案是: 它直接改变了 ConnectionPool 的路由方向。 ConnectionPool- 常规模式: 你的 socket 直连目标服务器(如 example.com:80)。
- 代理模式: urllib3 在建立连接时,会把 socket 的连接目标改为代理服务器的地址(例如上述的 http.proxy.16yun.cn:8080)。
# 无代理直连时
GET /api HTTP/1.1
Host: example.com
# 有代理介入时
GET http://example.com/api HTTP/1.1
Host: http.proxy.16yun.cn
代理服务器正是通过读取请求行里完整的
http://example.com/api
,从而知道自己该把这个请求转发到哪里去。
如果你遇到了文章开头提到的
ProxyError
,在读懂源码后,你的排查思路应当无比清晰:
- 连接失败: 检查你的机器到代理服务器(如爬虫代理主机)的端口通不通。
- 认证失败: 检查用户名和密码是否拼写错误、格式是否正确,或者代理套餐是不是到期欠费了。
四、 避坑指南:数据工程中的最佳实践
1. 别再盲目新建 Session 了!
很多新手喜欢在函数内部写 requests.get() ,或者每次请求都声明一个全新的 requests.Session() 。这样做的后果是,每一次请求都在重复创建和销毁连接池,连接池直接沦为摆设,并发一高还会导致 连接泄露 和大量系统句柄占满。- 正确姿势: 全局复用同一个 Session 实例。
# 推荐:全局复用
SUITE_SESSION = requests.Session()
def fetch_data(url):
return SUITE_SESSION.get(url)
2. 遭遇高并发?记得调大连接池容量
Requests 默认的连接池大小( pool_maxsize )是 10 。在多线程高并发爬虫场景下,10 个连接根本不够用,会导致大量线程在 urllib3 层等待空闲连接。- 正确姿势: 显式修改适配器的连接池上限。
adapter = requests.adapters.HTTPAdapter(
pool_connections=20, # 允许缓存的 host 池数量
pool_maxsize=50 # 每个池子内的最大连接数
)
session.mount("https://", adapter)
3. 超时设置要精准
timeout 并不是针对整个请求的下载时间,而是传递给最底层的 socket 的。为了防止爬虫卡死在某些垃圾代理或慢速服务器上,强烈建议传入元组进行分阶段控制:# (连接超时, 读取超时)
requests.get(url, timeout=(3.05, 27))