aiohttp 高并发异步爬虫实战:从核心组件到工程化调优
发布时间:2026/10/6 17:06:00 锦皓数字建站

写异步编程上篇的时候评论区画风相当一致概念看懂了事件循环能画出来了Task 也敢用了但真要写一个爬虫还是顺手打开 requests 走老路。到了 Day 40 这个节点我不想再让异步停留在“会写 async/await”的层面索性把 aiohttp 拉出来做一期完整落地从 ClientSession、TCPConnector、超时管理这些核心对象到信号量限流、重试策略、任务调度这些工程细节最后用一套代码把千级 URL 的抓取时间压缩到原来的十分之一以内。这篇适合两类读者。一类是已经看完上篇、对协程有基本认识但没写过真实异步爬虫的人可以对照自己的代码查漏补缺另一类是只会 requests、很好奇异步爬虫到底快在哪的人直接把这篇文章当成入门实战来看也不吃力。我会从原理一路讲到压测尽量让每一步都能自己复现。1. 为什么选 aiohttp先弄明白阻塞发生在哪1.1 同步爬虫慢在“等”而不是慢在“下载”很多人第一次感受到同步爬虫慢是在遍历几千个 URL 的时候。表面上看起来是“下载慢”实际上慢的是“等待”。每次requests.get()发出之后线程就挂在那里等网络返回这段等待时间 CPU 是闲置的。一个 200ms 返回的页面等待占 199ms真正做事的不到 1ms。这里有个简单算术1000 个 URL每个平均响应 200ms同步循环就是 200 秒起步。你可能会想那用线程池不就行了线程确实能解决一部分问题开 20 个线程能把时间压到 10 秒左右但线程本身不是免费的。每条线程都有自己的栈空间和上下文切换成本开 200 个线程时调度器光切线程就要消耗不少 CPU。我把同步爬虫比作只有一个窗口的银行一个客户没办完下一个就只能在门外等着。线程池相当于多开了几个窗口但银行的大堂面积是有限的窗口不可能无限增加。异步模型换了一种玩法它不是一个窗口一个窗口地等而是让大堂经理同时盯着所有窗口哪个客户证件准备好了就处理哪个。事件循环做的就是大堂经理这件事。1.2 事件循环靠“让出”来换并发aiohttp 建立在 asyncio 之上核心就一句话await把当前协程挂起把执行权交还给事件循环事件循环去处理其他准备好的协程。整个过程只有一个线程没有锁竞争也没有线程上下文切换代价仅仅是维护一堆协程状态。听起来很玄其实落到爬虫场景里就是同一个单线程可以同时管理几十上百个 HTTP 连接。每个请求发起后都不干等而是告诉事件循环“这个 Socket 有数据了再叫我”。响应到达后对应协程被唤醒继续往下执行。这种“等待复用”的能力才是异步爬虫性能提升的根本原因不是 aiohttp 做了什么黑魔法只是它把原本白白浪费的等待时间拿来处理别的请求了。要注意异步只适合 I/O 密集型任务。如果你拿它做大量 CPU 计算比如在协程里跑一个需要几秒钟的复杂循环事件循环整个会被卡住其他所有协程都动不了。遇到重 CPU 任务还是得交给多进程或者asyncio.to_thread丢出去。1.3 requests 加线程不是不行但上限更低用ThreadPoolExecutor改造同步爬虫代码很简短也能获得不错的提速为什么不直接用from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers20) as pool: results list(pool.map(requests.get, urls))最大的问题在于控制力。线程池只控制“执行线程数”但对底层连接池、单个域名的并发数、DNS 缓存、超时细分都缺少精细管理。线程数一旦调大内存和上下文切换的开销会明显上升而这类开销对异步代码来说几乎可以忽略。另一方面requests 本身基于 urllib3虽然底层有连接池但每个线程各自持有连接复用不充分。aiohttp 则把所有请求收敛到一个ClientSession上连接池由全局统一管理TCP 握手、TLS 握手、DNS 解析这些开销都被摊薄。可以说 aiohttp 不是让单个请求变快而是让“大量请求同时进行”这件事变得更省钱、更可控。2. 核心组件拆解真正决定并发质量的是这些细节2.1 ClientSession一个会话对应一个连接池aiohttp 里最容易被用错的对象就是ClientSession。有人习惯每个请求都创建一个新会话这基本等于放弃了连接池的所有优势。每次创建会话都意味着新的连接池、新的 Cookie 容器TCP 连接无法复用每个请求都要重新握手。正确姿势是程序里只创建一个ClientSession所有请求共享它。async def main(): async with aiohttp.ClientSession() as session: tasks [fetch(session, url) for url in urls] await asyncio.gather(*tasks)你还可以在创建会话时传入统一请求头比如 User-Agent这样每个请求都默认带上不需要在每个get()里重复写session aiohttp.ClientSession( headers{User-Agent: Mozilla/5.0 (compatible; my-spider/1.0)} )这里有个反直觉的地方raise_for_status参数我反而建议保持默认 False。如果打开它任何 4xx、5xx 都会抛aiohttp.ClientResponseError在爬虫场景里404、403 是常见业务状态你往往想自己判断状态码而不是直接中断流程。2.2 TCPConnector连接池参数决定并发上限ClientSession管理逻辑连接TCPConnector负责真实的 Socket 连接。它有几个参数直接影响高并发行为。connector aiohttp.TCPConnector( limit100, limit_per_host20, ttl_dns_cache300, enable_keepaliveTrue, )limit100是全局最大并发连接数limit_per_host20是对同一个域名最大连接数。这两个参数必须配合使用。如果只设limit100而目标站只有一个域名爬虫会同时发起 100 个连接打向同一台服务器很快就会被限流甚至封禁。如果目标站有大量不同域名limit_per_host可以适当放宽让单域名也有更高的并发空间。ttl_dns_cache300是 DNS 缓存 300 秒。注意asyncio 的 DNS 解析是独立的线程池虽然不阻塞事件循环但解析本身有耗时。缓存命中后这部分等待就省掉了。爬虫场景下非常值得打开。keepalive 则让连接在请求完成后不立刻关闭下一个请求可以复用。频繁建立 TCP 连接是很大的开销尤其在 HTTPS 场景下TLS 握手成本比 TCP 握手高得多。还有一个隐藏点TCPConnector 限制的是“同时存在的连接数”而不是“协程数”。你完全可能创建 1000 个 Task但连接池只有 50这时多出来的协程会在连接池排队不会报错但调度开销和管理复杂度会上升。所以后面要提到的信号量仍然是必要的两者控制的是不同维度的东西。2.3 超时体系别让一个坏请求拖垮整个任务列表同步 requests 默认一直等下去在异步环境里这是致命的。一个请求挂了对应 Task 会一直占着协程资源和连接池中的连接数量积累起来整个爬虫就僵在那里。aiohttp 用ClientTimeout做精细超时控制。timeout aiohttp.ClientTimeout( total30, connect5, sock_read15, )total30是整次请求最长时间包含连接、发送、读取全部环节connect5是建立连接阶段最长等待sock_read15是两次读取 Socket 数据的最大间隔。实际调参时sock_read往往更能反映“连接还活着但数据迟迟不来”的状态。把total设成唯一超时如果目标页面有大体积流式响应很容易被误杀。高并发场景下我建议total设置在 15 到 30 秒之间。太长会让坏请求占坑过久太短可能误伤正常慢页面。如果任务重试次数多超时又太短结果是每个 URL 都快速失败整体成功率极低。注意不要用timeoutNone除非你对目标站响应速度有绝对把握否则这是最典型的“看起来能跑跑起来全卡住”的配置。3. 高并发爬虫工程化限流、重试、任务调度3.1 信号量限流把瞬时并发压到合理区间连接池控制的是 TCP 连接数但实际服务水平还取决于请求频率。同一个连接上可以连续发多个请求服务端依然可能因为“短时间内请求太密”而拒绝你。这就是信号量asyncio.Semaphore的用武之地。sem asyncio.Semaphore(20) async def fetch_with_limit(session, url): async with sem: return await fetch(session, url)async with sem的意思是进入时获取一个许可许可用完了当前协程就在原地等待直到其他协程释放。因为等待发生在协程层不会阻塞事件循环其他协程照常运行。把并发数调到多少没有一个放之四海而皆准的答案。我的经验是第一次先设 20跑一小批 URL观察目标站的响应时间和错误率。如果一切平稳再逐步加到 50、100。最忌讳的是上来就开 500 的并发你会看到自己的错误日志比正常结果还多。信号量、连接池、全局并发任务数是三个不同层面的控制不要以为设了一个就不用管另两个。3.2 重试机制网络错误和 5xx 要重试4xx 要尊重重试是所有网络请求类代码的必修课。aiohttp 本身不提供重试组件需要自己写好在逻辑并不复杂。关键是区分什么情况下重试网络异常比如ClientConnectorError、asyncio.TimeoutError可以直接重试。5xx 服务端错误可以重试但要退避。429 限流响应必须读取响应头里的Retry-After按它指定的时间等待后再试。404、403 等 4xx 错误通常不需要重试重试一万次结果也不会变。指数退避是最常见的重试策略每次失败后等待时间翻倍。async def fetch_with_retry(session, url, max_retries3): for attempt in range(max_retries): try: async with session.get( url, timeoutaiohttp.ClientTimeout(total15) ) as resp: if resp.status in (408, 429, 500, 502, 503, 504): retry_after resp.headers.get(Retry-After) wait_time float(retry_after) if retry_after else 2 ** attempt await asyncio.sleep(wait_time) continue if resp.status 400: return resp.status, return resp.status, await resp.text() except (aiohttp.ClientError, asyncio.TimeoutError): await asyncio.sleep(2 ** attempt) return -1, 注意Retry-After的值可能是秒数也可能是 HTTP 日期这个示例只处理了秒数的情况。生产环境还需要解析日期格式否则会抛ValueError。另外重试只对 GET 这类幂等请求安全如果发的是 POST 或修改类请求重试可能会产生重复副作用一定要小心。3.3 任务调度gather 与 as_completed 的选择创建 Task 的常见写法是列表推导式加asyncio.gather。tasks [fetch_with_retry(session, url) for url in urls] results await asyncio.gather(*tasks, return_exceptionsTrue)列表推导式本身不会执行请求它只是创建协程对象。gather把协程包装成 Task 后真正启动。这里有一个新手常踩的坑如果没有设置并发上限10000 个 URL 会瞬间创建 10000 个协程对象内存和调度压力很大。信号量只限制“正在运行的请求数”但协程对象本身还是全部被创建了。所以大规模抓取时最好分批调度。return_exceptionsTrue让 gather 把异常当作结果返回而不是中断整个任务组。这样单个请求失败不会影响到其他 999 个请求。如果你需要在请求过程中就实时处理结果比如打印进度、把成功数据落库用as_completed更合适。from tqdm import tqdm async def crawl_site(urls): tasks [fetch_with_retry(session, url) for url in urls] for future in tqdm(asyncio.as_completed(tasks), totallen(tasks)): status, body await future # 到这里时某个请求刚完成可以立即处理as_completed不是按列表顺序返回而是谁先完成谁先交结果。处理结果的代码写在循环里更新进度条会非常顺滑。3.4 响应释放与数据落盘别把连接泄露在代码里aiohttp 的响应对象如果不把内容读干净连接就不会归还给连接池。正确姿势是使用async with包住session.get的返回值离开作用域时自动释放。async with session.get(url) as resp: text await resp.text()如果你只写了resp await session.get(url)又不调用resp.text()、resp.read()或resp.release()那个连接就一直被这个协程占着。协程结束确认后连接在垃圾回收时才会关闭但垃圾回收不可靠在高并发下连接池最终会被耗尽。数据落盘方面我的经验是爬取结果如果只是文本规模在几百 MB 以内直接攒在内存里最后用普通文件写入一次性落盘比每次请求都写文件快得多代码也更简单。只有当数据量大到内存撑不住或者下载的是大文件才考虑用aiofiles做真正的异步写磁盘。还需要注意事件循环里不要做重 CPU 操作。如果你拿到 HTML 后要立即做复杂解析比如正则匹配几万次建议把解析逻辑丢给asyncio.to_thread保持事件循环的响应性parsed await asyncio.to_thread(parse_html, html)4. 压测与排查1000 个 URL 的实际表现4.1 压测环境与对照组为了给你一个可参考的数据我在本机起了一个模拟服务每个请求固定延迟 200ms模拟真实页面的响应速度。对 1000 个 URL 跑了四种方案。方案1000 个请求总耗时说明requests 串行约 210s简单直接代码最少requests 20 线程约 15s线程池提升了并发但内存占用上升aiohttp 并发 20约 14s单线程连接池复用aiohttp 并发 100约 7s速度最快但也开始出现服务端拒绝连接必须说明这是本地服务的理想数据公网环境会受到网络抖动、目标站限流、DNS 解析等因素影响数字会明显变化。但这个对比能看清一个事实aiohttp 的提速空间是实实在在的而且可以通过并发度参数平滑调整不会像线程池那样开大就内存爆炸。并发 100 那组出现了约 1.5% 的连接被服务端拒绝。这提醒我注意快不代表好生产环境盲目调高并发只会让错误率和封禁风险同步上升。我更倾向于把并发控制在 50以较小的速度牺牲换取更高的成功率。4.2 常见异常速查表实际跑爬虫时你大概率会遇到下面这些异常。我把它们整理成一份速查表方便对照。异常常见原因处理建议ClientConnectorError域名解析失败、端口不通、连接被拒绝检查 URL 拼写、目标服务状态、网络连通性配合connect超时ServerDisconnectedError服务端主动断开连接常见于并发过高触发限流降低信号量或者limit_per_host加退避重试ClientPayloadError响应体不完整可能是压缩或传输问题检查响应的Content-Encoding必要时改用 HTTP/1.1asyncio.TimeoutError请求时间超过ClientTimeout配置合理放大total或者用sock_read做更细粒度控制UnicodeDecodeErrorresp.text()自动解码判断错误显式指定resp.text(encodinggb18030)或按页面 charset 指定ClientOSError操作系统层面的网络错误大概率也是并发太高降并发、加重试排查这类问题有个基本顺序先看是不是“并发太高”导致的再排除网络和 DNS最后检查代码层面有没有释放连接。很多诡异问题最后都指向同一个原因代码里某个地方没有用async with包住响应对象。4.3 并发、超时、连接池三者的调参联动这三者不是孤立参数而是互相影响的系统。信号量并发高但total超时太长失败请求会长期占坑Task 堆积越来越严重。连接池limit大信号量小连接数上限形同虚设因为协程根本不会同时跑到连接上限。超时太短遇到慢页面就会误杀重试次数又不够的话成功率直线下降。我提供一组比较稳妥的起步配置connector aiohttp.TCPConnector(limit50, limit_per_host10) timeout aiohttp.ClientTimeout(total15, connect5, sock_read10) sem asyncio.Semaphore(20)先用这组配置跑 100 个 URL 做预热观察平均响应时间和错误率。如果响应时间很低且没有错误再逐步把sem提高到 50connector.limit提高到 100。如果出现ServerDisconnectedError或者大量 429就把sem降回去。调参过程遵循一个原则先求稳定再求速度。一个稳定跑 8 秒的方案好过一个最快 3 秒但随时会炸的方案。5. 工程化心得我踩过的几个坑5.1 别把同步代码包在 async 函数里我见过不少初学者写出这样的代码async def get_page(url): return requests.get(url)表面上看是异步函数实际上requests.get是同步阻塞调用它会阻塞事件循环的整条线程。只要并发量为 1其他协程全部卡死整个“异步爬虫”退化成了同步爬虫。正确做法要么是换成 aiohttp 的异步请求要么用asyncio.to_thread把它丢到线程池但后者只是兼容手段不是异步解药。这个坑的本质是没有理解异步的换手机制协程只有在await挂起点才会让出控制权同步代码没法挂起。所以写异步代码时凡是可能阻塞的操作都要问一句它是 I/O 密集还是 CPU 密集I/O 密集就用异步库或to_threadCPU 密集就交给多进程。5.2 爬得快不如爬得稳异步爬虫真正考验人的不是并发参数而是目标站的承受能力。我在实际项目里总结过几条硬经验爬取前先看目标站的robots.txt尊重它声明的抓取规则。这既是技术习惯也是工程素养。频率宁低勿高。普通站点 20 并发已经很快大多数内容型站点完全够用。把失败 URL 记录下来支持断点续爬。不要在程序重启后把已经抓到的内容再抓一遍。日志里记录状态码、响应时间、重试次数这些数据能帮你定位是不是被限流。断点续爬的做法不复杂把失败 URL 写到一个文件里with open(failed.txt, w, encodingutf-8) as f: for url in failed_urls: f.write(url \n)下次启动时读回这个文件只处理上次没成功的 URL。这个能力在大量抓取场景里几乎必备因为网络不可能全程无故障总有少量请求会失败。5.3 这个系列还能继续往前走写到这里Day 40 的一个完整闭环算是跑通了从 requests 同步脚本到 aiohttp 高并发爬虫再到限流、重试、调度、断点续爬这些工程细节。往后的路还能延伸到异步文件下载、WebSocket 实时数据抓取、FastAPI 接口封装或者把抓到的数据通过异步数据库驱动写入 PostgreSQL 这类存储里这些都是异步编程自然延伸的方向。我个人实际做项目的体会是异步爬虫的难点从来不在语法而在于建模。你有没有把“等待”视为一种可调度的资源有没有为限流、重试、超时、失败续爬建立完整预案才是决定一个爬虫能不能长期稳定跑下去的关键。aiohttp 只是把工具交到你手上节奏还是得你自己控制。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。