重构之前,我的爬虫长这样:
for url in urls:
resp = requests.get(url, timeout=10)
save(parse(resp.text))
2000 个 URL,耗时 11 分 23 秒。换成 asyncio 之后是 1 分 50 秒。但如果你以为把 requests 换成 aiohttp 就能自动获得这个提升,那多半会失望——我第一版异步代码跑了 9 分钟,几乎没变快。
细节一:并发数不是越大越好¶
第一版我写了个无限并发:
async def fetch_all(urls):
async with aiohttp.ClientSession() as s:
return await asyncio.gather(*[fetch(s, u) for u in urls])
2000 个协程同时开,结果是被目标站点限流,大量请求重试,还有一堆连接超时。加上信号量之后立刻正常:
sem = asyncio.Semaphore(20) # 同时最多 20 个
async def fetch(session, url):
async with sem:
async with session.get(url) as r:
return await r.text()
并发数怎么定
经验公式:并发数 ≈ 目标站点的响应时间(秒) × 你想要的 QPS。响应时间 0.2 秒、想跑 100 QPS,并发就是 20。比这再高,收益递减而封禁风险上升。
细节二:超时要分层设置¶
aiohttp 的默认超时是 5 分钟——对爬虫来说等于没有。而且单一 timeout 值没法区分「连不上」和「连上了但一直不发数据」:
timeout = aiohttp.ClientTimeout(
total=30, # 整个请求最长 30 秒
connect=5, # 建连 5 秒
sock_read=10, # 连接建立后,10 秒没数据就放弃
)
session = aiohttp.ClientSession(timeout=timeout)
sock_read 是最容易被忽略的一项。没有它,遇到一个慢速响应的死连接,你的协程能挂好几分钟。
细节三:失败要退避重试,但不能无脑重试¶
async def fetch_with_retry(session, url, retries=3):
for i in range(retries):
try:
return await fetch(session, url)
except (aiohttp.ClientError, asyncio.TimeoutError):
if i == retries - 1:
return None
await asyncio.sleep(2 ** i + random.random()) # 指数退避 + 抖动
两个要点:
- 指数退避:1s、2s、4s,给对方喘息时间
- 抖动:
+ random.random()防止多个协程同时重试,形成新的洪峰
完整的骨架¶
import asyncio, aiohttp, random
async def worker(session, queue, results):
while True:
url = await queue.get()
try:
html = await fetch_with_retry(session, url)
if html:
results.append(parse(html))
finally:
queue.task_done()
async def main(urls):
queue = asyncio.Queue()
for u in urls:
queue.put_nowait(u)
results = []
async with aiohttp.ClientSession(timeout=timeout) as s:
workers = [asyncio.create_task(worker(s, queue, results)) for _ in range(20)]
await queue.join()
for w in workers:
w.cancel()
return results
用队列而不是 gather 的好处是:任务进度可见,中途可以持久化,崩了不用从头再来。
什么时候别上 asyncio¶
最后泼一盆冷水。下面这些场景,同步代码反而更合适:
| 场景 | 原因 |
|---|---|
| 只有几十个请求 | 复杂度不划算 |
| 重度 CPU 计算 | asyncio 解决不了 GIL,用多进程 |
| 依赖的库不支持异步 | 一个阻塞调用拖垮整个事件循环 |
| 需要精细控制请求顺序 | 调试成本会让你怀疑人生 |
异步的本质是在等待 IO 的时候去干别的事。如果你的瓶颈根本不在 IO 等待上,换 async 只会让代码更难读。