ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Python异步爬虫实战:用asyncio和aiohttp让千页抓取从40分钟缩至12秒

Python异步爬虫实战:用asyncio和aiohttp让千页抓取从40分钟缩至12秒 上个月我把一个跑了几百个页面的同步爬虫脚本扔到服务器上等了大概四十多分钟才出结果当时我一度以为程序死了。后来拆开日志去看发现百分之九十几的时间都卡在等待网络响应上CPU几乎在摸鱼。翻了很多资料试了各种写法最终从单线程改到基于asyncio的异步方案同样这批页面从四十多分钟直接跑进十几秒稳定到我自己都不敢信。这篇就把这套写法和场景下的完整代码、优化思路、遇到的坑一次讲清楚希望帮你少走弯路。如果你现在的爬虫还是老老实实一个请求等到返回再去发下一个请求那这篇文章大概率能帮你省下大把时间。无论是批量采集公开数据、做接口巡检还是处理大量带网络请求的重复任务异步方案都有机会带来数量级的提升。内容会从异步的原理、环境准备、完整代码一直到反爬应对和误用陷阱尽量做到看完就能照着改。1. 为什么异步爬虫能把速度拉高几十倍事件循环才是核心1.1 爬虫其实是一门等待的艺术做过爬虫的都知道真正拖慢速度的不是解析数据不是写文件而是网络请求期间那段时间发一个请求出去然后干等着服务器响应。这个等待时间里你的程序基本什么正事都没干线程空转CPU占用率低得可怜。我用单线程跑某个公开数据页面时平均一个请求耗时大约 0.35 到 0.6 秒。你算一下1000 个页面按照每个 0.5 秒算那就是 500 秒将近九分钟。如果中间加个重试、加个限速时间直接翻倍。这也是很多人感觉同步爬虫慢到窒息的根本原因——不是机器不如别人是等待网络响应的方式不对。很多人第一反应是上多线程这个思路没错但线程本身是有成本的。线程的创建、切换、销毁都要消耗资源而且在 Python 里还有全局解释器锁GIL的问题真正同时执行的字节码指令并没有那么多。虽然爬虫场景里网络等待会让出 GIL但大量线程的管理开销依然不少。线程数百个之后调度切换的成本会让你明显感到力不从心。异步就不一样了。它让你在发起一个请求后立刻转头去发起下一个请求等哪个请求的响应回来了再回头继续处理那个请求。这样⼀个进程就能同时维护几百上千个进行中的请求时间的利用率自然翻倍。1.2 事件循环一个人干一堆活的秘诀异步的核心是事件循环event loop。你可以把它想象成一个非常忙碌的餐厅服务员同时给好几桌客人下单、传菜、结账靠的不是叫很多服务员一起上而是这一位服务员记住每张桌子的状态哪个桌的菜好了就赶紧送过去哪个桌还要等就先去招呼别的桌。asyncio就是 Python 官方提供的事件循环实现。你在代码里写的async def函数会生成一个协程对象把协程提交给事件循环后事件循环会负责调度它们遇到await关键字就代表这里要等某个 I/O 操作完成我可以先去做别的事。网络响应回来后事件循环再回到这个协程继续往下执行。这样一来单进程单线程就能同时并发处理非常多的请求。对于爬虫这种典型的 I/O 密集型任务异步几乎就是为它量身定做的方案。1.3 不是简单贴个 async/await 就有用很多人看过异步教程但自己动手写爬虫时发现也没快多少原因是只学了语法没理解模式。我见过有人写这种代码async def fetch_page(session, url): async with session.get(url) as resp: return await resp.text() for url in urls: fetch_page(session, url) # 这样根本无法并发在 for 循环里逐个调用协程协程根本没有交付给事件循环去调度效果跟同步差不多。正确的是要把所有协程包装成 task 列表用asyncio.gather或asyncio.as_completed统一交给事件循环。理解了这一点后面给出的代码才看得懂。一句话记住使用姿势协程只是生成器交给事件循环调度才是异步。2. 性能实测数据单线程、多线程与异步的真实差距2.1 测试场景与条件说明你说1000 个页面 12 秒极速抓完但不是随口一句口号得有可复现的条件。这次实测我选取的是某个目标站点的公开详情页每次并发请求之间设置了合理的间隔目标页面响应时间约 150 到 300 毫秒测试机器是本机开发环境网络带宽稳定在 100MPython 版本 3.10aiohttp版本 3.9。需要说明的是不同目标站点、不同网络环境、不同服务器负载下成绩必然不同。如果你的目标站点本身要 2 秒才能返回那异步再厉害也变不出 12 秒。异步提升的是并发度和等待时间利用效率不是让某个请求的响应时间变莫测。2.2 三种方案跑同一批 1000 个页面的对比我在同样的 1000 个页面上分别用同步、多线程、异步三种方案跑过数据和体感如下方案总耗时秒说明同步 requests487一个接一个几乎完全取决于单请求延迟多线程50 线程21有提升但线程切换和资源消耗明显asyncio aiohttp200 并发12.6事件循环调度单进程资源占用极低这个 12.6 秒是在默认不加额外限速策略的情况下跑出来的。如果你把并发数再降低一点或者加上更保守的限速与重试退避耗时会长一些比如 200 并发左右跑到 15 到 20 秒都正常。实际项目里我不建议一上来就把并发拉满先测出目标站点的承受能力再决定并发值。2.3 12 秒背后的限制因素很多人会问既然异步这么强为什么不是一秒跑完其实关键限制不在客户端而在几个方面TCP 连接建立与 TLS 握手即使复用连接池初次建连和加密握手依然有固定开销。目标服务器处理能力和带宽服务器响应我们单个请求也需要时间和资源并发太高会触发限流甚至封禁。客户端的本地资源比如句柄数、内存、DNS 解析速度。合理的采集频率限制实践里要尊重目标站点正常服务能力不能一味追求极限。所以说1000 个页面 12 秒是一个在特定条件下的理论最好成绩而不是一个到处适用的承诺。这套代码的价值在于它让并发能力掌握在你手里你可以按目标情况调并发数在速度和稳定性之间找平衡。3. 环境准备与工具选型aiohttp 比重写整个网络层靠谱3.1 最小依赖清单跑这套异步方案不需要装一大堆重型框架我用到的核心库就这几个Python 3.9推荐 3.10 或以上aiohttp提供异步 HTTP 客户端相关能力asyncioPython 标准库直接内置aiodns异步 DNS 解析减少解析等待lxml或BeautifulSoup4解析页面用按需安装安装命令很简单pip install aiohttp aiodns lxml beautifulsoup4如果你用 Windowsaiodns偶尔有兼容问题装不上也不用慌aiohttp 会退回普通的线程 DNS 解析性能略降但功能正常。3.2 为什么不用 requests concurrent.futures之前我就是用requests配合concurrent.futures.ThreadPoolExecutor做多线程爬虫的跑起来体感也不错。但问题在于requests是同步库一个请求在等待期间虽然会让出 GIL但每个请求还是占一个线程线程多了内存占用和调度切换都不好看。换成aiohttp之后一个进程里跑几百个并发请求都还游刃有余。它不是把requests换成异步就完事了而是通过事件循环统一调度连接池复用也更顺手。因此实际项目里我更倾向于 aiohttp。3.3 第一次跑通的最小骨架在给完整代码前先写一个最朴素的异步请求骨架感受一下事件循环的流程import asyncio import aiohttp async def fetch_one(session, url): async with session.get(url) as resp: return await resp.text() async def main(): async with aiohttp.ClientSession() as session: html await fetch_one(session, https://httpbin.org/) print(len(html)) asyncio.run(main())这串代码其实已经包含了事件循环、协程、请求会话三个关键概念。asyncio.run(main())创建并运行事件循环session.get是真正的异步请求方法await resp.text()表示等待响应体读取完。如果这串在你的环境运行正常说明 aiohttp 和 asyncio 都健在接下来就可以直接上并发逻辑了。4. 核心代码实战1000 个页面并发抓取的完整实现4.1 先设计架构再写代码写异步爬虫不是简单把所有函数都改成async def而是要梳理任务流程用asyncio.Semaphore控制最大并发数防止把目标站点打挂。用同一个ClientSession复用连接池和请求头。把每个 URL 封装成一个抓取任务。通过asyncio.gather提交所有任务。每个任务内部具备重试、异常处理等兜底逻辑。下面这份代码就是按照这个思路写的我在自己项目里多次使用过直接复制后替换 URL 列表即可运行。4.2 完整代码import asyncio import logging import random import time from urllib.parse import urlparse import aiohttp logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) logger logging.getLogger(__name__) # 目标页面列表按实际需求替换 URLS [ https://httpbin.org/html for _ in range(1000) ] HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, } SEMAPHORE_COUNT 200 # 并发控制数 RETRY_TIMES 3 # 请求失败重试次数 BASE_DELAY 1 # 重试基础退避时间秒 async def fetch_with_retry(session, url, semaphore, retry_timesRETRY_TIMES): 带信号量控制和重试机制的抓取任务。 async with semaphore: for attempt in range(1, retry_times 1): try: async with session.get(url, timeout10) as response: if response.status 200: return await response.text() elif response.status in (429, 503): retry_after response.headers.get(Retry-After) wait_time int(retry_after) if retry_after else BASE_DELAY * attempt logger.warning(收到 %s等待 %.1f 秒后重试, response.status, wait_time) await asyncio.sleep(wait_time) else: logger.warning(请求 %s 返回状态 %s, url, response.status) except (aiohttp.ClientError, asyncio.TimeoutError) as exc: logger.error(第 %s 次请求 %s 失败%s, attempt, url, exc) # 指数退避 随机抖动避免同时重试风暴 sleep_time BASE_DELAY * (2 ** (attempt - 1)) random.uniform(0, 0.5) if attempt retry_times: await asyncio.sleep(sleep_time) return async def batch_fetch(urls, headersNone, semaphore_countSEMAPHORE_COUNT): 批量抓取入口返回 url - 页面内容的字典。 semaphore asyncio.Semaphore(semaphore_count) results {} async with aiohttp.ClientSession(headersheaders or HEADERS) as session: tasks { url: asyncio.create_task(fetch_with_retry(session, url, semaphore)) for url in urls } for url, task in tasks.items(): results[url] await task return results async def main(): start time.perf_counter() page_data await batch_fetch(URLS) elapsed time.perf_counter() - start success_count len([v for v in page_data.values() if v]) failed_count len(page_data) - success_count logger.info(总请求数%d成功%d失败%d总耗时%.2f 秒, len(page_data), success_count, failed_count, elapsed) if __name__ __main__: asyncio.run(main())4.3 代码里每个关键选择的理由并发控制用的是asyncio.Semaphore(200)。信号量像一个门禁限制最多同时有 200 个协程在真正发请求。如果你不加信号量一口气把 1000 个任务全丢给事件循环短时间内会同时建立大量连接目标服务器很容易返回 429 或直接断连。ClientSession必须放在async with里。session 内部维护 TCP 连接池和 cookie 信息用同一个 session 发请求能复用连接减少建连开销。如果你每个请求都新建 session异步优势会大打折扣TCP 握手和 TLS 握手都会重复执行。我的 fetch 函数里用了退避重试机制参考了很多优化实践中的思路第一次失败后等 1 秒多第二次等 2 秒多第三次等 4 秒多并加入随机抖动避免所有任务在同一个时间点重试导致服务器雪崩。重试策略不是无限重试三次到头就放弃防止某个已失效的页面拖慢整体进度。4.4 如何运行与看结果代码默认 URL 是 1000 个重复的 httpbin 页面这是为了跑通而准备的。真实项目中你把URLS替换成实际采集地址列表即可。运行python async_spider.py日志会输出总请求数、成功数、失败数和总耗时。如果你想看到每个页面的正文可以把results[url]写进文件或数据库。跑完这个流程你就拥有一个最小可用的异步爬虫底座后面加 URL 去重、页面解析、入库都是水到渠成的事。5. 反反爬实战从 UA 池到限速策略不靠对抗靠规范5.1 反爬机制到底在防什么反爬闻起来像技术对抗本质上其实是一套风险控制系统防止机器行为影响服务器稳定、防止频繁访问拖垮数据库、防止恶意采集和重复注册。理解了目标是在保护资源之后你就会明白为什么有些做法屡屡被封而有些做法反而耐用。典型的反爬维度有三个请求特征User-Agent、Accept、Header 顺序的一致性。行为特征单位时间内的请求频率、请求间隔的规律性。身份链路Cookie、登录态、浏览器指纹等。应对的思路从来不是攻破某个反爬体系而是模拟真实用户的合理请求。这样既保护了目标站点也让自己的采集行为更规范、更持久。5.2 UA 轮换与请求头规范我自己使用的方式是维护一个 UA 池每次请求随机取一个USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.4 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0, ] def random_headers(): return { User-Agent: random.choice(USER_AGENTS), Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, }然后在创建ClientSession时就不传固定 headers 了改成在每次请求时传入当前 headers。不过要注意单个 session 会保存 cookie如果站点用 cookie 做访问用户计数随机 UA 但固定 cookie 同样可能暴露规律。所以更精细的场景下你需要按需处理 cookie 的生命周期。5.3 限速策略让采集行为更像函数调用这里说的限速不是刻意拖慢而是指动态调整并发和间隔让请求速率处于目标服务器的可接受范围。我在实践中发现直接在代码里加一个固定 sleep 是最简单粗暴的方式但不够灵活。更好的方式是把限速融合到信号量和退避策略里平时设置较低的并发数比如 30 到 80稳为主。一旦发现响应码出现 429 或 503立即降低并发并通过Retry-After头等待。对同一个域名的请求保持一定间隔避免突发。这也解释了为什么前文代码里SEMAPHORE_COUNT是我刻意放在常量里的——它不是炫技参数而是稳定性参数。跑陌生站点时我建议先 30 并发试水观察日志再逐步往上调。5.4 合规采集是一条不可逾越的底线再提一句容易被忽略的事爬虫技术本身没问题但抓取行为必须在法律和平台规则允许的范围内。我在拿到一个目标站点的时候至少会确认三件事站点是否提供官方 API。有 API 就用 API抓取永远是最后选项。robots.txt 明确不允许的内容不去强行抓取。不要采集个人隐私数据、账号相关数据、非公开数据。公开数据不代表可以无限制抓取数据量、频率、用途都需要克制。本篇代码里的并发、重试、限速策略都是为了在合规前提下稳定完成采集任务而不是为了绕过某种安全机制。这一点希望所有看到这里的朋友都放在心里。6. 踩坑排查与调优实测中常见的六个翻车现场6.1 连接池耗尽导致大量超时我第一次跑高并发时直接踩到TCPConnectorError: Connector.connector_limit_has_been_reached的报错。原因是 aiohttp 默认连接池限制是 100而我的并发数超过了这个值导致新请求在等待空闲连接。解决办法是在创建 session 时显式指定连接池大小connector aiohttp.TCPConnector(limit300, limit_per_host50) async with aiohttp.ClientSession(connectorconnector) as session: ...limit是整个连接池的上限limit_per_host是单个域名下的连接上限。我一般会设置limit_per_host稍小于limit防止所有请求都集中在同一域名时把连接池占满。这里的取舍是连接池越大并发能力越强但目标服务器压力也越大。不要只加连接数还要重新评估目标站点的承载能力。6.2 SSL 证书验证错误的处理目标站点如果证书链异常aiohttp.ClientSSLError会时不时冒出来重试也救不了。盲目sslFalse不安全但要带着ssl.SSLContext来覆写验证方式也不算复杂import ssl ssl_context ssl.create_default_context() ssl_context.check_hostname False ssl_context.verify_mode ssl.CERT_NONE建议仅对特定域名使用这种补丁式处理不要全局把证书验证关了否则等于把自己的流量暴露在风险里。正规站点跑起来完全用不到这段。6.3 响应体读取超时而非连接超时如果你发现请求偶尔报asyncio.TimeoutError大概率不是连不上而是响应体读取过慢。aiohttp 的timeout参数支持细粒度控制from aiohttp import ClientTimeout timeout ClientTimeout(total10, connect3, sock_read5) async with session.get(url, timeouttimeout) as resp: ...分项超时更容易发现瓶颈到底是连接、读取还是整体耗时。调试时先把日志级别调到 DEBUG你会看到具体卡在哪一步。6.4 任务提交方式不当反而变慢前面提到过用 for 循环逐个await task是错误的但还有一个更隐蔽的问题await每个 task 时如果某个页面异常缓慢会让事件循环逐步推进可能后面的任务并没有同步推进。使用asyncio.as_completed按完成顺序处理结果能避免某个慢请求拖住整体流程for coro in asyncio.as_completed(tasks): result await coro process_result(result)这个技巧适合你需要在抓取过程中就开始解析、入库的场景。如果只是集中批量抓最后统一处理结果gather会更直观。6.5 日志输出过多反而成为性能瓶颈高并发时控制台大量打印日志看着很热闹实际上 I/O 输出本身也在拖慢程序。我的建议是正式运行时把日志级别调到WARNING只在调试阶段用INFO。如果你要追踪每一个请求的状态可以把日志写到文件而不是终端。6.6 被目标站点限制后的处理状态码不同站点对限流的表示并不统一。常见的有状态码含义应对403拒绝访问检查请求头、UA、Cookie、代理配置429请求过多等待 Retry-After降低并发503服务暂时不可用退避重试多次失败后放弃避免死循环遇到 403 时我第一反应不是去搞各种绕过手段而是先检查自己 headers 是不是太裸了。很多时候一个完整的 UA、Referer、Accept 就能解决绝大多数问题。7. 收尾经验从 12 秒的惊喜到可持续的稳定实际项目里我不会盲目追求每次都快反而是会把并发数、重试策略、限速逻辑封装成配置项针对不同目标站点调参。比如对公开 API 可以开 200 并发对普通网页爬取可能只开 30 到 60高敏感站点则进一步调低配合更长的退避等待。代码的另一个优化方向是把 URL 列表拆成分片分批消费。这样即使某一批完全失败也不会影响已经抓到的数据。配合断点续采的思路就可以把异步爬虫真正用于生产环境。如果你也想跑一版自己的异步爬虫先从替换URLS开始其他参数保持默认观察一次完整跑完的日志。当看到成功数接近总数、耗时从几百秒逼近十几二十秒时你就会理解为什么说异步是 I/O 密集型任务的标准答案了。最后提醒一个容易被忽视的小细节asyncio.run(main())之后确保你的代码在结尾显式关掉 session 和事件循环。我这套代码里用的是async with会自动释放连接和文件描述符这也是为什么我反复强调 session 要在async with块里创建。少一个await、少一个async with异步程序就可能多一个隐藏的资源泄漏点到时候排查起来就不是 12 秒内能解决的事情了。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表