引言:同步爬虫的天花板不在 CPU,而在等待
绝大多数人写的第一版爬虫长这样:一个for循环,里面requests.get(url),拿到 HTML 解析、入库,然后进入下一个 URL。逻辑清晰,调试友好,跑一百个页面毫无压力。但当采集目标从一百个变成十万个时,问题立刻暴露:单请求平均耗时 280ms,其中 DNS 解析 20ms、TCP+TLS 握手 80ms、等待服务端响应 170ms、传输 10ms——真正消耗 CPU 的时间不到 5ms,其余 275ms 里,你的进程只是在recv()上阻塞,什么也没干。
换句话说,同步爬虫的瓶颈从来不是算力,而是一次只能等一件事。CPU 在 95% 以上的时间里处于空闲,你付了 100% 的机器成本,却只用了不到 5% 的计算能力。这就是为什么单纯给爬虫加机器、加 CPU 核数,几乎看不到吞吐提升的原因——瓶颈压根不在那里。
有人会说:上线程池啊。ThreadPoolExecutor(max_workers=50)确实能把吞吐拉上去,这是最省事的方案,本文也不否认它在很多场景下够用。但线程池有三个绕不开的成本:一是每个线程要预留栈空间(默认 8MB 虚拟内存),几百个线程就开始吃内存;二是线程切换由内核调度器主导,上下文切换在万级 QPS 下变成可观开销;三是并发度难以做细粒度的动态调节——你没法在请求级别上优雅地插一个"每域名最多 8 个并发"的闸门。
asyncio + aiohttp提供的正是另一种抽象:单线程、事件驱动、在await处主动让出控制权。协程的内存开销在 KB 级别,调度完全在用户态完成,并且并发控制可以精确到单个await语句。本文从原理讲到生产级实现,给出一套可以直接落地的异步采集骨架。
一、核心原理:asyncio 到底省掉了什么
1.1 事件循环与协程调度
asyncio的核心是一个EventLoop,本质是一个select/poll/epoll/kqueue的封装。当你写await session.get(url)时,发生的是:
- 协程把 socket 注册到 epoll 上,挂起自己,把控制权交还给事件循环;
- 事件循环继续跑其他就绪的协程;
- socket 可读时,epoll 返回,事件循环唤醒对应协程,从上次
await的位置继续执行。
关键点在于:挂起和唤醒都是用户态的栈帧操作,没有系统调用,没有线程创建,没有锁。这就是为什么单线程能扛住上千个并发连接——不是因为单线程更快,而是因为它把"等待"这件事的成本降到了近乎为零。
如果把线程模型和协程模型放在一起对比,差别会更加直观。一个线程从创建到销毁,要经历内核分配task_struct、分配栈、加入调度队列、被抢占或主动让出、上下文切换保存寄存器等一整套流程;而一个协程的"挂起"本质上只是把一个 Python 帧对象的引用放进事件循环的回调队列,唤醒则是从队列里取出来继续send()。前者的开销在微秒到毫秒量级,后者的开销在亚微秒量级,差了两到三个数量级。
还要澄清一个常见误解:asyncio 不是多线程,也不是并行。它做到的是"并发"——在单核上交错执行多个任务,让 I/O 等待时间被其他任务填满。如果你的爬虫里混入了大量 CPU 密集的解析逻辑(比如正则回溯、大型 JSON 反序列化),这些代码会阻塞整个事件循环,导致所有其他协程一起卡住。这是异步爬虫最隐蔽的性能陷阱之一,后文 FAQ 会专门讨论。
1.2 aiohttp 的连接池才是吞吐的真正来源
很多人以为异步的收益来自"同时发请求",其实更重要的收益来自连接复用。一次 HTTPS 请求里,TCP 三次握手 + TLS 1.3 握手通常占 60~100ms,如果每个请求都重新建连,并发再高也是浪费。
aiohttp.ClientSession内部维护一个TCPConnector,默认limit=100(总连接数上限)、limit_per_host=0(单域名不限)、开启 keep-alive。这意味着对同一域名的第 2 个到第 N 个请求,可以直接复用已建立的 TLS 连接,把 280ms 压到 180ms 左右。这也是为什么ClientSession必须全局复用,绝不能每个请求 new 一个——那样连接池形同虚设,还会因为 fd 泄漏把进程拖死。
连接池的另一个好处是它天然承担了"背压"的一部分职责。当并发请求数超过limit时,后续的session.get()不会立即发出,而是挂起等待连接释放。这相当于在传输层给系统装了一个隐形的限流阀,避免瞬间打满 fd 上限或者被目标站点的 WAF 判定为攻击。
需要特别注意limit_per_host这个参数。默认值0表示"不限制单域名并发",看起来很美,但对单站点采集时极其危险:你可能一瞬间对同一个域名发起几百个连接,轻则被限流返回 429,重则直接触发 IP 封禁。生产环境里,永远显式设置limit_per_host,这是一个几乎零成本、收益极高的保险。
1.3 用 Little’s Law 估算你的吞吐上限
排队论里的利特尔法则给出一个粗糙但好用的模型:
吞吐 X (req/s) = 并发数 N / 平均延迟 L (s)- 串行:N=1,L=0.28s → X ≈ 3.6 req/s,十万 URL 需要7.7 小时;
- 异步 N=200,若 L 因排队上升到 0.62s → X ≈ 322 req/s,十万 URL 约5.2 分钟。
注意后半段:延迟 L 不是常数。并发上去后,服务端排队、带宽打满、DNS 阻塞都会让 L 上升,所以吞吐不会随 N 线性增长,最终会撞到某个拐点。调优的目标不是把并发开到最大,而是找到 L 开始非线性上升之前的那个 N。
这个模型最大的价值在于:它把"要不要加并发"变成了一个可以量化的问题。你只需要做一轮简单的压测——分别用 N=50、100、200、400 跑固定数量的 URL,记录实际耗时和成功数——就能画出自己环境下的吞吐曲线。如果 N 从 200 加到 400,吞吐只提升了 10%,说明你已经过了拐点,继续加并发只是在制造失败请求。
二、最小可用骨架:从 requests 迁移到 aiohttp
先看一个能跑起来的最小版本,重点观察三处差异:会话复用、并发聚合、超时配置。
importasyncioimporttimeimportaiohttp URLS=[f"https://example.com/item/{i}"foriinrange(1000)]asyncdeffetch(session:aiohttp.ClientSession,url:str):# 注意:必须在 async with 内部读完 body,否则连接不会归还连接池asyncwithsession.get(url)asresp:resp.raise_for_status()html=awaitresp.text()returnurl,len(html)asyncdefmain():connector=aiohttp.TCPConnector(limit=200,# 全局连接数上限limit_per_host=20,# 单域名并发上限,保护目标站点也保护自己ttl_dns_cache=300,# DNS 结果缓存 5 分钟,避免重复解析enable_cleanup_closed=True,)timeout=aiohttp.ClientTimeout(total=15,connect=5,sock_read=10)t0=time.perf_counter()asyncwithaiohttp.ClientSession(connector=connector,timeout=timeout,headers={"User-Agent":"Mozilla/5.0 (compatible; MyCrawler/1.0)"},)assession:tasks=[asyncio.create_task(fetch(session,u))foruinURLS]results=awaitasyncio.gather(*tasks,return_exceptions=True)ok=sum(1forrinresultsifnotisinstance(r,Exception))print(f"{ok}/{len(URLS)}成功, 耗时{time.perf_counter()-t0:.2f}s")if__name__=="__main__":asyncio.run(main())三个细节值得单独强调:
return_exceptions=True:不加这个参数,任何一个任务抛异常都会让gather整体失败,前面跑完的结果全部丢失。加了之后异常作为返回值出现,由调用方统一归类处理。async with内读完 body:session.get()返回时响应头已到,但 body 还在流里。提前退出async with会触发连接 release 而非 reuse,连接池里的连接会逐渐被"半消费"状态污染。- 三层超时:
connect管握手,sock_read管两次读之间的间隔,total兜底。只设total会让慢速攻击型的服务端拖住连接直到总超时。
这个骨架有个致命问题:1000个 URL 一次性create_task,如果换成十万个,光 Task 对象就吃掉几百 MB,而且没有任何背压——生产者速度远大于消费者。生产环境必须换成队列模型。
顺便说一句迁移时的常见习惯:很多从requests过来的同学会保留requests.get(url, timeout=10)的写法,然后在aiohttp里只写session.get(url)而忘了超时。aiohttp默认total=300s,一个卡死的连接能挂你五分钟。超时不是可选项,是异步爬虫的生存前提——在同步模型里,一个慢请求只是拖慢一个请求;在异步模型里,一个不超时的慢请求会长期占据一个连接名额,最终把整个连接池耗光。
三、实战案例:带限流、重试、背压的生产级采集器
下面这套结构是笔者在多个采集项目中反复验证过的:固定数量的 worker 协程 + 有界队列 + 信号量限流 + 指数退避重试。
importasyncioimportrandomimportaiohttpfromcollectionsimportdefaultdictfromdataclassesimportdataclass@dataclassclassStats:ok:int=0fail:int=0retried:int=0classAsyncCrawler:def__init__(self,concurrency:int=128,per_host:int=8):self.sem=asyncio.Semaphore(concurrency)self.per_host=per_host self.host_sems:dict[str,asyncio.Semaphore]=defaultdict(lambda:asyncio.Semaphore(self.per_host))self.stats=Stats()asyncdeffetch(self,session,url:str,max_retry:int=3)->str|None:host=url.split("/")[2]forattemptinrange(max_retry):# 信号量要包住"建连 + 读 body"的全过程asyncwithself.sem,self.host_sems[host]:try:asyncwithsession.get(url)asresp:ifresp.status==429:wait=float(resp.headers.get("Retry-After",2**attempt))awaitasyncio.sleep(wait)continueifresp.status>=500:raiseaiohttp.ClientResponseError(resp.request_info,resp.history,status=resp.status)resp.raise_for_status()html=awaitresp.text()self.stats.ok+=1returnhtmlexcept(aiohttp.ClientError,asyncio.TimeoutError):self.stats.retried+=1ifattempt==max_retry-1:self.stats.fail+=1returnNone# 指数退避 + 抖动:避免所有失败请求在同一时刻集体重试awaitasyncio.sleep(2**attempt+random.random())returnNoneasyncdefworker(self,session,queue:asyncio.Queue,results:list):whileTrue:try:url=queue.get_nowait()exceptasyncio.QueueEmpty:returntry:html=awaitself.fetch(session,url)ifhtmlisnotNone:results.append((url,len(html)))finally:queue.task_done()asyncdefrun(self,urls:list[str],workers:int=64):queue:asyncio.Queue=asyncio.Queue(maxsize=10000)# 有界队列形成背压results:list=[]connector=aiohttp.TCPConnector(limit=200,limit_per_host=self.per_host,ttl_dns_cache=300)timeout=aiohttp.ClientTimeout(total=15,connect=5,sock_read=10)asyncwithaiohttp.ClientSession(connector=connector,timeout=timeout)assession:# 生产者:把 URL 塞进队列,队列满时自动 await 等待asyncdefproducer():foruinurls:awaitqueue.put(u)prod_task=asyncio.create_task(producer())workers_tasks=[asyncio.create_task(self.worker(session,queue,results))for_inrange(workers)]awaitprod_taskawaitasyncio.gather(*workers_tasks)print(f"ok={self.stats.ok}fail={self.stats.fail}"f"retried={self.stats.retried}collected={len(results)}")returnresults这段代码里有几个关键设计点,值得逐条拆解:
第一,双信号量结构。self.sem是全局并发闸门,self.host_sems[host]是单域名闸门。两者用同一个async with一起进入,意味着一个请求必须同时拿到"全局名额"和"该域名名额"才能发出。这样可以避免对单一站点过度施压,同时又不会因为某个慢域名把全局并发名额全部占死。
第二,信号量包住了读 body 的过程。注意async with self.sem的位置是包在async with session.get(url)的外层,而不是只包住建连。因为连接在 body 读完之前是不会归还的,如果信号量只包住session.get(),那么大量请求会在"持有连接、等待 body"的状态下突破并发限制。
第三,Retry-After头优先于指数退避。遇到 429 时,如果服务端明确告诉你"X 秒后再来",就老老实实等 X 秒;没有这个头时才用2 ** attempt的指数退避。这是对目标站点的基本尊重,也是避免被封的实用手段。
第四,退避加入随机抖动。2 ** attempt + random.random()里的random.random()看似无关紧要,实则是防止"重试风暴"的关键。如果一批请求同时失败,没有抖动的话它们会在同一时刻集体重试,形成新的尖峰;加了抖动后,重试时间被打散,压力被摊平。
第五,有界队列即背压。asyncio.Queue(maxsize=10000)让生产者在队列满时自动await阻塞,从而限制了内存占用。如果队列无界,生产者可以瞬间把百万 URL 塞进去,内存直接爆炸。
第六,固定 worker 数量而非按 URL 数量建 Task。worker 协程数量是常量(如 64 或 128),每个 worker 循环从队列取任务。这样即使 URL 有百万条,Task 对象数量也稳定在几十到几百,内存占用可控。
第七,queue.task_done()放在finally里。即使fetch内部抛出未捕获异常,也保证队列计数正确,不会让join()永远挂住。虽然本例里用的是get_nowait轮询而非join(),但保留这个习惯能让你在未来改成阻塞式消费时少踩一个坑。
四、常见问题(FAQ)
Q1:为什么我的异步爬虫比同步还慢?
最常见的原因是在协程里写了阻塞代码。典型场景包括:用time.sleep()而不是await asyncio.sleep();用requests而不是aiohttp;在解析 HTML 时用了大量 CPU 密集操作(复杂的 XPath、正则回溯);同步写文件、同步访问数据库。任何一次阻塞都会让整个事件循环停摆,所有并发瞬间退化成串行。排查方法很简单:用asyncio的 debug 模式(asyncio.run(main(), debug=True)),它会打印出执行时间超过 100ms 的回调,直接定位阻塞点。
Q2:gather和as_completed该用哪个?
gather适合"我关心全部结果,且结果顺序重要"的场景,它按传入顺序返回。as_completed适合"谁先完成先处理谁"的流式场景,比如边采集边入库、边采集边写文件。当 URL 数量达到十万级时,gather需要把所有结果攒在内存里才能返回,内存压力大;as_completed可以边完成边消费,内存曲线更平缓。实战中推荐:中小规模用gather,大规模用队列 +as_completed或 worker 模式。
Q3:协程里能不能用多线程/多进程?
可以,但要分清边界。CPU 密集的解析任务可以丢进loop.run_in_executor(ThreadPoolExecutor(), fn),把计算挪出事件循环;如果解析特别重,可以用ProcessPoolExecutor。但要注意:executor 里的函数不能是协程,也不能跨进程传不可序列化的对象。另外,run_in_executor返回的是 future,需要await,且它本身会占用一个线程,别在协程里滥用。
Q4:为什么明明设置了limit=200,实际并发上不去?
检查三个地方:一是limit_per_host是否把单域名卡死了(比如limit_per_host=8,那你对单站点的有效并发就是 8);二是目标站点的响应是否变慢,导致连接被长时间占用;三是sock_read超时是否过短,导致大量请求在读到一半时被中断,连接反复重建。用connector.limit和实际in-flight数量对比,能快速定位。
Q5:aiohttp 报 “Connector is closed” 是什么原因?
几乎都是ClientSession的生命周期管理问题。典型写法是在函数里async with aiohttp.ClientSession() as session,函数返回后 session 关闭,但还有协程持有它去发请求。正确做法是在main级别创建 session,通过参数传给所有 worker,等所有任务结束后再退出async with。这就是本文骨架里 session 在最外层创建的原因。
Q6:异步爬虫还需要遵守 robots.txt 和限速吗?
需要,而且比同步更重要。异步让你很容易在几秒内向一个站点发出上千请求,这种"瞬时压力"对小型站点是灾难性的,也最容易触发封禁。建议:默认对单域名限速(比如每秒 5~10 个请求),尊重Retry-After,遇到 403/429 立即降速。技术上可以用asyncio.Semaphore之外再加一个基于时间的令牌桶,或者直接依赖limit_per_host配合sock_read超时形成自然节流。
五、踩坑清单与优化建议
踩坑一:连接泄漏导致进程卡死。如果async with session.get(url)内部因为异常提前退出,连接其实是可以正常归还的;但如果你用resp = await session.get(url)这种非上下文管理器的写法,一旦忘记resp.release()或resp.close(),连接就永远挂在池子里。坚持用async with是最省心的做法。
踩坑二:DNS 解析成为隐藏瓶颈。aiohttp底层用getaddrinfo,在部分系统上是同步阻塞调用,会短暂卡住事件循环。开启ttl_dns_cache能大幅缓解;如果域名数量巨大且分散,考虑用aiodns加速器(aiohttp支持AsyncResolver)。
踩坑三:asyncio.run()只能调用一次。在 Jupyter、某些 Web 框架、以及已有事件循环的环境里,直接调asyncio.run()会报 “event loop is already running”。此时应该用await main()或loop.run_until_complete(),并注意不要嵌套创建事件循环。
踩坑四:日志和监控在异步下会失真。多个协程交错打印日志,时间戳容易混淆;计数器的自增在单线程里虽然安全,但如果引入run_in_executor就会产生竞态。建议用结构化日志(带url、attempt、elapsed字段),并对Stats的更新集中在事件循环线程内完成。
优化建议一:把解析也异步化。HTML 解析本身是 CPU 操作,无法"异步",但你可以控制它在哪执行。简单解析直接在协程里做(只要足够快),重型解析丢进 executor。关键是别让一个页面的解析时间超过一个请求的耗时。
优化建议二:用aiofiles或批量写入处理落库。同步的open().write()会阻塞事件循环。小规模用aiofiles,大规模用"内存攒批 + 定时 flush",让 I/O 不在关键路径上。
优化建议三:给整个采集任务加一个"看门狗"。用asyncio.wait_for(main(), timeout=3600)包住主流程,防止某个 bug 让任务无限挂起。同时记录每个 worker 的in-flight数量和平均延迟,这些指标比总吞吐更能反映系统健康度。
结语
把同步爬虫改成异步,核心从来不是背几个async/await语法,而是理解"等待"这件事的真实成本,然后用事件循环把它摊平。
更多硬核网安与AI工具包,请扫码获取完整源码!
本文从 epoll 到连接池、从 Little’s Law 到队列背压、从最小骨架到生产级限流重试,给出了一套可以直接抄作业的结构。落地时请记住三句话:session 要复用、超时必配置、并发要限流。做到这三点,十倍吞吐只是起点,真正的收益是你终于可以按"站点"而不是按"机器"来规划采集能力。