三万条评论爬了一整夜?我是怎么把异步爬虫救回来的
去年接了个小活,要扒一个电商站上大概三万条商品评论做情感分析。我第一版脚本跑起来,进度条悠悠地走,瞄了眼「预计剩余时间」——十一个小时。那一刻我意识到,问题不在网速,在我自己。
那版代码长这样(节选):
import requests
def fetch(url):
return requests.get(url, timeout=10).text
for url in urls: # 三万个,串行
html = fetch(url)
parse(html)
requests 是同步阻塞的,一个请求卡 200ms,三万个就是 6000 秒起步。更要命的是,我在循环里每回都 new 一个 session 的坏习惯没改——连接不复用,TCP 三次握手每次重来,TLS 也重来。这俩开销加起来,比请求本身还贵。
卡在哪,就先解决哪
换成 aiohttp 之后,核心思路就一句话:用协程把等待的时间交出来,让事件循环去跑别的请求。但光换库没用,并发不控一样死。我最终的写法:
import aiohttp, asyncio
async def fetch(session, url, sem):
async with sem: # 控并发,别一窝蜂
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as r:
return await r.text()
except Exception as e:
return f"ERR:{e}"
async def main(urls):
connector = aiohttp.TCPConnector(limit=20, ttl_dns_cache=300)
sem = asyncio.Semaphore(20)
async with aiohttp.ClientSession(connector=connector,
headers={"User-Agent": "Mozilla/5.0"}) as session:
tasks = [fetch(session, u, sem) for u in urls]
return await asyncio.gather(*tasks)
htmls = asyncio.run(main(urls))
这里有三个我踩出来的点,值得单拎出来说:
- connector 的 limit 和 Semaphore 是两个东西。TCPConnector 管底层连接池,Semaphore 管你同时发多少任务。只设一个,另一个可能成为隐形瓶颈。我两个都卡在 20,是因为那站单 IP 超过这个数就开始 503。
- ttl_dns_cache 别漏。三万个请求反复解析同一个域名,DNS 查询累积的延迟很可观,缓存 5 分钟直接砍掉这块。
- 超时一定显式设。aiohttp 默认没总超时,一个慢请求能吊着整批。
但别高兴太早
并发拉到 50 的时候,我遇到了平生第一次「自己把自己 ban 了」——不是对方反爬多高级,是我请求太密,触发了人家的基础限流。所以并发数不是越大越好,它本质是「速度和被封概率」的权衡。我的经验是先用小批量探出对方 tolerant 的阈值,再定一个留余量的数。
还有个坑:异步代码里一旦混进一个同步阻塞调用(比如时间长的 parse 或同步 requests),整个事件循环就被你那个调用卡住,协程的优势瞬间归零。解析逻辑要么也异步化,要么丢到线程池(loop.run_in_executor)里跑。
改完那版,同样三万条,二十分钟跑完,而且因为连接复用,服务器那边压力反而比之前友好。异步不是银弹,它救不了烂的网络和更强的反爬;但在「IO 等待多、CPU 活儿少」的爬虫场景里,它确实能把你从无聊的等待里解放出来。这事之后我再看任何「慢」的脚本,第一反应都是先问一句:你在等什么,又为什么不在等的时候干点别的。
