如果你天天用 requests.get(),请务必读懂这篇文章

17 阅读7分钟

谢邀。作为一名常年和爬虫、高并发数据工程打交道的程序员,这个报错你一定不陌生:

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)。

在发送的 HTTP 报文数据行上,也会发生微妙的变化:

# 无代理直连时
GET /api HTTP/1.1
Host: example.com

# 有代理介入时
GET http://example.com/api HTTP/1.1
Host: http.proxy.16yun.cn

代理服务器正是通过读取请求行里完整的 example.com/api,从而知道自己该把这个请求转发到哪里去。

如果你遇到了文章开头提到的 ProxyError,在读懂源码后,你的排查思路应当无比清晰:

  1. 连接失败: 检查你的机器到代理服务器(如爬虫代理主机)的端口通不通。
  2. 认证失败: 检查用户名和密码是否拼写错误、格式是否正确,或者代理套餐是不是到期欠费了。

四、 避坑指南:数据工程中的最佳实践

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))

总结

看透了 Requests 的底层,你会发现网络编程的本质依然是那些经典的概念:协议格式化、Socket 传输、连接池复用。了解了 HTTPAdapter 和 ConnectionPool 的运作机制,下次再面对各式各样的网络报错,相信你已经能够从容地定位到源码层级,优雅地解决它了。