写爬虫的时候你是不是也遇到过这种情况代码半天就写完了跑起来却发现慢得离谱。几千个URL单线程一个接一个请求眼见着进度条半天才动一格一算时间要好几个小时。更气人的是看着CPU利用率就没起来过网速也没跑满纯粹就是干等着。这种代码写完了但跑不动的憋屈感我太熟了。这篇文章不是讲怎么爬某个网站而是聊聊Python写爬虫时最容易踩的性能坑以及我实际用下来提升最明显的5个技巧。标题里那个300%不是吹的前提是你得先知道自己慢在哪、然后用对方法。我会从请求、解析、并发、缓存到分布式一层一层拆开讲。不管你是刚入门Python爬虫的新手还是写过一阵子但总觉得效率上不去的开发者这5个技巧都能直接用到项目里而且每一步我都会给出能直接复制的代码和配置思路。1. 慢在哪儿先分清是IO慢还是CPU慢1.1 用最少的时间找到性能瓶颈先说个反常识的结论大部分Python爬虫慢还真不是代码写得差而是根本没搞清楚时间耗在哪一步了。所以我先不急着给优化方案先教你花两分钟定位瓶颈。拿我自己的一个爬虫项目举例。当时要抓一个电商网站的商品列表总共6000多个URL。最初的实现很简单用requests.get()逐个请求然后BeautifulSoup解析跑完要将近1小时。我一开始以为是解析慢直到我在关键流程里加了计时代码import time def timed_call(func, *args, **kwargs): start time.perf_counter() result func(*args, **kwargs) cost time.perf_counter() - start return result, cost resp, fetch_cost timed_call(session.get, url) parsed, parse_cost timed_call(parse_with_bs4, resp.text) print(f请求耗时: {fetch_cost:.3f}s, 解析耗时: {parse_cost:.3f}s)跑了几百个URL之后结果特别清晰平均每个请求耗时在0.8到1.2秒之间而解析最多占0.05秒。也就是说99%的时间都耗在网络IO上解析根本不算瓶颈。这个认知很重要——因为对症下药才是提效的第一步。再顺手说一句如果你的项目里解析占了大头那又是另一个优化方向我后面会用单独一节讲解析提速。但如果像大多数爬虫项目一样网络等待占绝对主力那你最该做的是想办法让等待的时间重叠起来而不是抠解析那点CPU时间。1.2 常见爬虫性能瓶颈清单我把平时带新人时总结的典型瓶颈列了个表你可以对照一下自己的项目瓶颈类型典型表现常见原因对效率的影响网络IO阻塞一个请求完成才发下一个同步请求串行执行最严重时间线性增长TCP/TLS握手重复单次请求看似快总时长高每次新建连接单连接多花0.1~0.3秒解析缓慢页面解析占CPU高正则复杂或BS4默认解析器效率低中等看页面规模重复请求同一个页面被反复抓取无去重、无缓存浪费时间和带宽磁盘/数据库操作存储环节拖后腿逐条INSERT、无批量中后期项目常见这张表里每一项我都踩过后面几个技巧正好逐一对应。顺便奉劝一句优化前一定要量化别凭感觉改代码。用time.perf_counter()或cProfile跑一遍把耗时分布弄清楚再决定要不要大动干戈。2. 技巧一把连接建立的时间省下来——用Session管理连接池2.1 为什么每次requests.get()都那么慢很多人写爬虫都是这样起步的import requests resp requests.get(url)代码简洁也能跑但性能上是真浪费。原因在于每一次requests.get()都会创建一个全新的连接完成HTTP请求后连接就关闭了。下次再请求同一个域名又要重新走一遍TCP三次握手如果是HTTPS还得再来一轮TLS握手。单次握手看似很快也就几十到一两百毫秒但当你抓几千个URL的时候这些时间全成了纯粹的额外开销。打个比方吧。你去同一个超市买东西每次都重新进门、重新找售货员、重新结账买完再出去。下一次买又重复全套流程。而Session就是一个保留着已结账通道的会员卡你和服务器之间建立一条持久连接后面的请求直接复用这条通道不再重复握手。对同一个域名的大量请求这个优化立竿见影。2.2 用Session改造你的请求层改造起来非常简单就是把散落的requests.get(url)统一换成session.get(url)import requests # 创建Session并配置连接池大小 session requests.Session() adapter requests.adapters.HTTPAdapter( pool_connections10, # 同一主机的连接池大小 pool_maxsize50, # 连接池保存的最大连接数 max_retries3, # 请求失败自动重试次数 pool_blockFalse # 连接池满时是否阻塞等待 ) session.mount(http://, adapter) session.mount(https://, adapter) # 设置统一起始请求头 session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Accept-Language: zh-CN,zh;q0.9, }) # 后续所有请求都走同一个连接池 resp session.get(https://example.com/page/1)几个参数的解释pool_connections是指同个主机缓存多少个连接pool_maxsize是全局最多存多少。我一般设10和50就够了不是越大越好——连接池太大的话维护成本上去了还可能被目标站点判定为异常流量。max_retries设为3次能自动处理临时的网络抖动省得你自己写重试逻辑。这里给一个我实测过的数据对比抓取同一个站点的300个详情页用裸requests.get()总耗时约280秒换成Session复用连接后降到220秒左右节省了大概20%。这只是第一步但几乎不用动脑就能拿到。2.3 用Session时需要注意的坑第一连接复用对目标站点是有状态的。如果对方关闭了Keep-Alive或者返回了Connection: close那连接也复用得有限。你可以在响应头里看到这个字段如果是close那就说明这次请求之后连接会被关闭。第二Session的Cookie管理是自动的这在某些需要登录的站点很方便但也要注意别让登录态意外串到别的请求里。多线程或异步场景下建议每个工作协程或进程使用独立的Session避免Cookie并发读写出问题。第三也是最容易忽视的——Session不是万能药。如果你的爬虫只抓一两个页面就跑完了Session带来的收益微乎其微。它的价值在大量、重复、高频请求的场景下才真正体现。所以先判断自己的场景再决定要不要上。3. 技巧二别再让网络IO排队用异步并发把请求叠起来3.1 同步请求的问题全程在等待Session解决了重复握手的问题但核心问题还在同步请求是一问一答的模式发一个请求CPU和网络都闲着等响应下个请求才开始。这就像排队打电话每个人占着线聊完下一个人才能拨号。网络等待占了整个爬虫时间的大头而这段时间CPU完全无事可做。解决思路很直接让多个请求同时进行。我有三条路可选——多线程、多进程、异步IO。对爬虫这种典型IO密集型任务asyncioaiohttp是最轻量、最省资源的选择它在单线程里用事件循环调度成千上万个并发协程不会像多线程那样有大量线程切换开销也不会像多进程那样吃满内存。3.2 用asyncio aiohttp重写请求层先给个最简可跑的版本抓取10个页面做demoimport asyncio import aiohttp async def fetch(session, url): try: async with session.get(url, timeout10) as resp: if resp.status 200: return await resp.text() except Exception as exc: print(f请求失败: {url}, 错误: {exc}) return None async def main(): urls [fhttps://example.com/page/{i} for i in range(1, 11)] # 控制并发数 semaphore asyncio.Semaphore(5) async def bound_fetch(session, url): async with semaphore: return await fetch(session, url) async with aiohttp.ClientSession() as session: tasks [bound_fetch(session, url) for url in urls] results await asyncio.gather(*tasks) for url, html in zip(urls, results): print(url, len(html) if html else FAIL) if __name__ __main__: asyncio.run(main())我习惯用asyncio.Semaphore(5)把并发数限制在5到10左右别一口气全发出去。为什么一是目标服务器不一定扛得住容易被限流甚至封IP二是本地文件、数据库写入如果也在事件循环里操作并发太高会互相挤压。用信号量限流本质上是给自己留个缓冲余地。实测效果特别明显。还是前面300个详情页的场景Session版本耗时约220秒改用aiohttp并发8个请求后直接降到40秒左右。这才叫量级的差别——因为网络等待时间被叠起来而不是一个个排队。3.3 同步与异步混用时要小心的坑用了异步之后最怕一件事情事件循环里混入了同步阻塞代码。比如在协程里直接调requests.get()或者用time.sleep()这些都会把整个事件循环卡住所有的并发请求一起等。正确的做法是如果某个环节必须要用同步库把它丢进asyncio.to_thread()里跑或者干脆换用异步版本的库。另外aiohttp虽然效率高但它在SSL握手、代理支持上有些细节和requests不完全一致。如果遇到奇怪的报错优先检查这几项请求头是否完整、是否设置了超时、是否在异常分支正确关闭了响应对象。我把async with里的resp尽量不手动close直接用async with托管能少踩不少内存泄漏的坑。最后异步不是银弹。如果目标网站本身就特别慢——单次响应要好几秒——那并发能带来的提升也是有限的毕竟服务器是瓶颈。遇到这种站点效率优化的方向就变成了提高并发数去压榨带宽或者干脆考虑分布式抓取这就涉及到后面第五节的内容了。4. 技巧三把解析引擎换掉快得不是一点点4.1 BeautifulSoup不是慢的代名词但要慎用默认解析器说完请求层的优化再聊聊解析。前面我测过解析只占了很小的时间比例但那是页面比较简单的情况。如果页面很大、结构复杂、嵌套层级多解析就会变成不可忽视的瓶颈。BeautifulSoup大家都用过但很多人不知道它默认用的html.parser是纯Python实现的性能一般。同样一段HTML用lxml解析会比html.parser快好几倍。所以只换一行代码——指定解析器——就能拿到立竿见影的提升from bs4 import BeautifulSoup # 慢默认用Python内置解析器 soup BeautifulSoup(html, html.parser) # 快换用lxml解析器 soup BeautifulSoup(html, lxml)别小看这行改动。我在一个论坛爬虫项目里一个帖子页面大概30KB的HTML用默认解析器需要近40毫秒换成lxml后降到8毫秒左右。如果每天抓几十万个页面这个差距会非常明显。如果连lxml的依赖都不想在环境里装还有一个备选方案html5lib更慢但更宽容html.parser算是中间档。实际选型时我基本都会优先上lxml。4.2 更高阶的选择用XPath更直接BeautifulSoup虽然好用但它在API层面隐去了底层树结构的复杂度灵活但不够直接。如果你和我一样经常要抓表格、列表这类结构化数据建议直接用lxml的XPathfrom lxml import etree html etree.HTML(page_html) # 直接通过XPath定位省去一层一层的find调用 titles html.xpath(//div[classlist]//h2/a/text()) links html.xpath(//div[classlist]//h2/a/href)这一改本质上是把找元素从Python对象遍历变成了C语言层面的树搜索速度差距通常在数量级。我之前的经验是同样的解析逻辑用BeautifulSouplxml解析器可能耗时近百毫秒改写成lxml.etree XPath之后能压到20毫秒以内。而且代码更短、意图更清晰可维护性也好了。4.3 再激进一点换解析库如果你已经被BeautifulSoupfind的写法折磨够了而且项目规模确实大那可以考虑直接上selectolax这类基于C解析器libxml2/Modest封装的高性能库。selectolax的API类似BeautifulSoup但底层是用C实现的解析速度通常比lxml还快from selectolax.parser import HTMLParser parser HTMLParser(html) # 类似CSS选择器的写法 titles [node.text() for node in parser.css(div.list h2 a)]注意selectolax返回的节点API和BeautifulSoup不一样很多习惯写法要改。我的建议是如果页面解析速度已经不是你项目的瓶颈不要为了换而换保持团队习惯更重要。但如果你确实卡在解析这关selectolax是一个值得调研的方向。4.4 尽量提取结构化数据而不是解析HTML一个容易被忽略的经验能做结构化提取的地方就别去解析HTML。很多页面下面其实内嵌了JSON数据或者接口直接返回JSON。如果能直接拿到数据源解析HTML完全是绕远路。举几个例子很多列表页的下一页数据实际上是由一个JSON接口动态加载的数据量大的站点会直接在HTML里内联window.__INITIAL_STATE__这样的JSON字段。用正则或JSON解析器直接从原始响应里提取比建一棵HTML解析树快几个量级还稳。判断方法很简单在浏览器开发者工具的Network面板里看看页面加载的时候是不是发了很多XHR请求。如果是先用这些接口来取数比硬啃HTML靠谱太多。5. 技巧四用缓存和去重别让同样的页面请求两次5.1 一份代码跑两遍重复请求全浪费很多爬虫项目做到后面数据量上去了才发现真正的浪费不是慢而是重复。最常见的就是你每天跑一次全量爬虫但昨天已经抓过的页面今天又重新抓了一遍或者分页列表页里有一堆重复链接requests照样给你一个不落全请求了。解决思路分两层请求前的去重和请求后的缓存。去重保证同一个URL不会被抓第二遍缓存保证即使因为重试等原因重复请求了也能直接命中结果不再走网络。5.2 去重不只有set()这一种方案先来个最简单的内存去重seen set() def fetch(url): if url in seen: return None seen.add(url) # 继续请求...单机、单次运行set就够了。但爬虫经常要跑很多轮内存里的seen一重启就没了。所以稍微正规点的项目我会用Redis做去重import redis r redis.Redis(hostlocalhost, port6379, db0) def is_duplicate(url): key fcrawled:{abs(hash(url))} # 实际生产建议用hashlib.md5 if r.sadd(scrapy:seen_urls, key): return False return True这里我用hashlib.md5或sha1做URL指纹更稳妥因为Python内置hash()每次进程的种子不同跨进程不稳定import hashlib def url_fingerprint(url: str) - str: return hashlib.md5(url.encode(utf-8)).hexdigest()Redis去重的好处是天然支持多进程和分布式可以跨机器共享同一个去重集合。如果你还在用MySQL做爬虫数据存储把一个url字段设置成UNIQUE索引也可以顺手承担一部分去重职责但要记得捕获IntegrityError。5.3 抓过的不再抓增量爬取思路去重之外主要是用缓存让重复请求直接命中。最简单的方式是磁盘缓存import os import hashlib from pathlib import Path CACHE_DIR Path(./http_cache) def cached_get(session, url, cache_seconds3600): cache_file CACHE_DIR / hashlib.md5(url.encode()).hexdigest() if cache_file.exists() and cache_file.stat().st_mtime time.time() - cache_seconds: return cache_file.read_text(encodingutf-8) text session.get(url).text cache_file.write_text(text, encodingutf-8) return text配合Session的用法很顺。下次再跑同一个URL只要缓存没过期直接读本地文件一秒都不用等。我现在很少写全新的通用爬虫基本都是维护这种带缓存的增量版跑一轮的时间能压缩到第一次跑的十分之一甚至更少。这里有一个很重要的平衡缓存太长时间数据时效性就差缓存太短收益就小。我的实践经验是对不常变更的数据如商品详情页的规格参数缓存24小时没问题对时效敏感的如价格、库存、榜单缓存5到10分钟就够了。5.4 缓存也可以反过来帮你控制访问频率多说一句缓存不只是为了快它还能帮你降低对目标站点的访问次数——这既是对对方服务器的礼貌也是降低自己IP被限制概率的有效手段。我见过一些爬虫项目为了避免被封把请求速率调得很低结果跑一个任务要好几天。用缓存解决重复请求之后访问频率降下来了抓取效率反而上去了——两全其美。不过缓存要注意清理尤其磁盘缓存。长期跑缓存目录会越来越大记得写个脚本按时间删除超期的缓存文件或者在读取时直接判断有效期并略过过期文件。6. 技巧五用上所有核心——多进程与分布式扩展6.1 异步也搞不定的任务才需要多进程和分布式前面几招组合起来单机爬虫的请求效率其实已经很高了。但总有一些场景单机怎么都扛不住比如每天的待抓URL有几百万个单机带宽、内存、连接数都是上限又比如解析逻辑特别重异步协程遇到CPU密集的解析任务会卡住整个事件循环。这时候就要考虑两个方向多进程和分布式。多进程能利用多核CPU绕过GIL的限制分布式则是把任务分发到多台机器上并行执行整体吞吐量可以横向扩展。6.2 用ProcessPoolExecutor做多进程并行如果你只是想让CPU解析代码跑满所有核concurrent.futures.ProcessPoolExecutor是最容易上手的方案from concurrent.futures import ProcessPoolExecutor import hashlib def parse_page(url): # 模拟一个比较耗时的解析任务 fake_html fhtmlbodyh1{url}/h1/body/html # 这里放真实的解析代码返回结构化数据 return {url: url, hash: hashlib.md5(fake_html.encode()).hexdigest()} urls [fhttps://example.com/item/{i} for i in range(100)] with ProcessPoolExecutor(max_workers8) as executor: results list(executor.map(parse_page, urls))注意进程之间不能直接共享Session或队列对象所以通常是主进程负责发请求拿到页面后把文本发给子进程去解析。更标准的架构是主进程或异步代码负责IO把HTML文本通过ProcessPoolExecutor提交给子进程解析解析结果再回收。这样既利用异步的IO高并发又利用多核的CPU算力是单机场景的终极形态。6.3 分布式把队列放到Redis里机器无限扩展如果单机都到顶了那就上分布式。分布式爬虫最核心的三个组件是任务队列谁该抓、去重池哪些抓过了、结果存储抓到的放哪。我通常在Redis里用一个LIST当任务队列一个SET当去重池几台机器都去消费同一个队列import redis import json r redis.Redis(hostredis_host, port6379, db1) def producer(urls): pipeline r.pipeline() for url in urls: if not r.sismember(dedup:urls, url): pipeline.sadd(dedup:urls, url) pipeline.rpush(queue:tasks, json.dumps({url: url})) pipeline.execute() def consumer(): while True: # 阻塞取任务取不到就等待 _, raw_task r.blpop(queue:tasks, timeout30) if raw_task is None: break task json.loads(raw_task) url task[url] # 业务处理请求、解析、存储这种架构的优雅之处在于加机器就等于加消费者不用改任何代码去重逻辑放在Redis的SET里天然全局唯一任务持久化在Redis里即使某台机器宕机任务也不会丢失其他消费者会继续处理。当然生产级方案我推荐直接用Scrapy Scrapy-Redis它把队列、去重、调度都封装好了比自己造轮子省心太多。6.4 什么样的项目才值得上分布式别杀鸡用牛刀我得泼一盆冷水如果你的任务量只是几千个URL单机异步并发10个就能10分钟跑完那分布式就是纯浪费。分布式要维护Redis、要管多台机器、要处理失败重试分发运维成本远高于收益。我的建议是先把单机异步、缓存、多进程组合方案用到极致确认瓶颈真的在吞吐量而不是代码效率再考虑分布式。还有一个容易被忽略的点大规模的分布式爬虫会极大消耗目标站点的资源这也是最大的合规风险点。很多站点的服务条款明确禁止自动抓取过高的请求频率可能触达法律和道德的边界。我个人的实操准则是无论什么规模的项目始终保持对目标站点的友好遵循robots.txt控制合理频率数据只用于个人学习和研究。千万不要为了效率而去踩那条红线。7. 不同技巧的叠加效果与实际收益聊完五个技巧我想用一个实际案例把它们的组合收益量化一下。一个常见场景抓取某资讯站点的文章列表共2000个URL每篇文章页面大约20KB解析出标题、正文和发布时间。方案主要改动预计总耗时基础版requests.get()BeautifulSoup默认解析器约40分钟加Session连接复用约32分钟加异步并发8个请求约6分钟加lxml/XPath解析提速约5分钟加缓存去重二次运行从5分钟降到30秒以内这个数据来自我之前的类似项目的记录。可以看到最大的收益来自异步并发这一项直接把时间从半小时级别拉到几分钟级别。Session和解析的优化是锦上添花但累积起来也很可观。缓存去重则是针对重复运行场景的杀手锏——第一次跑完之后每次增量几乎免费。所以如果你想提升爬虫效率我强烈建议按这个顺序来先量化瓶颈 → 然后上异步并发收益最大→ 再优化请求细节Session→ 再换解析引擎 → 最后考虑缓存和多机。一步到位上分布式往往不是最优路径。8. 频率控制、限流与合规这些事别等踩坑再学前面聊了很多怎么快最后必须花点篇幅聊聊怎么安全地快。我见过太多人把爬虫跑起来之后因为请求频率太猛被目标站点封了IP然后封了换代理、换了再封抓到的数据量还不如一开始细水长流来的多。这属于典型的战术勤奋、战略懒惰。所谓频率控制本质上就是有节奏地快。异步并发再高也别忘了前面信号量限制并发数的做法。我给自己的经验是目标站点没明确限制时单机并发默认8到15单请求间隔控制在几百毫秒到1秒之间。大型站点通常对请求频率有自己的防护策略频繁触发还不一定抓得到数据反而浪费时间。一个页面被限流重试三次都失败果断跳过比死磕效率高得多。再强调一句合规的事。爬虫抓取公开数据本身是中性技术但怎么用它是有边界的。我现在的习惯是开工前先看目标网站的robots.txt了解哪些路径允许抓取抓取频率控制在合理范围不把对方服务器打挂抓到的数据只用于个人学习研究不用于商业变现更不碰用户隐私数据。做技术的人越到后面越明白——效率是能力克制是智慧两者缺一不可。说回实测严格遵守上面的规则之后我反而觉得整体吞吐量一点没降因为省去了封IP后换代理、清缓存的折腾时间。稳定的慢远好于不稳定的快。这也是我这些年做爬虫项目最值钱的一条经验。9. 实战中的最后一个建议先写工具再造轮子最后分享一点个人层面的建议。很多新人喜欢一上来就研究高级特性比如造一个通用爬虫框架。我当年也是这么干的结果框架写完业务需求一变框架反而限制了灵活性。实际做项目这么多年我的选择是能用现成工具就绝不自己造轮子。requests、aiohttp、lxml、BeautifulSoup、Scrapy、Scrapy-Redis这些库和框架已经非常成熟踩坑踩得也差不多了直接用比自己从零实现靠谱得多。我自己平时可能只写一个几十行的封装模块把请求、解析、去重、存储串起来然后针对具体站点去调整细节。框架的重抽象能力远不如针对业务场景写具体代码来得实在。如果你的目标是提升爬虫项目的开发效率第一步是借鉴现成经验第二步是基于业务写点自己的小工具第三步才是考虑要不要引入重型框架。顺序反了大概率会陷入为了框架而框架的泥潭。回到开头说的那个300%——其实核心就是两点找到真正的瓶颈然后选对工具把它打掉。做到这两点你的爬虫不可能不快。