1. 为什么需要协程+异步请求?
在传统的Python爬虫开发中,requests库+多线程是最常见的组合。我在早期做数据采集时也长期使用这种方式,直到遇到一个需要爬取200万个商品详情页的项目时才意识到问题所在。当时用16个线程跑了整整三天,服务器CPU占用率却始终不到30%,网络带宽也只用了不到一半。
这种低效的根本原因在于IO等待。当爬虫线程发出HTTP请求后,大部分时间都在等待服务器响应。在同步模型中,线程会被阻塞,无法执行其他任务。虽然多线程看似可以并发,但Python的GIL(全局解释器锁)导致同一时刻只有一个线程在执行Python字节码,线程切换本身也有开销。
1.1 IO密集型场景的特点
典型的爬虫工作流可以分解为:
- 发送HTTP请求(网络IO)
- 等待响应(网络IO)
- 解析HTML(CPU计算)
- 存储数据(磁盘IO)
其中步骤2的等待时间往往占整个流程的80%以上。以爬取电商网站为例,测试数据显示:
| 操作类型 | 平均耗时 | 占比 |
|---|---|---|
| 发送请求 | 50ms | 5% |
| 等待响应 | 900ms | 85% |
| 解析HTML | 100ms | 9% |
| 存储数据 | 10ms | 1% |
这种场景下,同步模型的效率瓶颈显而易见。而协程+异步IO的解决方案,正是为了最大化利用IO等待时间而设计的。
1.2 协程的工作原理
协程(Coroutine)是一种用户态的轻量级线程,它的切换不需要操作系统介入,完全在用户空间完成。与线程相比,协程有两大优势:
-
极低的开销:创建一个协程只需要几KB内存,而一个线程至少需要1MB。在我的测试中,一台4核8G的机器可以轻松运行5000个协程,而开500个线程就会导致频繁的上下文切换和内存不足。
-
可控的调度:协程的调度权掌握在程序员手中。当一个协程遇到IO操作时,可以主动让出执行权,让其他协程运行。这避免了线程被动切换带来的性能损耗。
Python 3.5+通过async/await语法原生支持协程。一个最简单的协程示例:
python复制async def fetch(url):
print(f"开始请求 {url}")
await asyncio.sleep(1) # 模拟IO等待
print(f"完成请求 {url}")
2. 异步HTTP客户端选型与实践
2.1 主流异步HTTP库对比
Python生态中有多个支持异步的HTTP客户端,我实际测试过三个主流选择:
| 库名称 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| aiohttp | 功能全面,社区活跃 | 需要手动管理连接池 | 通用爬虫 |
| httpx | 接口友好,兼容requests API | 性能略低于aiohttp | 需要快速迁移的项目 |
| requests+gevent | 兼容现有代码 | 需要monkey patch,调试困难 | 改造旧项目 |
经过对比测试,aiohttp在性能和功能上都是最佳选择。特别是在处理大量并发请求时,aiohttp的连接池管理和重试机制表现突出。
2.2 aiohttp最佳实践
以下是一个经过生产验证的aiohttp爬虫模板:
python复制import aiohttp
import asyncio
from datetime import datetime
class AsyncCrawler:
def __init__(self, concurrency=100):
self.semaphore = asyncio.Semaphore(concurrency) # 控制并发量
self.timeout = aiohttp.ClientTimeout(total=10) # 超时设置
async def fetch_page(self, session, url):
async with self.semaphore: # 限制并发
try:
async with session.get(url, timeout=self.timeout) as response:
if response.status == 200:
return await response.text()
else:
print(f"请求失败: {url} 状态码: {response.status}")
except Exception as e:
print(f"请求异常: {url} 错误: {str(e)}")
return None
async def crawl(self, urls):
connector = aiohttp.TCPConnector(limit=0) # 不限制连接数
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [self.fetch_page(session, url) for url in urls]
return await asyncio.gather(*tasks, return_exceptions=True)
关键配置说明:
- Semaphore:控制最大并发数,避免瞬间请求过多被目标网站封禁
- ClientTimeout:设置合理的超时时间,防止某些请求卡住整个程序
- TCPConnector:调整连接池参数,默认限制是100个连接
重要提示:实际项目中一定要设置延迟策略,建议在请求间加入随机延迟(如0.5-2秒),避免对目标服务器造成过大压力。
3. 性能优化与实战技巧
3.1 协程与线程池的混合使用
虽然协程擅长处理IO密集型任务,但某些CPU密集型的操作(如HTML解析、数据清洗)仍可能阻塞事件循环。这时可以结合线程池来优化:
python复制from concurrent.futures import ThreadPoolExecutor
async def parse_with_thread(html):
loop = asyncio.get_event_loop()
with ThreadPoolExecutor() as pool:
result = await loop.run_in_executor(
pool,
self._parse_html, # 同步解析函数
html
)
return result
这种模式将CPU密集型任务放到线程池执行,避免阻塞主事件循环。在我的测试中,对于复杂的页面解析,这种混合模式比纯协程方案快2-3倍。
3.2 错误处理与重试机制
异步爬虫的异常处理需要特别注意。以下是经过实战检验的错误处理方案:
python复制from tenacity import retry, stop_after_attempt, wait_exponential
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10)
)
async def robust_fetch(session, url):
try:
async with session.get(url) as response:
response.raise_for_status()
return await response.text()
except aiohttp.ClientError as e:
print(f"请求失败: {url} 错误: {str(e)}")
raise
这里使用了tenacity库实现:
- 指数退避重试(2s, 4s, 8s)
- 最多重试3次
- 自动处理各种网络异常
3.3 性能对比测试
为了验证异步爬虫的优势,我设计了一个对比实验:爬取1000个网页,分别使用:
- 单线程requests
- 多线程(16线程)
- 协程(100并发)
测试结果:
| 方案 | 耗时 | CPU使用率 | 内存占用 |
|---|---|---|---|
| 单线程 | 325s | 15% | 50MB |
| 多线程 | 48s | 85% | 450MB |
| 协程 | 12s | 65% | 120MB |
协程方案不仅速度最快,资源利用率也更为合理。特别是在长时间运行的任务中,协程的稳定性优势更加明显。
4. 高级应用与注意事项
4.1 分布式爬虫架构
对于超大规模爬取任务,单机协程仍然可能遇到性能瓶颈。这时可以采用分布式架构:
code复制主节点(任务调度) → 消息队列(Redis/RabbitMQ) → 工作节点(协程爬虫)
每个工作节点运行多个协程爬虫实例,通过消息队列获取任务。这种架构可以轻松扩展到数百个节点,日处理能力可达千万级页面。
4.2 反爬虫策略应对
异步爬虫虽然高效,但也更容易触发网站的反爬机制。以下是我总结的应对策略:
-
请求头管理:随机切换User-Agent,模拟不同浏览器
python复制headers = { 'User-Agent': random.choice(USER_AGENTS), 'Accept-Encoding': 'gzip, deflate' } -
IP轮换:使用代理IP池,每个请求使用不同IP
python复制proxy = f"http://{random.choice(PROXY_POOL)}" async with session.get(url, proxy=proxy) as response: -
行为模拟:随机延迟、鼠标移动轨迹模拟等
4.3 资源监控与调优
长期运行的异步爬虫需要完善的监控:
python复制import psutil
async def monitor():
while True:
mem = psutil.virtual_memory()
print(f"内存使用: {mem.percent}%")
await asyncio.sleep(60)
主要监控指标:
- 内存使用(协程泄漏会导致内存持续增长)
- 网络连接数(避免耗尽系统限制)
- 请求成功率(及时发现被封禁)
5. 项目实战:电商网站爬虫
下面通过一个完整的电商商品爬虫示例,展示协程爬虫的实际应用:
python复制import asyncio
import aiohttp
from bs4 import BeautifulSoup
import json
import random
class EcommerceCrawler:
def __init__(self):
self.base_url = "https://example.com/products"
self.concurrency = 50
self.sem = asyncio.Semaphore(self.concurrency)
self.results = []
async def parse_product(self, html):
# 使用线程池解析HTML,避免阻塞事件循环
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
self._parse_product_sync,
html
)
def _parse_product_sync(self, html):
soup = BeautifulSoup(html, 'lxml')
return {
'title': soup.select_one('.product-title').text.strip(),
'price': float(soup.select_one('.price').text.replace('$', '')),
'rating': float(soup.select_one('.rating').get('data-score', 0))
}
async def crawl_product(self, session, product_id):
url = f"{self.base_url}/{product_id}"
try:
async with self.sem:
async with session.get(url) as resp:
html = await resp.text()
data = await self.parse_product(html)
data['id'] = product_id
self.results.append(data)
# 随机延迟1-3秒
await asyncio.sleep(random.uniform(1, 3))
except Exception as e:
print(f"Error crawling {url}: {str(e)}")
async def run(self, start_id, end_id):
connector = aiohttp.TCPConnector(limit=0)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [
self.crawl_product(session, pid)
for pid in range(start_id, end_id+1)
]
await asyncio.gather(*tasks)
with open('products.json', 'w') as f:
json.dump(self.results, f, indent=2)
if __name__ == '__main__':
crawler = EcommerceCrawler()
asyncio.run(crawler.run(1000, 2000)) # 爬取商品ID 1000-2000
这个示例包含了协程爬虫的所有关键要素:
- 并发控制(Semaphore)
- 异步HTTP请求(aiohttp)
- CPU密集型任务卸载(线程池)
- 随机延迟(反爬虫)
- 异常处理
- 结果存储
在实际项目中,我通常会在此基础上添加:
- 代理IP中间件
- 请求重试机制
- 分布式任务队列支持
- 更完善的数据清洗管道
异步爬虫虽然性能优异,但也需要特别注意资源管理和反爬策略。建议从少量并发开始,逐步调优参数,找到目标网站能承受的最佳并发数。同时要严格遵守robots.txt的规则,设置合理的请求间隔,做一个负责任的网络公民。
