构建高效的Python Requests长连接池详解
本文深入解析Python requests库中Session类的长连接机制,指出默认基于host的连接管理在多域名或子域名场景下的性能缺陷。通过自定义HTTPAdapter,结合scheme、主域名、IP及端口构建更精细的连接池键值,有效解决IP轮询和子域名复用问题,显著提升高并发爬虫及分布式系统的网络IO效率。
前文:
Python Requests长连接机制与默认限制
近期在开发全网CDN刷新系统时,我在性能调优环节遇到了requests长连接相关的挑战。过去虽然关注过长连接过多导致的资源浪费问题,但由于系统采用分布式扩展架构,针对个别节点的问题往往选择忽略。然而,当前的缓存刷新系统对性能极为敏感,因此我将在此分享构建最优HTTP长连接池的具体方案。
老生常谈:
在Python生态中,哪个HTTP客户端库最易用?大多数人会首选requests库,主要原因在于其简洁性和易用性。
如何蛋疼的构建reqeusts的短连接请求:
Python的requests库默认启用长连接(HTTP 1.1,Connection: keep alive)。若仅尝试在requests头部移除Connection字段是无效的,必须借助httplib模块进行配合调整。
s = requests.Session()
del s.headers['Connection']
正确发起 http 1.0的请求姿势是:
#xiaorui.cc
import httplib
import requests
httplib.HTTPConnection._http_vsn = 10
httplib.HTTPConnection._http_vsn_str = 'HTTP/1.0'
r = requests.get('http://127.0.0.1:8888/')
服务端接收的http包体内容:
GET / HTTP/1.0
Accept-Encoding: gzip, deflate
Accept: */*
User-Agent: python-requests/2.5.1 CPython/2.7.10 Darwin/15.4.0
所谓短连接,即发送HTTP 1.0协议,Web服务端在发送数据后会触发close(),传递\0字符串以关闭连接。这里需要指出,许多系统优化讨论忽视了基本的网络IO优化,这显然是不可取的。接下来,我们将依次探讨requests长连接的各种问题及性能优化策略。
那么requests长连接如何实现?
requests提供了Session类来实现长连接,它不仅保持基本连接,还会自动处理服务端返回的Cookie数据。其底层实现原理是什么?
只需将HTTP 1.0升级为HTTP 1.1即可。若明确指定HTTP 1.1,无论是否包含Connection: keep-alive头部,连接均视为长连接。若使用HTTP 1.0并添加Connection: keep-alive,服务端也会将其识别为长连接。原理如此简单。
poll([{fd=5, events=POLLIN}], 1, 0) = 0 (Timeout)
sendto(5, "GET / HTTP/1.1\r\nHost: www.xiaorui.cc\r\nConnection: keep-alive\r\nAccept-Encoding: gzip, deflate\r\nAccept: */*\r\nUser-Agent: python-requests/2.9.1\r\n\r\n", 144, 0, NULL, 0) = 144
fcntl(5, F_GETFL) = 0x2 (flags O_RDWR)
fcntl(5, F_SETFL, O_RDWR) = 0
多域名与子域名场景下的连接池缺陷
Session的长连接支持多个主机么? 也就是我在一个服务里先后访问 a.com, b.com, c.com 那么requests session能否帮我保持连接 ?
答案很明显,当然是可以的!
但这种实现存在诸多槽点。例如,若xiaorui.cc主机上托管了多个虚拟主机,会发生什么情况?系统会不断创建新连接。这是因为requests的urllib3连接池管理基于host(可能是域名或IP,取决于输入)。
strace -p 25449 -e trace=connect
Process 25449 attached - interrupt to quit
connect(13, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("61.216.13.196")}, 16) = 0
connect(8, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("10.202.72.116")}, 16) = 0
connect(8, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("125.211.204.141")}, 16) = 0
connect(8, {sa_family=AF_UNSPEC, sa_data="\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}, 16) = 0
connect(8, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("153.37.238.190")}, 16) = 0
connect(8, {sa_family=AF_UNSPEC, sa_data="\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}, 16) = 0
connect(8, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("157.255.128.103")}, 16) = 0
connect(8, {sa_family=AF_UNSPEC, sa_data="\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}, 16) = 0
connect(8, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("139.215.203.190")}, 16) = 0
connect(8, {sa_family=AF_UNSPEC, sa_data="\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}, 16) = 0
connect(8, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("42.56.76.104")}, 16) = 0
connect(8, {sa_family=AF_UNSPEC, sa_data="\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}, 16) = 0
connect(8, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("42.236.125.104")}, 16) = 0
connect(8, {sa_family=AF_UNSPEC, sa_data="\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}, 16) = 0
connect(8, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("110.53.246.11")}, 16) = 0
connect(8, {sa_family=AF_UNSPEC, sa_data="\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}, 16) = 0
connect(8, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("36.248.26.191")}, 16) = 0
connect(8, {sa_family=AF_UNSPEC, sa_data="\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}, 16) = 0
connect(8, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("125.211.204.151")}, 16) = 0
此外,即使访问同一域名但子域名不同(如a.xiaorui.cc, b.xiaorui.cc, c.xiaorui.cc, xxxx.xiaorui.cc),即便IP地址相同,由于域名不同,requests session仍会为每个子域名实例化独立的长连接。
python 24899 root 3u IPv4 27187722 0t0 TCP 101.200.80.162:59576->220.181.105.185:http (ESTABLISHED)
python 24899 root 4u IPv4 27187725 0t0 TCP 101.200.80.162:54622->101.200.80.162:http (ESTABLISHED)
python 24899 root 5u IPv4 27187741 0t0 TCP 101.200.80.162:59580->220.181.105.185:http (ESTABLISHED)
python 24899 root 6u IPv4 27187744 0t0 TCP 101.200.80.162:59581->220.181.105.185:http (ESTABLISHED)
python 24899 root 7u IPv4 27187858 0t0 TCP localhost:50964->localhost:http (ESTABLISHED)
python 24899 root 8u IPv4 27187880 0t0 TCP 101.200.80.162:54630->101.200.80.162:http (ESTABLISHED)
python 24899 root 9u IPv4 27187921 0t0 TCP 101.200.80.162:54632->101.200.80.162:http (ESTABLISHED)
如果是同一个二级域名,不同的url会发生呢? 是我们要的结果,只需要一个连接就可以了。
import requests
import time
s = requests.Session()
while 1:
r = s.get('http://a.xiaorui.cc/1')
r = s.get('http://a.xiaorui.cc/2')
r = s.get('http://a.xiaorui.cc/3')
我们可以看到该进程只实例化了一个长连接。
# xiaorui.cc
python 27173 root 2u CHR 136,11 0t0 14 /dev/pts/11
python 27173 root 3u IPv4 27212480 0t0 TCP 101.200.80.162:36090->220.181.105.185:http (ESTABLISHED)
python 27173 root 12r CHR 1,9 0t0 3871 /dev/urandom
那么requests还有一个不是问题的性能问题。。。
requests session可以保持长连接,但它能保持多少个?答案是10个。session内置了一个连接池,requests库的默认长连接数为10个。
requests.adapters.HTTPAdapter(pool_connections=100, pool_maxsize=100)
通常,单个session保持10个长连接已足够。但如果是社交爬虫等场景,多个域名共用10个长连接显然不够用。
python 28484 root 3u IPv4 27225486 0t0 TCP 101.200.80.162:54724->103.37.145.167:http (ESTABLISHED)
python 28484 root 4u IPv4 27225349 0t0 TCP 101.200.80.162:36583->120.132.34.62:https (ESTABLISHED)
python 28484 root 5u IPv4 27225490 0t0 TCP 101.200.80.162:46128->42.236.125.104:http (ESTABLISHED)
python 28484 root 6u IPv4 27225495 0t0 TCP 101.200.80.162:43162->222.240.172.228:http (ESTABLISHED)
python 28484 root 7u IPv4 27225613 0t0 TCP 101.200.80.162:37977->116.211.167.193:http (ESTABLISHED)
python 28484 root 8u IPv4 27225413 0t0 TCP 101.200.80.162:40688->106.75.67.54:http (ESTABLISHED)
python 28484 root 9u IPv4 27225417 0t0 TCP 101.200.80.162:59575->61.244.111.116:http (ESTABLISHED)
python 28484 root 10u IPv4 27225521 0t0 TCP 101.200.80.162:39199->218.246.0.222:http (ESTABLISHED)
python 28484 root 11u IPv4 27225524 0t0 TCP 101.200.80.162:46204->220.181.105.184:http (ESTABLISHED)
python 28484 root 12r CHR 1,9 0t0 3871 /dev/urandom
python 28484 root 14u IPv4 27225420 0t0 TCP 101.200.80.162:42684->60.28.124.21:http (ESTABLISHED)
连接池源码分析与自定义优化方案
让我们看看requests的连接池是如何实现的? 通过代码很容易得出Session()默认的连接数及连接池是如何构建的? 下面是requests的长连接实现源码片段。如需要再详细的实现细节,那就自己分析吧
# xiaorui.cc
class Session(SessionRedirectMixin):
def __init__(self):
...
self.max_redirects = DEFAULT_REDIRECT_LIMIT
self.cookies = cookiejar_from_dict({})
self.adapters = OrderedDict()
self.mount('https://', HTTPAdapter()) # 如果没有单独配置adapter适配器,那么就临时配置一个小适配器
self.mount('http://', HTTPAdapter()) # 根据schema来分配不同的适配器adapter,上面是https,下面是http
self.redirect_cache = RecentlyUsedContainer(REDIRECT_CACHE_SIZE)
class HTTPAdapter(BaseAdapter):
def __init__(self, pool_connections=DEFAULT_POOLSIZE,
pool_maxsize=DEFAULT_POOLSIZE, max_retries=DEFAULT_RETRIES,
pool_block=DEFAULT_POOLBLOCK):
if max_retries == DEFAULT_RETRIES:
self.max_retries = Retry(0, read=False)
else:
self.max_retries = Retry.from_int(max_retries)
self.config = {}
self.proxy_manager = {}
super(HTTPAdapter, self).__init__()
self._pool_connections = pool_connections
self._pool_maxsize = pool_maxsize
self._pool_block = pool_block
self.init_poolmanager(pool_connections, pool_maxsize, block=pool_block) # 连接池管理
DEFAULT_POOLBLOCK = False #是否阻塞连接池
DEFAULT_POOLSIZE = 10 # 默认连接池
DEFAULT_RETRIES = 0 # 默认重试次数
DEFAULT_POOL_TIMEOUT = None # 超时时间
Python requests连接池是借用urllib3.poolmanager来实现的。
每一个独立的(scheme, host, port)元组使用同一个Connection,(scheme, host, port)是从请求的URL中解析分拆出来的。
from .packages.urllib3.poolmanager import PoolManager, proxy_from_url 。
下面是 urllib3的一些精简源码, 可以看出他的连接池实现也是简单粗暴的。
# 解析url,分拆出scheme, host, port
def parse_url(url):
"""
Example::
>>> parse_url('http://google.com/mail/')
Url(scheme='http', host='google.com', port=None, path='/mail/', ...)
>>> parse_url('google.com:80')
Url(scheme=None, host='google.com', port=80, path=None, ...)
>>> parse_url('/foo?bar')
Url(scheme=None, host=None, port=None, path='/foo', query='bar', ...)
return Url(scheme, auth, host, port, path, query, fragment)
# 获取匹配的长连接
def connection_from_url(self, url, pool_kwargs=None):
u = parse_url(url)
return self.connection_from_host(u.host, port=u.port, scheme=u.scheme, pool_kwargs=pool_kwargs)
# 获取匹配host的长连接
def connection_from_host(self, host, port=None, scheme='http', pool_kwargs=None):
if scheme == "https":
return super(ProxyManager, self).connection_from_host(
host, port, scheme, pool_kwargs=pool_kwargs)
return super(ProxyManager, self).connection_from_host(
self.proxy.host, self.proxy.port, self.proxy.scheme, pool_kwargs=pool_kwargs)
# 根据url的三个指标获取连接
def connection_from_pool_key(self, pool_key, request_context=None):
with self.pools.lock:
pool = self.pools.get(pool_key)
if pool:
return pool
scheme = request_context['scheme']
host = request_context['host']
port = request_context['port']
pool = self._new_pool(scheme, host, port, request_context=request_context)
self.pools[pool_key] = pool
return pool
# 获取长连接的主入口
def urlopen(self, method, url, redirect=True, **kw):
u = parse_url(url)
conn = self.connection_from_host(u.host, port=u.port, scheme=u.scheme)
至此,关于Python requests中session连接类的实现已阐述清楚。但就requests和urllib3的连接池实现而言,仍有提升空间。问题在于,仅依靠域名和端口会造成上述描述的问题。
那么如何解决?
我们可以用 scheme + 主domain + host_ip + port 来实现长连接池的管理。
其实大多数的场景是无需这么细致的实现连接池的,但根据我们的测试的结果来看,在服务初期性能提升还是不小的。
这样既解决了域名ip轮询带来的连接重置问题,也解决了多级域名下不能共用连接的问题。
以上这篇构建高效的python requests长连接池详解就是小编分享给大家的全部内容了,希望能给大家一个参考,也希望大家多多支持本站。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









