ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

aiohttp高并发爬虫实战:从requests到异步提速的完整指南

2026/10/6 17:06:05 拓冰建站 浏览量
aiohttp高并发爬虫实战:从requests到异步提速的完整指南 上一期把 asyncio 的事件循环、协程、await 这些基础概念捋了一遍评论区很多朋友说“懂了但不知道在项目里怎么用”。这期正好是异步编程的下篇咱们就干一件最实在的事用 aiohttp 写一个高并发爬虫。还是那句老话不整虚的直接上代码、上数据。这个实战的典型效果是抓取 20 个服务器处理时间各 1 秒的页面同步 requests 串行大概要 21 秒而用 aiohttp 并发起来能压到 3 秒左右。今天这一篇是 Day 40属于【99天精通Python】系列我会把从 requests 转向 aiohttp 的思维转变、ClientSession、Semaphore、连接池配置、超时重试这些细节全部摊开讲。如果你是那种已经会用 requests 写简单爬虫但觉得线程池别扭、GIL 让人头疼或者刚学完 asyncio 还一脸懵的读者这篇文章就是为你准备的。看完你可以直接把代码骨架拖到自己项目里改文末还有一份压测对比和一份避坑清单全是我实际踩过的坑。1. 为什么需要 aiohttp从同步到异步的痛点1.1 requests 爬虫的瓶颈到底在哪先说一个所有爬虫开发者都经历过的场景用 requests 写了个循环抓取 500 个详情页的任务跑起来之后只能盯着进度条干等。为什么会慢因为 requests 是同步阻塞库它的执行模型是发一个请求、等响应、再发下一个请求全程串行。有人可能会说那用线程池不就行了确实requests ThreadPoolExecutor 能解决一部分问题但有两个硬伤让它不是最优解。第一线程本身有开销开 50 个线程就有 50 个线程栈和上下文切换成本虽然 Python 的 GIL 让 CPU 密集型任务没法真正并行而爬虫是 IO 密集型受影响相对小但线程调度成本依然肉眼可见。第二代码写起来别扭回调、锁、队列一个不少出问题以后尤其是超时重试、批量取消这些操作处理起来非常费劲。这里可以打个比方requests 串行抓取就像餐厅只有一个服务员每桌客人从点菜到吃完他都得全程伺候线程池相当于雇了十个服务员看着热闹但大厅总共一个厨房出菜口服务员大部分时间都在干等上菜。而异步编程的逻辑是服务员把菜单往厨房一递不用站在灶台前等菜转身就去接待下一桌等菜好了厨房喊一声再端过去。这中间的“等待时间”就被充分利用起来了这也是高并发爬虫提速的本质。1.2 异步的心智模型事件循环就是那个调度员理解了上面的比喻再看 asyncio 就顺畅多了。核心就是事件循环event loop它像一个调度员手里维护着一个任务队列。当一个协程执行到 await 时说明它正在等待一个 IO 操作比如 HTTP 请求调度员就会立刻把它挂起转去执行其他协程。等 IO 操作完成了调度员再回来把它唤醒继续往下跑。这里千万要注意一个认知异步不是让任务跑得更快而是让等待的时间里不闲着。1 秒的服务器处理时间同步和异步都得等但同步是所有人排成一队干等异步是大家一起把请求发出去让所有服务器同时开始“睡觉”再同时醒来。asyncio 的 API 其实就三个核心async def声明协程、await挂起等待、asyncio.create_task把协程丢进事件循环。上篇已经细讲过这里不再重复。但有两个判断标准可以帮你自查第一如果你的代码里一个 await 都没有那它跟普通函数没有区别第二如果你的异步爬虫里出现了time.sleep事件循环会被整体卡住后面所有任务都会迟到这一点后面避坑部分还会单独拎出来说。2. aiohttp 上手速览先搞懂这三个核心概念2.1 ClientSession连接复用的正确打开方式aiohttp 的第一个关键点是 ClientSession。很多新手会照着 requests 的习惯在每个请求里新建一个 session这是大忌。TCP 握手、TLS 握手都是很贵的开销每请求新建 session 等于每次都要重新握手并发优势直接折半。正确姿势是全局维护一个ClientSession它内部维护连接池自动复用 keep-alive 连接。先看一个最基础的请求长什么样import aiohttp import asyncio async def main(): async with aiohttp.ClientSession() as session: async with session.get(https://httpbin.org/get) as resp: print(resp.status) print(await resp.text()) asyncio.run(main())两行 async with 嵌套第一层管会话生命周期第二层管单次响应的读取和释放。这里有个细节resp.text()前面必须有 await因为响应体是流式读取的不等待就读不到完整内容。生产环境里我更推荐创建一个全局会话并绑定 connector把连接池参数显式控制起来import aiohttp connector aiohttp.TCPConnector( limit20, # 连接池总连接数上限 limit_per_host10, # 同一域名最大连接数 ttl_dns_cache300, # DNS 缓存时长单位秒 enable_cleanup_closedTrue ) session aiohttp.ClientSession( connectorconnector, headers{User-Agent: Mozilla/5.0 (compatible; AsyncCrawler/1.0)}, timeoutaiohttp.ClientTimeout(total30) )limit决定整个连接池最多同时建立多少连接limit_per_host则是针对单台主机的保护防的一个站点抢占所有连接。ttl_dns_cache是我后来才加上的没有它的话每条连接第一次建立都要做一次 DNS 解析对高并发场景是实打实的瓶颈。另外 aiohttp 默认没有全局超时一个挂起的请求可能等上几分钟所以ClientTimeout(total30)这种兜底配置必须有。2.2 Semaphore并发控制的钥匙高并发爬虫听起来很猛但真把所有任务一股脑丢进事件循环瞬间就会打爆目标服务器自己的连接池也会不堪重负。所以几乎每个生产级异步爬虫都会有一个 Semaphore 信号量限制同时执行的请求数量。用法很简单提前创建好一个信号量sem asyncio.Semaphore(10) # 同时最多10个请求 async def fetch(session, url): async with sem: # 进入临界区占用一个信号量 async with session.get(url) as resp: return await resp.text()这个async with sem保证同一时刻最多只有 10 个协程进入请求执行区其余的协程会在信号量外面排队等待。使用上有一个坑Semaphore 必须在 main 协程里提前创建一份然后传给所有任务复用。如果你图省事在每个任务函数里 new 一个那每个任务自己的“限流器”都是满的等于没限流。信号量的值怎么定我的经验是先从目标站点的承受能力出发。抓公开 API 或公开测试接口保守一点用 5 到 10自己搭的服务或者内部接口可以放到 30 到 50。还有一点很多人忽略并发数要和连接池配合调如果并发任务数是 20而连接池limit只有 5那多余的并发请求其实全在排队并发性能不会真正发挥出来。3. 高并发爬虫实战一套可直接改的爬虫骨架3.1 任务拆解抓 20 个“慢接口”并统计耗时为了演示能复现我选了一个完全合规的公开测试服务 httpbin.org它的/delay/1接口会先睡 1 秒再返回。用这个接口模拟“每个页面都要服务器忙 1 秒”的场景非常适合观察异步并发收益也不会给任何真实站点造成压力。任务需求很简单抓取 20 个这样的接口记录每个请求的状态码、返回内容长度统计总耗时。同步串行基线大概是 21 秒我们要做的是把它压到 5 秒以内。如果你的网络环境访问 httpbin.org 偶尔抽风可以本地起一个模拟服务代替几十行代码的事# mock_server.py import asyncio from aiohttp import web async def delay(request): await asyncio.sleep(1) return web.json_response({ok: True}) app web.Application() app.router.add_get(/delay/1, delay) if __name__ __main__: web.run_app(app, port8080)这样把下面代码里的 BASE_URL 换成http://127.0.0.1:8080效果完全一致而且不管网络怎么波动压测数据都稳定。3.2 完整代码与关键行拆解下面是完整可运行的版本建议先整个复制跑一遍再看拆解import asyncio import time import aiohttp BASE_URL https://httpbin.org/delay/1 CONCURRENCY 10 TIMEOUT aiohttp.ClientTimeout(total30) async def fetch_page(session, url, sem): async with sem: try: async with session.get(url, timeoutTIMEOUT) as resp: body await resp.text() return {url: url, status: resp.status, len: len(body)} except Exception as exc: return {url: url, error: str(exc)} async def main(): sem asyncio.Semaphore(CONCURRENCY) connector aiohttp.TCPConnector(limitCONCURRENCY, limit_per_hostCONCURRENCY) headers {User-Agent: Mozilla/5.0 (compatible; AsyncCrawler/1.0)} async with aiohttp.ClientSession( connectorconnector, headersheaders, timeoutTIMEOUT ) as session: urls [BASE_URL for _ in range(20)] tasks [asyncio.create_task(fetch_page(session, url, sem)) for url in urls] results await asyncio.gather(*tasks) for r in results: print(r) if __name__ __main__: start time.perf_counter() asyncio.run(main()) print(f总耗时: {time.perf_counter() - start:.2f}秒)这段代码的核心就三句Semaphore控制并发create_task把协程注册进事件循环gather统一收集结果。asyncio.gather会在所有任务都完成后返回结果列表结果顺序与任务传入顺序一致这点对后续数据处理很友好不用自己维护索引映射。我特意把异常处理放在每个任务内部而不是让 gather 直接抛异常。真实爬虫场景下个别请求失败太常见了一个请求挂了不该让整个任务组陪葬。每个任务自己兜住异常最后在结果里通过error字段区分失败请求。3.3 给爬虫加上优雅的重试机制真实爬虫里网络抖动很频繁第一批请求大概率有零星失败所以重试机制必须得有。同步代码里重试就是嵌套循环异步代码里可以用for _ in range(retries)配合await asyncio.sleep做退避但千万不能用time.sleep原因前面提过。一个带重试的 fetch 版本async def fetch_with_retry(session, url, sem, retries3): async with sem: for attempt in range(1, retries 1): try: async with session.get(url, timeoutTIMEOUT) as resp: if resp.status 200: return await resp.text() # 5xx 错误也值得重试但4xx基本是请求本身有问题 if resp.status 500: return {url: url, status: resp.status} except Exception as exc: if attempt retries: return {url: url, error: str(exc)} # 指数退避失败后等0.5秒、1秒、1.5秒... await asyncio.sleep(0.5 * attempt)指数退避的逻辑很简单第一次失败等 0.5 秒第二次等 1 秒第三次等 1.5 秒。这个等待时间也要让出控制权await asyncio.sleep不会阻塞事件循环其他任务照跑效果上只是这个任务自己慢了一点。4. 参数调优与压测实录并发数到底怎么定4.1 不同并发数的实测对比我拿上面这套代码在本地网络环境下跑了几组数据。每组都是 20 个“延迟 1 秒”的请求分别调整信号量和连接池的并发值结果如下并发数总耗时失败请求我的观察1约 21 秒0完全退化成了同步串行没有意义5约 5 秒0平稳适合公共小站点10约 3.2 秒0综合最优速度和稳定性兼顾20约 2.8 秒偶尔 1 个极限快但是有一点风险这些数字会随网络波动但趋势是稳定的并发从 1 加到 10提速非常明显从 10 加到 20收益骤降。原因也很简单20 个请求每个延迟 1 秒理想状态下 20 并发就是 1 秒多完成但连接建立、TLS 握手这些额外开销会吃掉一部分时间所以 2.8 秒已经是比较接近物理上限的值。真正的教训是不要盲目追求并发数。我见过有人把并发调到 200结果目标站点直接开始返回 503重试风暴反过来拖垮了自己的带宽和 CPU整体耗时反而比 50 并发还差。高并发爬虫的“高”应该有限度这个限度通常取决于对方服务的承受能力而不是你的网速。4.2 连接池、超时与并发数的协同配置原则并发数确定之后至少有四个参数需要一起调Semaphore的值、TCPConnector.limit、limit_per_host、ClientTimeout。最容易犯的错就是只调 Semaphore不管连接池。我习惯的配置逻辑是先定目标并发数 N然后把Semaphore(N)、connector.limitN、limit_per_hostN三个值设为相同保证信号量放行的请求到了连接池这里不会被二次排队。如果目标是抓多个不同站点limit_per_host可以单独设小一点比如 N/2这样单个站点的压力有限其他站点的抓取不会被某个慢站点拖死。超时设置也讲究。total30表示整个请求从开始到结束最多 30 秒connect10表示连接建立超过 10 秒直接放弃sock_read20表示 socket 读超时。对一个正常响应不超过 5 秒的接口我建议total30, connect10足够不要把超时调得太大否则大量挂起的请求会占满连接池新的请求进不来。5. 常见问题与排查技巧速查表5.1 高频坑位实录从报错到静默失效第一坑time.sleep直接写进协程。这个坑最隐蔽因为不会报错只是你会发现异步程序跑得比同步还慢。原因是time.sleep是阻塞调用它休眠的是整个线程事件循环在这个过程中完全停摆。正确做法永远是用await asyncio.sleep()。第二坑忘记 await。session.get(url)返回的是协程对象不 await 它就不会发出请求而且会产生RuntimeWarning: coroutine ... was never awaited警告。你要是在循环里忘记 await效果就是瞬间创建了几百个协程对象一个请求都没发出去程序还莫名其妙地退出了。第三坑响应没有读取完就退出上下文。async with session.get(...) as resp离开上下文时如果响应体没读完连接就无法归还连接池长期跑下来连接池会被耗尽表现就是程序跑一段时间后突然卡死。确保await resp.text()或者resp.release()被调用。第四坑SSL 证书验证失败。有些测试环境或者内网服务证书不正规会抛ssl.SSLCertVerificationError。可以用aiohttp.TCPConnector(sslFalse)跳过验证但只建议在调试时这么做生产环境请修复证书而不是关验证。第五坑每个请求都ClientSession()。之前讲过了会话复用是高并发的命根子每次新建等于自废武功。把这五个坑整理成速查方便以后对着排查症状可能原因解决方案程序跑得比同步还慢协程里用了 time.sleep换成 await asyncio.sleep大量 coroutine was never awaited忘记 await 请求或响应检查所有 async 调用跑一段时间后卡死响应未读取连接池耗尽读完响应体或调用 release()抛 SSL 证书错误证书验证失败调试期 sslFalse生产修证书并发设置失效每任务新建 Semaphore提前创建并全局复用5.2 如何验证并发真的生效写完异步爬虫第一个问题就是它真的并发了吗最直接的办法是加时间戳日志。在 fetch 函数里记录请求发出时间和完成时间如果多个请求的发出时间非常接近说明并发生效了如果它们是排队依次发出的说明你的信号量或者连接池设置有问题。另一个有用的调试工具是asyncio.all_tasks()它返回当前事件循环里所有未完成的任务。在程序卡住的时候打印这些任务对象能看到它们各自停在哪一行 await 上这比盲猜快得多。还有 Python 3.11 以后推荐的asyncio.timeout上下文管理器可以更方便地对单个协程做超时控制比asyncio.wait_for可读性更好。爬虫跑完以后要能干净退出。如果事件循环里有未完成的任务asyncio.run会直接抛异常。稳妥的做法是这样的async def main(): tasks [] try: tasks [asyncio.create_task(fetch_page(session, url, sem)) for url in urls] await asyncio.gather(*tasks) except KeyboardInterrupt: print(收到中断取消剩余任务...) for t in tasks: t.cancel() await asyncio.gather(*tasks, return_exceptionsTrue) print(已全部取消)这个模式能保证你在按 CtrlC 的时候已经发出去的请求不会留下半截连接程序也不会有残破的僵尸任务。写异步爬虫这几年我最大的体会是不要把异步当成加速魔法。它的本质是把等待时间还给你让你能在同一份时间里处理更多请求。真正决定成功率的还是对目标服务的尊重合理的并发、合理的超时、完善的重试。拿这套骨架去改你的第一个 aiohttp 高并发爬虫很快就能跑起来但一定记得先在测试接口上压一轮不要上来就拿真实站点练手。