跑一个企业级的Python异步爬虫,最烦的就是程序跑着跑着突然冒出一堆ServerDisconnectedError。我在接一个电商数据采集项目时遇到过这样一个情况:用asyncio+aiohttp写了套异步协程爬虫,300个并发请求丢进去,前5分钟一切正常,然后错误率突然飙升,日志被ServerDisconnectedError刷屏,程序像被抽空了一样,所有请求瞬间失败。最气人的是,这个错误不是偶发的,每次跑到同一个量级必然出现,而且目标网站并没有封我IP——因为我换回requests慢慢爬,同一个URL又完全正常。
这篇文章我就把自己排查和解决这个问题的完整过程写出来,包括底层原理、连接池参数拆解、重试策略设计、并发度调整,以及最终一套可以直接抄走的完整实战代码。适合正在用aiohttp写爬虫、被这个错误折磨过的开发者,也适合准备上手异步爬虫想提前避坑的朋友。
1. 先说说我在真实项目中遇见的场景
那次项目是抓某个垂直电商平台的商品详情页,量级不大,总量大概10万个URL。一开始我用requests+ThreadPoolExecutor,30个线程跑,效率也能接受,但目标网站的响应时间不太稳定,平均一个请求要1秒多,30个线程每秒撑死20来个请求。按这个速度,10万个页面得跑一个多小时,加上反爬策略触发后的降速,实际耗时更长。于是我把方案换成了aiohttp异步协程,想着用200个并发连接,吞吐量能直接拉满。
改造倒是很顺利,ClientSession+asyncio.gather这套写法网上到处是教程,照着写很快跑通了。前几分钟速度确实快了很多,每秒能处理50到80个请求,我心里还挺美。结果跑了不到5分钟,日志里开始出现ServerDisconnectedError,一开始是个别请求失败,几分钟后像连锁反应一样,大片请求全部报错,程序基本处于瘫痪状态。
我当时的第一反应是IP被目标网站封了。因为服务端主动断连,最常见的解释就是服务端发现你行为异常,直接拒了。我用curl手动请求了一下页面,发现完全正常,连验证码都没有弹。这就很诡异了——服务端明明没封我,为什么连接一个接一个断开?
我又怀疑是本地连接数超限,查了系统的文件描述符限制,ulimit -n确认过65535,net.ipv4.ip_local_port_range也没问题。排除了系统层面的限制之后,我意识到问题大概率出在aiohttp自身的行为上,或者说,是我对aiohttp连接复用机制的理解不够导致的。
那会儿我翻了aiohttp官方文档,也去GitHub的issue区搜了ServerDisconnectedError,发现遇到这个问题的人非常多,但网上的回答大多停留在“重试一下就好了”这种层面。作为一个被这种错误折磨了一整天的人,我对这种回答是很不满意的。我需要的是搞清楚它为什么发生、在什么条件下发生、怎么从根源上避免它,而不只是给它贴个“膏药”。
2. 扒开ServerDisconnectedError的源码与底层逻辑
2.1 它到底是从哪冒出来的异常
ServerDisconnectedError在aiohttp的异常体系里继承自ConnectionError,然后ConnectionError又继承自OSError。说白了,这个异常的语义是:客户端和服务器之间的TCP连接被服务器端主动关闭了。但注意,主动关闭和优雅关闭是两回事。如果是正常完成响应后关闭,aiohttp会认为这个连接已经处理完,把它从连接池里移除,不会报错。真正触发问题的是:连接在请求发出后、完整响应返回之前,突然被服务端切断。
这个场景很常见——服务端收到一个请求,但处理到一半发现“这个连接好像不太对劲”或者“空闲时间太长了”,直接扔掉了这个连接。客户端这边还傻乎乎地等着响应,结果等到的是EOF(文件结束符),也就是连接被对端关闭。aiohttp内部解析器发现连接提前关闭了,就会抛出ServerDisconnectedError。
从TCP协议的视角来看,HTTP协议构建在TCP之上,HTTP的keep-alive机制允许在同一个TCP连接上发送多个HTTP请求。服务端通常会设置一个keep-alive timeout,比如5秒或15秒,意思是这个连接空闲超过指定时间就关闭。aiohttp的ClientSession也很聪明,会在本地连接池里维护一堆keep-alive连接,下次请求时优先复用。
问题就出在这两个“keep-alive时间”不一致上。
2.2 本地连接池与服务端空闲连接时间的错位
aiohttp连接池里有一个keepalive_timeout参数,默认是15秒。意思是连接在池子里空闲超过15秒,aiohttp自己会把连接关掉。但如果目标服务器的keep-alive timeout是5秒,那就出现了一个严重错位:服务器在5秒之后就关闭了这条空闲连接,但aiohttp以为这个连接还是好的,下一次请求直接复用这个本地缓存,结果请求发过去才发现连接早被服务端关了——此时ServerDisconnectedError就来了。
异步爬虫的场景下这个问题会特别明显。因为我用的是高并发,一个连接在某次请求结束后进入空闲,然后立刻被另一个协程复用。如果复用发生在服务端已经关闭连接之后,错误就出现了。并发越高,连接复用的频率越高,踩中这个时间差的概率就越大。这也能解释为什么我降低并发、或者换回requests的线程池模式时,这个问题几乎不出现——线程池模式下连接数少,复用频率低,而且requests.Session在连接失效后往往有更宽松的容错机制。
2.3 还有哪些隐藏的触发因素
除了keep-alive时间错位,我排查后发现还有几个场景容易触发ServerDisconnectedError:
- 服务端并发连接数限制:很多Web服务器(尤其是Nginx类型)会对同一IP的并发连接数做限制。我300个并发连接同时打在同一个域名上,触发了服务端的连接数保护。服务器直接丢弃多余连接,表现就是一部分请求报
ServerDisconnectedError。 - 负载均衡/网关的空闲超时:如果目标网站前面挂了负载均衡或CDN网关,它们往往有独立的idle timeout配置,通常比后端服务器更严格。即使后端服务器还在等请求,网关已经把连接关了。
- 请求体发送被切断:在某些代理环境下,客户端请求头已经发过去了,但因为代理或中间层的问题,请求体没发完整,连接就被切断。
- 本地连接被复用但目标已不可达:上游网络抖动、DNS切换、服务器重启,都会导致已建立连接全部失效,这时候如果连接池没有及时清理,大量复用就会触发批量报错。
综合来看,这个问题本质上是“客户端连接池对服务端状态的假设破灭了”。解决思路也就清晰了:一是从根上降低这种假设破灭的概率(连接池参数调优),二是假设破灭后要有优雅的恢复手段(重试机制),三是减少同时破灭的数量(并发度控制)。
3. 高效策略一:从连接池参数入手解决根源问题
TCPConnector是aiohttp连接池的核心配置入口。很多人写爬虫时根本不关心这个对象,直接ClientSession()一把梭,短时间跑点小数据没问题,一旦大了就各种诡异报错。我实际调整和验证过的参数主要有下面几个,每一个都直接影响ServerDisconnectedError的出现频率。
3.1 核心参数逐个拆解
| 参数名 | 默认值 | 含义 | 爬虫场景建议 |
|---|---|---|---|
limit | 100 | 连接池中所有连接的并发上限 | 根据目标站点承受能力调整,别盲目调大 |
limit_per_host | 0(不限制) | 同一个host最多复用多少个连接 | 建议设置,比如10~30,防止单域名被打爆 |
keepalive_timeout | 15 | 连接池中空闲连接的最大存活时间 | 建议调低到5左右,匹配常见服务端keep-alive |
force_close | False | 每个请求结束后关闭连接,不进入连接池 | 反爬严格时可以设True,但会损失性能 |
enable_cleanup_closed | False | 清理那些被服务端异常关闭但未通知本地的连接 | Windows系统建议设True,Linux也别省 |
verify_ssl | True | SSL证书校验 | 自签名证书场景要设False,但别乱关 |
3.2 我实际调优的Connection配置
针对那次电商采集项目,我最终采用的TCPConnector配置长这样:
import aiohttp connector = aiohttp.TCPConnector( limit=50, # 总并发连接数,不超过50 limit_per_host=10, # 单域名最多10个连接 keepalive_timeout=5, # 本地空闲连接5秒就关闭 enable_cleanup_closed=True, # 主动清理被服务端异常关闭的连接 force_close=False, # 保留连接池复用,毕竟性能要紧 ttl_dns_cache=300 # DNS缓存5分钟 ) session = aiohttp.ClientSession( connector=connector, timeout=aiohttp.ClientTimeout(total=30, connect=10) )这个配置的核心思路是:把本地连接池的keep-alive存活时间压到5秒,低于绝大多数服务端的空闲超时时间,这样每次复用的连接大概率是服务端还没舍得扔的连接。limit_per_host设成10,防止某个热门页面的请求把连接池全占完,导致其他域名的请求全部排队等待。
你可能会有疑问:既然force_close=True能完全避免连接复用失效的问题,为什么不直接用它?原因很简单:性能损失太大。如果每个请求都要重新建立TCP连接,三次握手的开销在TLS连接上会被放大,因为TLS握手还要来回交换证书。我在测试中发现,force_close=True会让整体耗时增加30%到50%,在某些网络环境下甚至更离谱。所以我的原则是:能用keep-alive参数调优解决的,就尽量不用force_close这个“大杀器”。
3.3 为什么enable_cleanup_closed在这个问题里很关键
这个参数在官方文档里的描述很简单:让底层不断清理那些“已经被服务端关闭但本地还没感知”的连接。听起来像黑魔法,其实原理是:aiohttp基于asyncio的传输层,当服务端关闭连接时,底层会收到一次“连接关闭”的通知。但因为有缓冲或者其他原因,aiohttp可能不会立刻从连接池里清除这个失效连接。enable_cleanup_closed的作用就是在后台定时轮询这些连接的状态,发现问题连接直接扔出连接池。
实战中我建议无论什么平台都把它设成True。尤其是Windows环境,因为Windows的asyncio事件循环行为和Linux不太一样,这个参数在Windows上能解决很多莫名奇妙的连接异常。如果你已经把keepalive_timeout调低了但还是偶发错误,试试加上它,错误率能再降一个量级。
3.4 连接池调整后的效果
调完连接池参数,我重新跑了同样数量级的采集任务。错误率从之前的10%左右降到了0.5%以下。从日志上看,ServerDisconnectedError虽然还有,但已经处于“完全可以接受”的范围,剩下的零星错误交给重试机制兜底就行了。
从根因层面说,这一步解决的是“大多数错误发生前的问题”。连接池参数调优是主动预防,重试是被动补救。预防做得越好,后面需要补救的就越少,整体效率自然也就上去了。
4. 高效策略二:重试机制不能无脑写,必须分级
说实话,连接池参数调优做完之后,错误率已经很低了,但离“生产级”还差一口气。在大规模采集场景里,0.5%的错误率意味着10万个请求里有500个会失败,这些失败如果不处理,最终数据就会有缺口。所以重试机制不是可选项,是必选项。
但重试这件事,写的时候特别容易踩坑。我见过太多人写for i in range(3)这种无脑重试,还把time.sleep(0.5)写死在那里。这种方案在低并发下看着没问题,但在高并发异步场景下,一个愚蠢的重试策略分分钟让整个爬虫雪崩。
4.1 先搞清楚哪些错误值得重试
不是所有错误都值得重试。重试是有代价的:占用时间、占用连接、可能加重服务端负担。所以第一步是把错误分类。
| 错误类型 | 是否重试 | 原因 |
|---|---|---|
ServerDisconnectedError | 重试 | 连接被服务端断开,下一次新建连接大概率正常 |
ConnectionResetError | 重试 | 对端重置了连接,属于暂时性网络问题 |
TimeoutError(如asyncio.TimeoutError) | 视情况重试 | 如果目标页面逻辑复杂,响应慢导致的超时,重试意义不大 |
ClientConnectorError | 不重试 | 域名解析失败、目标不可达等,重试大概率还是失败 |
| 4xx异常(如404、403) | 不重试 | 请求本身有问题或者权限不足,重试浪费资源 |
| 5xx异常(如500、502、503) | 重试 | 服务端暂时过载,等一会儿可能就好了 |
所以我的重试逻辑首先要做一个“错误分类器”,只对可重试的错误类型进行重试,其余的直接记录日志然后丢弃。
4.2 指数退避加抖动才是异步场景的正解
重试的间隔不能是固定值。固定间隔有两个问题:一是太短了,会在服务端刚出问题的时候,重试请求又铺天盖地地打过去,加重服务端负担;二是太长了,浪费整体采集时间。
正确做法是指数退避加抖动:每次重试的等待时间是上一次的两倍,同时加上一个随机扰动,避免所有重试请求在同一时刻打向服务端。
import asyncio import random from aiohttp import ClientSession, ServerDisconnectedError async def fetch_with_retry(session, url, max_retries=3): """带指数退避和抖动的请求方法""" for attempt in range(max_retries): try: async with session.get(url) as response: if response.status // 100 == 5: # 5xx错误,等退避时间后重试 await asyncio.sleep(backoff(attempt)) continue response.raise_for_status() return await response.text() except (ServerDisconnectedError, ConnectionResetError) as e: if attempt == max_retries - 1: raise wait_time = backoff(attempt) print(f"[重试] {url} 第{attempt + 1}次失败: {e}, 等待 {wait_time:.2f}s") await asyncio.sleep(wait_time) except aiohttp.ClientResponseError as e: if e.status in (500, 502, 503, 504): await asyncio.sleep(backoff(attempt)) continue raise return None def backoff(attempt, base=0.5, cap=5.0): """指数退避 + 抖动:0.5, 1.0, 2.0 秒为基础,加随机抖动""" exp_backoff = min(cap, base * (2 ** attempt)) jitter = random.uniform(0, exp_backoff * 0.3) return exp_backoff + jitter这里有几个细节值得说。
- 我第一次重试等待约0.65秒,第二次约1.3秒,第三次约2.6秒,不会等太久,共约4.5秒,对整体吞吐影响很小。如果3次重试全部失败,说明这个URL的问题不是临时性的,再重试也没意义。
- 抖动这0.3倍的随机值非常重要。在高并发环境下,如果没有抖动,所有失败请求会在同一时刻发起重试,形成新的“请求脉冲”,这比一开始的服务端过载还要危险。加抖动本质上是把重试请求的时间戳抹平,让每个请求的重试时刻自然分散。
- 重试时不要使用相同的TCP连接。所以在异常处理里,我直接让
session.get重新走一遍连接池分配逻辑,如果池子里的连接不可用,它会自动新建连接。如果你提前把连接对象缓存到了某个变量里,重试时务必重新从session发起请求。
4.3 超时配置也要纳入重试的考量范围
ServerDisconnectedError和超时的关系很微妙:连接被断时aiohttp会立刻抛异常,但有一种情况是连接断了但aiohttp还在傻等响应,最终表现为超时。所以ClientTimeout要设置两个维度:整体超时和连接超时。
连接超时设为5秒就够了,如果5秒内TCP连接还没建立好,这个网络环境大概率有问题;整体超时根据页面复杂度来,如果一个页面30秒还没返回完整响应,就算最后返回了,对爬虫来说这个响应时间也意味着风险。我实际场景里用的是:total=20,connect=5,sock_read=10(socket读超时)。这个配置在我跑的多个项目里都比较稳定。
4.4 重试与数据幂等性
最后提醒一句,重试逻辑在只读的GET请求场景下没有任何问题,但在POST请求场景下要特别小心。如果目标接口不是幂等的,一次重试可能导致服务端产生重复数据。如果你写的是采集类爬虫,绝大部分是GET请求,重试可以放开写。但如果你在写提交类的脚本(比如自动签到、提交表单),重试之前先确认接口设计是否幂等,或者设计一套“请求唯一ID”去重机制,否则一次网络抖动就可能造成业务数据的脏写。
5. 高效策略三:并发度的科学调整
很多人把“异步”和“高并发”划等号,觉得只要用了asyncio,并发数就能无限调大。这是个很危险的误解。异步协程解决的是IO等待问题,把CPU在等待IO的时间里腾出来干别的事,但网络连接数本身依然受操作系统、目标服务器、本地网络栈的硬限制。并发度设得不合理,ServerDisconnectedError只是一个开始,后面还会跟着各种奇怪的连接错误。
5.1 并发度与错误率的关系,我亲测过
我拿同一个目标网站做了个简单的梯度测试。在连接池参数调整好、重试机制保持不变的前提下,我分别用50、100、200、300、500个并发请求各跑了1000个URL,记录错误率和整体耗时。
| 并发数 | 平均耗时 | ServerDisconnectedError错误率 | 备注 |
|---|---|---|---|
| 50 | 38秒 | 0.1% | 很稳定 |
| 100 | 21秒 | 0.3% | 正常 |
| 200 | 13秒 | 1.2% | 错误开始上来了 |
| 300 | 11秒 | 4.6% | 错误率明显上升 |
| 500 | 9.5秒 | 12.3% | 不可接受 |
从这张表能看出来,并发数从200提到500,耗时只减少了3秒多,但错误率翻了10倍。这个拐点其实非常关键。很多爬虫工程师喜欢把并发拉满,觉得“跑得越快越好”,但实际上在拐点之后,你再往上加并发,收益越来越小,代价越来越大——错误越来越多,重试时间越来越长,甚至可能因为触发WAF导致IP被封。
5.2 用Semaphore做“双保险”并发控制
连接池的limit参数能限制连接总数,但它管不住“同时发起请求的协程数”。比如连接池limit=50,你可能有500个协程同时调用session.get(),然后这500个协程挤在连接池门口排队。这个排队过程虽然不会报错,但会造成大量的协程上下文切换和调度开销。更关键的是,如果请求之间还有依赖关系(比如第一个请求拿到商品ID后再请求详情),连接池的limit就完全不够用了。
所以我在协程层加asyncio.Semaphore,对同时发起的请求数量做一次限流。这是双保险:信号量控住协程并发,连接池控住底层连接数。
import asyncio from aiohttp import ClientSession class Crawler: def __init__(self, total_concurrency=50, per_host_concurrency=10): self.total_concurrency = total_concurrency self.per_host_concurrency = per_host_concurrency self.semaphore = asyncio.Semaphore(total_concurrency) async def fetch(self, session, url): async with self.semaphore: # 信号量控制同时执行的协程数 async with session.get(url) as response: response.raise_for_status() return await response.text()这个写法的好处是:不管外部怎么调用,fetch方法里同时只有total_concurrency个协程在真正执行请求,其余的都在信号量上挂着等待。配合连接池的limit_per_host,等于在“总并发”和“单域名并发”两个维度上都做了限制。
5.3 并发度到底设多少合适
这是个人人都想问、但没人能给你标准答案的问题。不同网站、不同机器配置、不同网络环境都会影响最优并发度。我给一个通用的推荐流程:
- 先摸底:从比较保守的并发数开始,比如20或者30,跑500个URL,记录耗时和错误率。
- 逐步加压:翻倍并发数,比如40、80、160,重复测试。
- 找到拐点:当错误率开始明显上升、耗时下降变缓时,拐点前的并发数就是当前环境下的合理值。
- 留出余量:生产环境取最优值的70%左右,因为目标网站的负载也是动态变化的,今天能承受100并发,明天可能因为活动促销只能承受30。
我自己经验是,针对大部分普通的Web网站,如果没有特殊反爬策略,单集群配置下总并发50到100是一个比较舒服的区间。既能把吞吐拉起来,又能把错误率压在1%以内。那些“随便就上300并发”的教程,要么跑的不是和业务相关的真实数据,要么目标网站是服务端比较抗揍的,别完全照搬。
5.4 动态限流的补充思路
如果你觉得固定并发度不够优雅,可以用一个反馈闭环做动态限流:每秒钟统计错误率,当错误率超过阈值(比如5%)时,自动把信号量的值调小;当错误率恢复正常时,再逐步调回来。这个思路实现起来也不难,但需要有人定时监控运行状态。我自己在大型采集项目里会写一个轻量的监控协程,定期调整信号量和重试参数。这里不展开,列个方向供参考。
6. 完整实战代码与验证结果
前面几节分别讲了原理、连接池、重试、并发控制,本节我把它们组合成一个可以直接跑的完整爬虫。为了容易复现,我用的是一个简单的列表页采集场景:从某个公开列表页抓取页面内容,提取所有商品链接,再并发请求商品详情页。目标URL我做了泛化处理,你用任何无登录的公开页面都可以替换。
6.1 完整代码:把三套策略组合起来
import asyncio import logging import random from dataclasses import dataclass import aiohttp logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger(__name__) @dataclass class FetchResult: url: str text: str = "" success: bool = False attempts: int = 0 error: str = "" class AsyncCrawler: def __init__( self, urls: list[str], total_concurrency: int = 50, per_host_concurrency: int = 10, max_retries: int = 3, ): self.urls = urls self.semaphore = asyncio.Semaphore(total_concurrency) self.max_retries = max_retries self.results = [] # 连接池:核心调优参数 self.connector = aiohttp.TCPConnector( limit=total_concurrency, limit_per_host=per_host_concurrency, keepalive_timeout=5, # 主动低于服务端keep-alive时间 enable_cleanup_closed=True, # 清理异常连接 force_close=False, ttl_dns_cache=300, ) self.timeout = aiohttp.ClientTimeout(total=20, connect=5, sock_read=10) @staticmethod def _backoff(attempt: int, base: float = 0.5, cap: float = 5.0) -> float: exp_backoff = min(cap, base * (2 ** attempt)) return exp_backoff + random.uniform(0, exp_backoff * 0.3) def _should_retry(self, status: int) -> bool: return status in (500, 502, 503, 504, 429) async def _fetch_one(self, session: aiohttp.ClientSession, url: str) -> FetchResult: result = FetchResult(url=url) for attempt in range(self.max_retries): result.attempts = attempt + 1 async with self.semaphore: try: async with session.get(url) as response: if self._should_retry(response.status): result.error = f"HTTP {response.status}" if attempt < self.max_retries - 1: await asyncio.sleep(self._backoff(attempt)) continue return result if response.status != 200: result.error = f"HTTP {response.status}" return result result.text = await response.text() result.success = True return result except (aiohttp.ServerDisconnectedError, ConnectionResetError) as e: result.error = str(e) if attempt < self.max_retries - 1: await asyncio.sleep(self._backoff(attempt)) continue return result except asyncio.TimeoutError as e: result.error = f"Timeout: {e}" if attempt < self.max_retries - 1: await asyncio.sleep(self._backoff(attempt)) continue return result except aiohttp.ClientError as e: # 其他客户端错误默认不重试 result.error = str(e) return result return result async def run(self) -> list[FetchResult]: async with aiohttp.ClientSession(connector=self.connector, timeout=self.timeout) as session: tasks = [self._fetch_one(session, url) for url in self.urls] self.results = await asyncio.gather(*tasks, return_exceptions=False) return self.results async def main(): # 你在实际使用中把这里换成真实URL集合 urls = [f"https://example.com/detail/{i}" for i in range(500)] crawler = AsyncCrawler(urls, total_concurrency=50, per_host_concurrency=10) results = await crawler.run() success = sum(1 for r in results if r.success) failed = len(results) - success total_attempts = sum(r.attempts for r in results) avg_attempts = total_attempts / len(results) logger.info(f"完成: 总计{len(results)}, 成功{success}, 失败{failed}, " f"成功率{success / len(results) * 100:.1f}%, 平均请求次数{avg_attempts:.2f}") if __name__ == "__main__": asyncio.run(main())这段代码的结构是:AsyncCrawler类负责整体调度,_fetch_one方法负责具体的请求、重试、异常处理,run方法统一收集结果。信号量在_fetch_one内部使用,确保同时执行的协程数不超过total_concurrency。
6.2 跑数据:优化前后对比
我在自己电脑上(普通办公笔记本,Ubuntu 22.04,Python 3.10)跑了一组模拟测试,以500个公开URL为例,每个URL模拟一个响应约0.3秒的轻量页面。结果如下:
| 指标 | 优化前(默认连接池+无重试) | 优化后(调参+重试+信号量) |
|---|---|---|
| 总耗时 | 8秒 | 11秒 |
| ServerDisconnectedError错误 | 43个(8.6%) | 0个(重试后全部成功) |
| 最终成功率 | 91.4% | 100% |
| 平均每个请求尝试次数 | 1次 | 1.02次 |
优化后耗时多了一点,但成功率提到了100%。真实场景里这个代价完全值得——因为你不可能让一个10万级别的数据采集任务在91%的完成率下收尾,后续补齐那9%的缺失数据所花的时间远大于你现在多花的那几秒。
这里要说明一下,真实生产环境很少出现错误率从8.6%直接降到0的神话。因为我测试用的目标服务本身比较稳定,加上重试兜底,错误才全部被消化了。如果你的目标网站不太稳定,最终成功率可能在99%左右,剩下的1%是重试三次都救不回来的死链接或者被服务端永久拒绝的请求,这种就交给人工校验和后续补采策略了。
6.3 运行时的观测手段
为了快速判断问题是否还在,我在真实项目里会额外加一个观测协程,每5秒输出一次当前的理论请求数和实际成功请求数。这里提供一个简化的版本,你可以把_fetch_one方法里每次请求前后都打点记录:
async def progress_reporter(urls_total: int, counter: dict, stop_event: asyncio.Event): """每5秒输出一次进度""" while not stop_event.is_set(): done = counter["done"] ok = counter["ok"] fail = counter["fail"] logger.info(f"进度: {done}/{urls_total}, 成功{ok}, 失败{fail}, " f"成功率{ok / max(done, 1) * 100:.1f}%") try: await asyncio.wait_for(stop_event.wait(), timeout=5) except asyncio.TimeoutError: pass把这个观测协程塞进run方法的async with上下文里,就能在终端实时看到采集过程中的成功率变化。一旦发现成功率低于预期,可以迅速按下CTRL+C终止脚本,调整参数后重新跑,不用等全部跑完才发现错误率超标。
7. 一些值得反复琢磨的细节与经验
7.1 Windows环境下被忽略的事件循环坑
如果你在Windows上跑上面的代码,很可能会遇到RuntimeError: Event loop is closed或者Proactor event loop相关的问题。这是Python 3.8+在Windows上默认事件循环策略变化导致的。解决办法是在入口处显式指定事件循环策略:
import sys import asyncio if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) asyncio.set_event_loop(asyncio.SelectorEventLoop())之所以要指定WindowsSelectorEventLoopPolicy,是因为ProactorEventLoop在aiohttp某些版本下有兼容性问题,加上asyncio.run(main())内部会创建和关闭事件循环,两个事件循环切换时容易出意外。这个坑不是每次必现,但一旦出现就会让人摸不着头脑。我在两个不同的Windows机器上踩过,都靠这一行解决。
7.2 连接复用与数据错位的风险
一个容易被忽略但很实际的问题是:连接复用可能会带来响应数据的“错位”。这个错位不是aiohttp导致的,而是HTTP协议本身不支持“连接级多路复用”——一个连接上同一时间只能有一个未完成的请求和响应。在高并发场景下,如果连接池里某条连接被同时分配给两个协程,后写的那个协程可能会读到前一个协程的响应。
aiohttp内部对这个问题是有防护的,它的连接池并不会把同一条连接同时分配给两个请求。但如果你手动复用了某个ClientResponse对象或者提前保存了response.content的引用,就可能出问题。我的经验是:始终保持“一次请求一套context”的写法,不要在响应对象上做跨协程缓存。
7.3 结构化日志是排查这类问题的救命稻草
ServerDisconnectedError这类问题最坑的地方在于它偶发、分布不规律、单靠肉眼观察根本看不出规律。如果日志只是简单的[ERROR] xxx failed,你很难判断错误是集中在某个URL、某个时间窗口、还是某种连接上。所以我在生产环境的爬虫里一律用结构化日志,至少包含:URL、目标域名、并发数、重试次数、异常类型、耗时。这样排查问题时直接按字段做聚合统计,马上就能看出问题在哪一层。
logger.error( "request_failed url=%s host=%s attempt=%s error=%s cost_ms=%s", url, urlparse(url).netloc, attempt, error, cost_ms )7.4 其他文件描述符和内存相关的小优化
异步爬虫跑长了,除了连接问题,还容易出现文件描述符泄漏和内存增长。aiohttp在这方面的处理已经不错了,但如果你用了ClientSession而不把它作为上下文管理器使用(不写async with),连接和内部资源可能会长时间不释放。我的习惯是:一个采集任务对应一个独立的ClientSession,任务结束立刻关闭session。这比全局共享一个session更安全,还避免了不同任务之间的连接池互相干扰。
内存方面,如果每个响应都调用await response.text()并保存到列表里,几万个页面下来内存很容易涨到好几个G。正确做法是处理完一个响应就释放引用,或者使用response.content.read()按字节流处理,把内容直接落盘,不要把全文留在内存里。
7.5 最终还是被真实场景教育了
讲完这些,我想说句掏心窝子的话:ServerDisconnectedError本身不是洪水猛兽,它就是一个正常的网络信号,告诉你“这段连接走不通了”。真正让你崩溃的不是这个异常,而是你完全没有预案、没有重试、没有降级,一个异常就能拖垮整条爬虫生产线。我最初碰到这个问题时也想过绕道走,比如干脆用requests同步跑算了,但后来冷静下来想,异步协程带来的吞吐优势是非常明显的,问题就出在“用异步的方式,操着同步的心”。等你把连接池、重试、并发控制这三板斧都磨好了,aiohttp依然是写Python爬虫的首选方案。