第一次请求慢?因为你没预热。

一、问题:为什么第一次请求总是慢?
用 requests 或 urllib3 发送 HTTPS 请求,你是不是也发现,第一次总是卡那么一下,少说也慢个100到300毫秒?
问题出在哪儿?冷连接。它得一步一步走完所有流程:
冷连接耗时 = TCP 三次握手(1 RTT) + TLS 握手(2 RTT) + 请求响应(1 RTT) = 4 RTT 热连接耗时 = 请求响应(1 RTT) = 1 RTT
省下的这3个RTT,就是预热要做的事——提前把该握的手握完,等用户真正来了,直接走就行了。
二、Session 本身就是连接池,为什么还要预热?
requests.Session() 和 urllib3.PoolManager() 底层都是连接池,没错。但问题在于,连接是懒加载的——你第一次请求它才建,之前就等着。
import requests
s = requests.Session()
# 第一次:冷连接,TCP + TLS 全部握手,慢
s.get('https://api.example.com/data')
# 第二次:复用连接,快
s.get('https://api.example.com/data')
所以核心矛盾来了:第一次请求的用户等不了。
预热的本质,说白了就是:在用户真正访问之前,先把连接焊好,放到池子里等着。
三、三种预热方式(由简到难)
方式 1:发一个 HEAD 请求(最简单)
HEAD 请求只拿头部,不下载 body,资源消耗极小,但能完整触发 TCP + TLS 握手流程。
import requests
s = requests.Session()
# 预热:发一个 HEAD 请求,握手完成后连接留在池中
s.head('https://api.example.com/health')
# 后续请求直接复用,第一个请求也是热连接
resp = s.get('https://api.example.com/data')
优点:一行代码,不依赖内部 API,上手就能用。
缺点:多了一次无用请求,服务端会看到这个 HEAD。
方式 2:调用connection_from_host强制建连(推荐)
这是 urllib3 提供的官方方法,直接创建连接放进池子里,全程不发任何请求。
import urllib3
pool = urllib3.PoolManager(num_pools=50, maxsize=20)
# 预热:强制建立到目标主机的连接,不发请求
pool.connection_from_host('api.example.com', port=443, scheme='https')
# 连接已就位,后续请求直接复用
resp = pool.request('GET', 'https://api.example.com/data')
如果用 requests,需要先拿到底层的 urllib3 对象:
import requests
s = requests.Session()
# 拿到 urllib3 的连接池
pool = s.mount('https://', requests.adapters.HTTPAdapter(pool_connections=20, pool_maxsize=20))
# 预热
pool.poolmanager.connection_from_host('api.example.com', port=443, scheme='https')
# 后续请求复用
resp = s.get('https://api.example.com/data')
优点:不发请求,零额外开销,服务端完全没有感知。
缺点:得绕到底层对象,代码稍微复杂一点。
方式 3:启动时批量预热多个主机(生产推荐)
import urllib3
from urllib3.util.retry import Retry
pool = urllib3.PoolManager(
num_pools=100,
maxsize=50,
timeout=3.0,
retries=Retry(total=3, backoff_factor=0.5),
block=True,
)
# 核心服务列表
core_hosts = [
('api.example.com', 443),
('auth.example.com', 443),
('cdn.example.com', 443),
]
print("开始预热连接...")
for host, port in core_hosts:
try:
pool.connection_from_host(host, port=port, scheme='https')
print(f" ✅ {host}")
except Exception as e:
print(f" ❌ {host}: {e}")
print("预热完成,开始处理请求...")
resp = pool.request('GET', 'https://api.example.com/data')
四、预热能省多少?实测对比
| 方式 | 第一次请求耗时 | 说明 |
|---|---|---|
| 不预热 | ~280ms | TCP + TLS 完整握手 |
| HEAD 预热 | ~120ms | 省了 TCP + TLS,多了一次 HEAD |
connection_from_host 预热 | ~110ms | 省了 TCP + TLS,零额外开销 |
| 不预热但连接池复用(第2次) | ~110ms | TLS Session Resumption 生效 |
结论:connection_from_host 预热是最优解,和“第2次请求”的耗时几乎一样,但这是你的“第一次”。
五、必须知道的三个坑
坑 1:TLS Session Resumption 才是真正的省时利器
urllib3 默认开启了 TLS Session Resumption。也就是说,即便不预热,第二次请求同一主机时,TLS 握手也能从 2 RTT 降到 1 RTT。
# 不预热
s.get('https://api.example.com/data') # ~280ms,完整握手
s.get('https://api.example.com/data') # ~110ms,Session Resumption
所以预热的真正价值是:让“第一次”就享受到“第二次”的速度。
坑 2:预热失败要处理异常
如果目标服务还没启动,预热会直接报错,搞不好整个程序都崩了。
try:
pool.connection_from_host('api.example.com', port=443, scheme='https')
except urllib3.exceptions.NewConnectionError:
print("服务未启动,跳过预热")
异常处理加一下,省得在排查问题时多一个坑。
坑 3:预热不是银弹
| 场景 | 预热有意义 | 原因 |
|---|---|---|
| 首次请求必须低延迟(如健康检查) | ✅ 有 | 省掉第一次的 4 RTT |
| 高频重复调用同一接口 | ❌ 没必要 | 连接池已复用 |
| 多主机轮询 | ✅ 有 | 避免每个节点都冷启动 |
| 长连接保持(keep-alive) | ❌ 没必要 | 连接本来就不会断 |
六、最佳实践:启动预热 + 连接池复用
import urllib3
from urllib3.util.retry import Retry
# 创建连接池
pool = urllib3.PoolManager(
num_pools=100,
maxsize=50,
timeout=3.0,
retries=Retry(total=3, backoff_factor=0.5),
block=True,
)
# 启动时预热核心服务
core_hosts = ['api.example.com', 'auth.example.com', 'cdn.example.com']
for host in core_hosts:
try:
pool.connection_from_host(host, port=443, scheme='https')
except Exception:
pass # 静默跳过
# 后续所有请求,第一次就是热连接
resp = pool.request('GET', 'https://api.example.com/data')
写在最后
| 冷连接 | 预热后 | |
|---|---|---|
| TCP 握手 | ✅ 要 | ❌ 省了 |
| TLS 握手 | ✅ 要 | ❌ 省了 |
| 请求响应 | ✅ 要 | ✅ 要 |
| 总耗时 | 4 RTT | 1 RTT |
预热的本质:把“第一次请求”变成“第二次请求”。
一行代码的事:
pool.connection_from_host('api.example.com', port=443, scheme='https')
剩下的,连接池会帮你搞定。
