很多做过数据采集和爬虫扩容的兄弟,几乎都经历过这样一个令人抓狂的场景: 你手里有一个刚写好的爬虫,开 20 个并发的时候,每秒能稳稳当当地处理 15 个页面。为了提高采集效率,你大笔一挥把并发拉到了 200,满心期待着吞吐量能翻十倍,结果现实狠狠打脸——吞吐不仅只涨到了每秒 40 个页面,请求延迟还飙升,甚至开始出现大面积超时。我不信邪,一咬牙把并发加到 500,得,吞吐量直接倒退回每秒 30。
代码还是那份代码,为什么并发越高,系统反而越卡? 其实,
问题根本不在代码逻辑上,而是系统的某个底层资源先摸到了天花板
。高并发环境下,CPU、内存、带宽、甚至是第三方代理的配额,都是有限且互相耦合的。盲目“加并发”解决不了问题,唯有分层诊断、精确调优,才能打破瓶颈。
今天我们就从
单机资源瓶颈
和
代理层瓶颈
两个维度,聊聊高并发爬虫的排查思路,并附上一份结合
隧道代理
的生产级高并发调优代码。
一、 别猜了!先排查单机的“三座大山”:CPU、内存、带宽
在动手改代码前,花两分钟在服务器上敲几个监控命令。是骡子是马,数据一眼就能看出来:- top -p $(pgrep -f your_crawler) —— 看 CPU 使用率,重点观察是 user(用户态)还是 sys(内核态)吃满了。
- vmstat 1 —— 观察内存和 Swap 交换区,看 free 是否在持续不可逆地下降。
- ss -s —— 重点看 TCP 连接状态,尤其是 TIME_WAIT 和 ESTABLISHED 的连接数。
- nethogs —— 按进程查看实时网络吞吐,确认网卡带宽有没有被打满。
1. CPU 瓶颈:解析、加密与无头浏览器
如果你的进程 CPU 接近 100% 且 user 占比极高,说明你的爬虫是计算密集型的。重灾区通常在三个地方:- HTML 解析:用 lxml 解析一个 500KB 的页面单核约 10-30ms,但如果用不指定 parser 的 BeautifulSoup,速度可能慢 5-10 倍。当 QPS 达到 200 时,单核绝对会过载。
- 参数加密签名:App 端采集常遇到 AES/RSA/HMAC 签名,纯 Python 实现的加密单个请求开销可能在 5-20ms,300 并发直接吃掉一个整核。
- JS 渲染:这是名副其实的 CPU 吞金兽。Playwright/Selenium 渲染一个中等页面需要 200-800ms 的 CPU 开销和 200-500MB 的内存。
优化药方:解析库全量替换为 lxml;CPU 密集型任务用 multiprocessing 多进程替代多线程以突破 GIL 限制;无头浏览器务必复用 Browser 实例,通过通过创建多个 Context 来并发。
2. 内存瓶颈:连接池膨胀与无界队列
如果进程 RSS 持续上涨直至被操作系统 OOM Kill,或者 Python 的 GC(垃圾回收)频繁触发导致停顿,一定要排查两处:- HTTP 连接池贪大:requests.Session 中的 pool_maxsize 设置上千,不仅占用大量内存和文件描述符(fd),还会导致本地端口耗尽。要知道,Linux 单机临时端口默认只有 28,000 个左右(ip_local_port_range),TIME_WAIT 占满后新连接直接报 Cannot assign requested address。
- 结果队列无限制:如果抓取生产速度大于写入数据库的消费速度,内存队列(如 asyncio.Queue)会无限膨胀。
优化药方:为所有内存队列设置明确的 maxsize(如 1000),满了就阻塞或持久化刷盘;控制连接池大小,200 并发配置 300 的连接池绝对够用。
3. 带宽与 DNS:被忽视的隐形天花板
单机 1Gbps 网卡的实际有效吞吐通常在 80-100MB/s。如果你抓取的是图片、PDF 或没有过滤 CSS/JS 的网页,带宽瞬间就会见底。此外,高并发下频繁的系统 DNS 解析会带来大量 Temporary failure in name resolution 报错和延迟抖动。优化药方:请求头强制添加 Accept-Encoding: gzip;在代码层或系统层(dnsmasq)配置 DNS 缓存,或直接使用 223.5.5.5(阿里)、119.29.29.29(腾讯)等优质公共 DNS;通过 sysctl -w net.ipv4.tcp_tw_reuse=1 开启端口复用,缓解 TIME_WAIT 问题。
二、 突破单机后,代理隧道的“天花板”怎么破?
当你把本地 CPU、内存、网络配置得炉火纯青后,马上会迎头撞上第二堵墙—— 目标网站的反爬限制与代理 IP 的资源瓶颈 。 为了稳定采集,我们通常会引入隧道代理。以工程中常用的 爬虫代理 为例,它通过云端服务地址,在服务端实现了毫秒级的 IP 切换,网络延迟能做到低至 100ms。同时提供了标准版 30 万+、加强版 80 万+ 的庞大 IP 池。但是,很多兄弟在接入代理后,一开高并发就疯狂触发 HTTP 429(Too Many Requests)。 这是因为,隧道代理不是无限无限资源,它天生自带两层物理约束:- RPS(每秒请求数)上限:代理订单通常包含 5 到 300 次/秒的上限限制,超过配额就会直接返回 429。
- 隧道总带宽上限:所有上下行流量都要经过代理服务器转发,如果不压缩数据,代理隧道的带宽就会饱和。
- IP 生存周期限制:单个 IP 有效时间通常为 20 秒或 180 秒,在极短时间内对同目标站狂发请求,该 IP 必定被封。
所以,引入代理后,爬虫的最优并发度公式变了! 不再是你本地能开多少,而是:
$$\text{实际并发度} = \min(\text{单机可支撑并发}, \text{代理每秒请求上限} \times \text{安全系数})$$ 这个安全系数,在工程上我们强烈建议取 0.8 到 0.9 之间。比如你的代理订单是 200 RPS,那么实际并发控制在 160-180 才是最平稳、吞吐最高的。三、 核心实战:高并发 + 隧道代理优化示例
下面提供一份基于 Python asyncio + aiohttp 的生产级爬虫代码。这份代码整合了 连接池控制 、 隧道代理接入 、 KeepAlive 握手复用 以及 基于安全系数的 RPS 限速保护 ,能彻底解决高并发下的吞吐抖动和 429 报错:import asyncio
import time
import random
import aiohttp
from aiohttp import TCPConnector, BasicAuth
class YiniuCloudCrawler:
def __init__(self, max_rps: int, safety_factor: float = 0.85):
"""
初始化高并发限速爬虫
:param max_rps: 亿牛云代理的每秒请求数上限 (如 100次/秒)
:param safety_factor: 安全系数,留出余量防止撞 429 上限,建议 0.8~0.9
"""
# 计算实际允许的最大 RPS
self.effective_rps = int(max_rps * safety_factor)
# 通过 Semaphore 限制并发量,通过 interval 限制请求频率
self.semaphore = asyncio.Semaphore(self.effective_rps)
self.interval = 1.0 / self.effective_rps
# 亿牛云爬虫代理配置 (隧道代理技术)
# 替换为你自己在后台申请的域名、端口、账号和密码
self.proxy_host = "t.16yun.cn"
self.proxy_port = "31111"
self.proxy_user = "16YUN_USER"
self.proxy_pass = "16YUN_PASS"
self.proxy_url = f"http://{self.proxy_host}:{self.proxy_port}"
self.proxy_auth = BasicAuth(self.proxy_user, self.proxy_pass)
async def _fetch_page(self, session: aiohttp.ClientSession, url: str) -> str:
"""
发起请求并处理连接复用与异常
"""
async with self.semaphore:
# 引入微小延时,平滑请求峰值,让请求模式更自然,避免瞬间脉冲打满 RPS
await asyncio.sleep(self.interval + random.uniform(0.01, 0.05))
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
# 显式声明接收 gzip 压缩,大幅降低代理隧道带宽压力
"Accept-Encoding": "gzip, deflate",
# 开启会话保持:KeepAlive 能够复用 TCP 连接,减少 SSL/TCP 握手开销;
# 如果需要每次请求都强制切换 IP,可将其改为 "Close"
"Connection": "keep-alive"
}
try:
# 结合爬虫代理毫秒级 IP 切换,延迟低至 100ms
async with session.get(
url,
headers=headers,
proxy=self.proxy_url,
proxy_auth=self.proxy_auth,
timeout=aiohttp.ClientTimeout(total=10)
) as response:
# 如果不幸遭遇 429,应该主动退让而非硬扛
if response.status == 429:
print(f"[429 告警] 触达代理 RPS 上限,正在等待退让... URL: {url}")
await asyncio.sleep(2.0)
return None
response.raise_for_status()
# 仅提取有用数据,避免把不需要的大资源加载进内存
html = await response.text()
return html
except Exception as e:
print(f"[请求异常] {url} -> {str(e)}")
return None
async def run_task(self, urls: list):
"""
主爬取流程,负责连接池管理
"""
# 核心优化:限制 HTTP 连接池大小,防止 ephemeral port 耗尽和内存溢出
# limit=200 确保本地 ESTABLISHED 连接总数可控
connector = TCPConnector(limit=200, ttl_dns_cache=300)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [self._fetch_page(session, url) for url in urls]
# 使用 as_completed 消费任务,控制结果队列大小,防止内存无限膨胀
success_count = 0
for future in asyncio.as_completed(tasks):
result = await future
if result:
success_count += 1
# 这里可以把数据写入有限大小的队列或者批处理刷盘
print(f"任务执行完毕,成功采集 {success_count} / {len(urls)} 个页面。")
# 运行示例
if __name__ == "__main__":
# 假设我们要抓取 500 个电商商品页面
test_urls = [f"http://example.com/goods/{i}" for i in range(1, 501)]
# 假设配置的隧道代理订单配额为 100 次/秒
# 传入 100,底层会乘以安全系数 0.85,把有效 RPS 控制在 85
crawler = YiniuCloudCrawler(max_rps=100, safety_factor=0.85)
# 启动异步事件循环
asyncio.run(crawler.run_task(test_urls))
四、 总结:爬虫调优的分层治理法则
高并发爬虫的性能优化,绝不是一个简单粗暴的“体力活”,而是一场 CPU、内存、带宽和代理配额四个维度的 资源博弈 。当你再遇到吞吐量上不去、卡顿或者报错时,请遵循以下迭代路径:- 基线测量:先用 top / vmstat / ss 持续 5 分钟观察单机,定位最先见底的硬件瓶颈。
- 治理本地:CPU 满换 lxml / 多进程;内存满调小连接池(如设置 300)、给队列设 maxsize;端口满开 tcp_tw_reuse 和 gzip 压缩。
- 约束代理:根据代理订单的 RPS 和隧道带宽做容量规划。利用好隧道代理的 KeepAlive 连接复用特性减少握手开销,严格用 \min(\text{单机并发}, \text{代理上限} \times 0.85) 来定并发参数。
- 循环验证:优化后重新测基线。如果瓶颈转移到了下一层,继续迭代优化。