
1. 瓶颈在哪别急着加线程先看清多线程爬虫的真相很多刚开始写爬虫的朋友一遇到采集速度慢第一反应就是“上多线程”。这个思路本身没错但问题在于如果不搞清楚多线程到底优化了什么、没优化什么那很可能开了一堆线程速度不升反降甚至被目标网站封了IP整个采集任务直接报废。我先说结论Python多线程爬虫的性能优化核心不在于“多用几个线程”而在于让线程真正忙起来。你需要先想清楚一个问题——你的爬虫当前的时间到底花在哪里。如果是花在等待网络响应上多线程确实有效如果是花在CPU计算上那多线程不仅没帮助还会因为GIL锁拖慢速度。这一步判断错后面所有优化方案都是空中楼阁。这里就要提到Python里一个绕不开的概念GILGlobal Interpreter Lock全局解释器锁。通俗点讲GIL保证了同一个进程里同一时刻只有一个线程在执行Python字节码。所以如果你的爬虫主要是在做CPU密集型的计算比如对每一篇抓下来的HTML做复杂的正则匹配、用大量的逻辑判断去解析字段那么多线程并不会让这些计算并行执行。真正要加速这种场景应该考虑多进程或者直接换工具。那多线程爬虫到底优化了什么呢答案是IO等待时间。网络请求发出后CPU一直在等待响应返回。这个等待过程不占用GIL——在真正的IO等待期间Python会释放GIL让其他线程有机会执行。也就是说多线程爬虫的价值在于当某一个请求正在等待网络的毫秒级延迟时另外的线程可以去发起新的请求、处理已经返回的数据。这样整体上就把看似零散的等待时间拼了起来单位时间内能发出去的请求自然变多了。明白了这层原理再看网上那些“线程数拉到50”“并发开200”的配置就知道有多危险了。开多少线程合适要看你的目标网站响应速度、你本机的性能、以及爬虫单次请求的业务逻辑复杂度。盲目开太多线程CPU切换线程的成本会陡增目标服务器也可能直接把你拉黑得不偿失。2. 基础优化线程池、任务队列与请求会话的正确姿势2.1 线程数量到底开多少合适先给一个参考公式。假设目标网站单次响应耗时是 (T_r)本地解析和写数据耗时是 (T_p)那么单个线程完成一个请求的周期大约是 (T_r T_p)。如果我们希望本机每秒发出 (N) 个请求那么理论上需要的线程数大约是[ 线程数 \approx N \times (T_r T_p) ]举个例子目标接口平均响应200ms本地解析加存储大概需要50ms一个请求完整体验是250ms。如果你希望每秒发出20个请求那差不多需要 (20 \times 0.25 5) 个线程。当然这只是理论值还要考虑网络抖动、目标服务器限流、本机上下文切换的开销实际使用中我会在这个理论值基础上乘以1.2到1.5的安全系数。不过说实话公式只是起步参考真正靠谱的办法是从低到高逐步压测。先把线程数设成2稳定跑一分钟观察每秒请求数和错误率再逐步升到4、8、16直到出现响应变慢或错误增多的拐点那个拐点附近就是这台机器、这个目标站点的合理线程数。我见过太多人一上来就开50个线程抓一个小网站结果目标服务器响应从200ms被打到2秒错误率飙升。爬虫不是拉满就是好细水长流才是长久之计。2.2 线程池比手动创建线程靠谱得多多线程爬虫最忌讳的做法是每个请求都手动创建新线程。假设你要采集10万条数据每条都threading.Thread(...).start()那就意味着系统需要创建和销毁10万个线程。每次创建线程都要申请内存、初始化运行时环境销毁时还要回收资源这些开销加起来非常可观而且线程数量一旦失控系统调度压力陡增爬虫性能反而会直线下降。正确的方案是用线程池把线程的生命周期统一管理起来。Python自带的concurrent.futures.ThreadPoolExecutor就够用了简单、没有额外依赖适合绝大多数采集场景。from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_one(url): # 实际的请求和解析逻辑 return url, data urls [https://example.com/item/1, https://example.com/item/2, ...] # 合理设置线程数这里以8为例 with ThreadPoolExecutor(max_workers8) as executor: future_map {executor.submit(fetch_one, url): url for url in urls} for future in as_completed(future_map): url future_map[future] try: result future.result() # 处理结果比如写库、写文件 except Exception as e: print(f请求失败: {url}, 错误: {e})with语句会在代码块结束时自动调用shutdown(waitTrue)优雅地等待所有线程完成任务。这个细节很重要它避免了主线程提前退出导致子线程任务被强杀的问题。写爬虫跑批任务时我踩过好几次这种坑主线程里的任务提交完了直接退出结果半数的请求还没回来程序就结束了。2.3 任务队列缓冲区的艺术线程池解决了线程生命周期管理问题但还需要考虑任务分发。如果你的采集任务是一个动态列表——比如先抓列表页拿到一批详情页URL之后再去抓详情页——那光靠固定列表就不够用了。这时需要引入生产者消费者模式。queue.Queue是标准库自带的任务队列特点是线程安全多线程同时往里塞数据、取数据都不会出问题。整体结构上可以分成三层生产者负责把待抓取的URL放进队列工作线程从队列里取URL去请求和解析消费者负责把解析结果落库或写文件。import queue import threading task_queue queue.Queue(maxsize200) # 生产者不断往队列里放URL def producer(url_list): for url in url_list: task_queue.put(url) # 消费者工作线程从队列取URL并抓取 def worker(): while True: url task_queue.get() if url is None: # 哨兵值用于退出循环 break try: fetch_url(url) finally: task_queue.task_done()有几个细节值得注意。一是队列长度要限制用maxsize设置一个合理上限。如果队列无穷大生产者速度远快于消费者内存会被待处理URL挤爆。二是要设置哨兵值比如None来通知工作线程退出不给的话所有线程会永远卡在task_queue.get()上主程序退不干净。三是task_done()与join()配合只有所有任务都标记完成queue.join()才会返回这能确保退出前所有任务都真正处理完了。2.4 请求会话复用每个线程一个专属连接爬虫一旦上了多线程很多人都会遇到一个奇怪的现象单线程时请求很正常一开多线程频繁出现连接被重置、socket超时。排查到最后发现问题往往出在Session的使用方式上。requests.Session内部维护了TCP连接池可以复用底层连接减少每次请求时三次握手和TLS握手的开销。但如果多个线程共享同一个Session连接池的并发竞争会导致连接状态混乱反而触发各种奇怪的网络错误。正确的姿势是每个线程一个Session或者至少保证Session的创建和线程绑定。在ThreadPoolExecutor场景下可以用threading.local()为每个线程保存独立的Session实例import threading import requests thread_local threading.local() def get_session(): if not hasattr(thread_local, session): thread_local.session requests.Session() thread_local.session.headers.update({ User-Agent: Mozilla/5.0 (compatible; MySpider/1.0) }) return thread_local.session这样每个线程只会创建一次Session后续所有请求都复用线程内的TCP连接既避免了共享Session的线程安全问题又让连接池真正发挥作用。从实测效果来看无论是目标响应速度还是本地资源占用这个改动带来的提升都相当明显。2.5 超时与重试不给僵尸线程留机会多线程爬虫最怕的一个场景某个请求永远不返回线程在那里干等什么活也不干。如果一个工作线程被一个超时未响应的请求卡住几分钟那这个线程的吞吐贡献基本就归零了。更麻烦的是如果一个进程里有多个线程同时被不同请求卡住整个任务会越跑越慢看着像是卡死了实际上是在等超时。标准做法是在请求层设置明确的超时时间并且区分连接超时和读取超时session.get(url, timeout(3.05, 10))这个元组里第一个值是连接超时连接建立超过3.05秒就放弃第二个值是读取超时数据包间隔超过10秒就放弃。设置超时之后还要配合重试机制。重试不是简单地重来一遍而是要有退避策略——第一次失败后等1秒第二次等2秒逐步放大间隔避免在目标服务器还没恢复时用高并发反复冲击把自己送进封禁名单。requests库本身不支持自动重试需要借助requests.adapters.HTTPAdapter来注入urllib3的Retry策略from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry_strategy Retry( total3, # 最多重试3次 backoff_factor1, # 退避时间1s, 2s, 4s status_forcelist[429, 500, 502, 503, 504], allowed_methods[GET, POST], ) adapter HTTPAdapter(max_retriesretry_strategy, pool_connections10, pool_maxsize10) session get_session() session.mount(http://, adapter) session.mount(https://, adapter)需要注意status_forcelist里加429是指定HTTP 429请求过多也纳入重试范围。目标服务器返回429就是在告诉你“慢一点”此时退避重试是最正确的应对方式大部分情况下能够平稳渡过限流窗口。3. 性能黑洞与瓶颈排查从日志到数据每一分钟都花在刀刃上3.1 数据解析优化容易被忽略的大头不少爬虫在优化时只顾着调线程数忘了看数据解析环节的耗时。这里有个真实的模拟场景某个采集任务需要抓取商品列表每个列表页20个商品。最初所有解析逻辑都用BeautifulSoup的find_all配合CSS选择器把整个HTML先转成复杂的对象树再反复查询。单个页面解析耗时接近120ms对于每秒要处理几十个页面的爬虫这部分CPU计算量就很可观了。而且要强调一点解析是纯CPU计算前面讲过GIL的限制这部分计算在多线程下不会并行加速。也就是说线程开得越多每个线程分到的CPU时间片反而越少整体吞吐甚至可能下降。所以数据解析优化往往是爬虫性能提升空间最大的环节。常见的优化路线是“从重到轻”如果有明确规律的数据优先用正则表达式或字符串切片处理如果需要处理复杂嵌套的HTML用lxml的etree.HTML配合XPath性能比BeautifulSoup高一个数量级只有确实需要容错性很强的解析时才保留BeautifulSoup并且尽量用lxml作为底层解析器即在BeautifulSoup(markup, lxml)中指定解析引擎。from lxml import etree def parse_item(html): tree etree.HTML(html) # XPath提取标题 title tree.xpath(//h1[classitem-title]/text()) # XPath提取价格 price tree.xpath(//span[classprice]/text()) return title[0].strip() if title else , price[0].strip() if price else 拿同一个模拟页面做对比测试BeautifulSoup方案耗时约120ms换etree.HTML加XPath之后单页解析大概25ms提升接近5倍。这还是小页面如果遇到几千行的大HTML差距会更夸张。这个优化本质上不改变线程数量但每个线程能处理的页面数大幅上升整体吞吐自然就上去了。3.2 序列化与存储把“写”变成异步操作解析完成之后的数据要去哪里如果每次解析完都直接写数据库或写文件这个写操作会阻塞当前线程。尤其是写入数据库的场景如果使用同步连接一次插入就算只要几毫秒在成千上万次请求的积累下阻塞时间也相当可观。更合理的设计是把数据写入做成异步。可以用一个单独的消费者线程把解析结果放进另一个队列由专门的存储线程批量处理。批量插入比逐条插入的效率高得多还能减少数据库连接建立和事务提交的开销。import sqlite3 import queue store_queue queue.Queue(maxsize500) store_thread_stop threading.Event() def store_worker(): conn sqlite3.connect(data.db) cur conn.cursor() batch_size 50 while not store_thread_stop.is_set(): batch [] # 批量取出最多50条 for _ in range(batch_size): try: item store_queue.get_nowait() batch.append(item) except queue.Empty: break if batch: cur.executemany( INSERT INTO items(url, title, price) VALUES(?, ?, ?), batch ) conn.commit() else: store_thread_stop.wait(0.5) # 队列空时短暂休眠 store_thread threading.Thread(targetstore_worker) store_thread.start()这里把写库放到了独立线程中抓取线程只需要把数据塞进队列就能立刻返回继续抓取下一个页面。虽然看起来只是把阻塞从抓取线程转移到了存储线程但效果完全不同抓取线程不再被磁盘或数据库拖慢系统整体吞吐量由生产和消费之间的平衡决定而不受最慢环节的制约。3.3 请求耗时与并发收益的可视化优化过程中有一个操作我建议所有爬虫开发者都做打印一份请求耗时分布统计。不用多复杂的工具最简单的就是按耗时区间分组记录请求数量比如0-100ms、100-200ms、200-500ms、500ms以上各有多少请求。跑一批样本请求后你会看到耗时分布呈现什么形态这比猜要准得多。举个实际例子某个模拟抓取任务目标接口平均耗时180ms但高耗时区间有一些尖峰达到800ms甚至1秒。如果按平均耗时180ms计算5个线程一秒钟大约能完成25个请求。但实际跑下来每秒只有12个左右因为那些800ms的尖峰请求拖慢了整条流水线。这类情况下先减少单次请求的传输量、优化请求头、启用压缩传输往往比单纯加线程更有效。3.4 常见性能问题速查表现象可能原因排查思路解决方案线程增多但吞吐不升解析逻辑密集GIL限制用profile工具统计CPU时间占比优化解析代码考虑多进程频繁connection reset多个线程共享一个Session查看是否有人为共享Session用threading.local实现每线程独立Session请求大量超时线程数超过目标服务器承载压测找到拐点降低线程数增加退避重试任务跑着跑着卡住某请求长期无响应检查是否有请求没设超时设置连接超时和读取超时增加重试内存持续上涨队列中堆积大量未处理数