ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python异步爬虫连接池中断报错排查与重试并发策略

Python异步爬虫连接池中断报错排查与重试并发策略 跑一个企业级的Python异步爬虫最烦的就是程序跑着跑着突然冒出一堆ServerDisconnectedError。我在接一个电商数据采集项目时遇到过这样一个情况用asyncioaiohttp写了套异步协程爬虫300个并发请求丢进去前5分钟一切正常然后错误率突然飙升日志被ServerDisconnectedError刷屏程序像被抽空了一样所有请求瞬间失败。最气人的是这个错误不是偶发的每次跑到同一个量级必然出现而且目标网站并没有封我IP——因为我换回requests慢慢爬同一个URL又完全正常。这篇文章我就把自己排查和解决这个问题的完整过程写出来包括底层原理、连接池参数拆解、重试策略设计、并发度调整以及最终一套可以直接抄走的完整实战代码。适合正在用aiohttp写爬虫、被这个错误折磨过的开发者也适合准备上手异步爬虫想提前避坑的朋友。1. 先说说我在真实项目中遇见的场景那次项目是抓某个垂直电商平台的商品详情页量级不大总量大概10万个URL。一开始我用requestsThreadPoolExecutor30个线程跑效率也能接受但目标网站的响应时间不太稳定平均一个请求要1秒多30个线程每秒撑死20来个请求。按这个速度10万个页面得跑一个多小时加上反爬策略触发后的降速实际耗时更长。于是我把方案换成了aiohttp异步协程想着用200个并发连接吞吐量能直接拉满。改造倒是很顺利ClientSessionasyncio.gather这套写法网上到处是教程照着写很快跑通了。前几分钟速度确实快了很多每秒能处理50到80个请求我心里还挺美。结果跑了不到5分钟日志里开始出现ServerDisconnectedError一开始是个别请求失败几分钟后像连锁反应一样大片请求全部报错程序基本处于瘫痪状态。我当时的第一反应是IP被目标网站封了。因为服务端主动断连最常见的解释就是服务端发现你行为异常直接拒了。我用curl手动请求了一下页面发现完全正常连验证码都没有弹。这就很诡异了——服务端明明没封我为什么连接一个接一个断开我又怀疑是本地连接数超限查了系统的文件描述符限制ulimit -n确认过65535net.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 核心参数逐个拆解参数名默认值含义爬虫场景建议limit100连接池中所有连接的并发上限根据目标站点承受能力调整别盲目调大limit_per_host0不限制同一个host最多复用多少个连接建议设置比如10~30防止单域名被打爆keepalive_timeout15连接池中空闲连接的最大存活时间建议调低到5左右匹配常见服务端keep-aliveforce_closeFalse每个请求结束后关闭连接不进入连接池反爬严格时可以设True但会损失性能enable_cleanup_closedFalse清理那些被服务端异常关闭但未通知本地的连接Windows系统建议设TrueLinux也别省verify_sslTrueSSL证书校验自签名证书场景要设False但别乱关3.2 我实际调优的Connection配置针对那次电商采集项目我最终采用的TCPConnector配置长这样import aiohttp connector aiohttp.TCPConnector( limit50, # 总并发连接数不超过50 limit_per_host10, # 单域名最多10个连接 keepalive_timeout5, # 本地空闲连接5秒就关闭 enable_cleanup_closedTrue, # 主动清理被服务端异常关闭的连接 force_closeFalse, # 保留连接池复用毕竟性能要紧 ttl_dns_cache300 # DNS缓存5分钟 ) session aiohttp.ClientSession( connectorconnector, timeoutaiohttp.ClientTimeout(total30, connect10) )这个配置的核心思路是把本地连接池的keep-alive存活时间压到5秒低于绝大多数服务端的空闲超时时间这样每次复用的连接大概率是服务端还没舍得扔的连接。limit_per_host设成10防止某个热门页面的请求把连接池全占完导致其他域名的请求全部排队等待。你可能会有疑问既然force_closeTrue能完全避免连接复用失效的问题为什么不直接用它原因很简单性能损失太大。如果每个请求都要重新建立TCP连接三次握手的开销在TLS连接上会被放大因为TLS握手还要来回交换证书。我在测试中发现force_closeTrue会让整体耗时增加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_retries3): 带指数退避和抖动的请求方法 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, base0.5, cap5.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秒还没返回完整响应就算最后返回了对爬虫来说这个响应时间也意味着风险。我实际场景里用的是total20connect5sock_read10socket读超时。这个配置在我跑的多个项目里都比较稳定。4.4 重试与数据幂等性最后提醒一句重试逻辑在只读的GET请求场景下没有任何问题但在POST请求场景下要特别小心。如果目标接口不是幂等的一次重试可能导致服务端产生重复数据。如果你写的是采集类爬虫绝大部分是GET请求重试可以放开写。但如果你在写提交类的脚本比如自动签到、提交表单重试之前先确认接口设计是否幂等或者设计一套“请求唯一ID”去重机制否则一次网络抖动就可能造成业务数据的脏写。5. 高效策略三并发度的科学调整很多人把“异步”和“高并发”划等号觉得只要用了asyncio并发数就能无限调大。这是个很危险的误解。异步协程解决的是IO等待问题把CPU在等待IO的时间里腾出来干别的事但网络连接数本身依然受操作系统、目标服务器、本地网络栈的硬限制。并发度设得不合理ServerDisconnectedError只是一个开始后面还会跟着各种奇怪的连接错误。5.1 并发度与错误率的关系我亲测过我拿同一个目标网站做了个简单的梯度测试。在连接池参数调整好、重试机制保持不变的前提下我分别用50、100、200、300、500个并发请求各跑了1000个URL记录错误率和整体耗时。并发数平均耗时ServerDisconnectedError错误率备注5038秒0.1%很稳定10021秒0.3%正常20013秒1.2%错误开始上来了30011秒4.6%错误率明显上升5009.5秒12.3%不可接受从这张表能看出来并发数从200提到500耗时只减少了3秒多但错误率翻了10倍。这个拐点其实非常关键。很多爬虫工程师喜欢把并发拉满觉得“跑得越快越好”但实际上在拐点之后你再往上加并发收益越来越小代价越来越大——错误越来越多重试时间越来越长甚至可能因为触发WAF导致IP被封。5.2 用Semaphore做“双保险”并发控制连接池的limit参数能限制连接总数但它管不住“同时发起请求的协程数”。比如连接池limit50你可能有500个协程同时调用session.get()然后这500个协程挤在连接池门口排队。这个排队过程虽然不会报错但会造成大量的协程上下文切换和调度开销。更关键的是如果请求之间还有依赖关系比如第一个请求拿到商品ID后再请求详情连接池的limit就完全不够用了。所以我在协程层加asyncio.Semaphore对同时发起的请求数量做一次限流。这是双保险信号量控住协程并发连接池控住底层连接数。import asyncio from aiohttp import ClientSession class Crawler: def __init__(self, total_concurrency50, per_host_concurrency10): 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(levellogging.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( limittotal_concurrency, limit_per_hostper_host_concurrency, keepalive_timeout5, # 主动低于服务端keep-alive时间 enable_cleanup_closedTrue, # 清理异常连接 force_closeFalse, ttl_dns_cache300, ) self.timeout aiohttp.ClientTimeout(total20, connect5, sock_read10) 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(urlurl) 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 fHTTP {response.status} if attempt self.max_retries - 1: await asyncio.sleep(self._backoff(attempt)) continue return result if response.status ! 200: result.error fHTTP {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 fTimeout: {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(connectorself.connector, timeoutself.timeout) as session: tasks [self._fetch_one(session, url) for url in self.urls] self.results await asyncio.gather(*tasks, return_exceptionsFalse) return self.results async def main(): # 你在实际使用中把这里换成真实URL集合 urls [fhttps://example.com/detail/{i} for i in range(500)] crawler AsyncCrawler(urls, total_concurrency50, per_host_concurrency10) 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.04Python 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(), timeout5) except asyncio.TimeoutError: pass把这个观测协程塞进run方法的async with上下文里就能在终端实时看到采集过程中的成功率变化。一旦发现成功率低于预期可以迅速按下CTRLC终止脚本调整参数后重新跑不用等全部跑完才发现错误率超标。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爬虫的首选方案。
返回列表