
你有没有遇到过这种场景手里的Python爬虫跑起来比手动复制粘贴还慢打开一个页面等好几秒点完按钮还要再等渲染几百个URL排成一串跑到最后已经是深夜。我最开始用Selenium写爬虫时就是这个状态单线程一个个跑日志一行行刷真实耗时比预估多出三倍不止。后来我把架构改成 Python 线程池 Selenium 并发爬虫一批任务同时跑页面加载、字段解析、CSV落盘并行起来整体采集时间直接砍掉一个量级。这篇文章就来拆解这套方案为什么爬虫会卡顿、线程池怎么配置才合理、Selenium 在并发下有哪些坑、CSV 数据怎么安全落地。适合刚入门 Python 爬虫但被速度折磨过的同学也适合想做动态页面批量采集但不知道从哪下手的工程师。1. 卡顿的根源与并发方案选型1.1 爬虫为什么慢先说个简单的算术问题。单个页面从发起请求到拿到完整渲染结果耗时一般要 2~5 秒如果页面里有异步加载、懒加载图片、弹窗遮罩这个时间会更长。假设平均 3 秒一个页面100 个页面就是 300 秒也就是 5 分钟。看起来还能接受但如果页面数量到 1000、5000时间就变成 50 分钟到 4 个多小时。这还只是网络和渲染的耗时。Selenium 的麻烦在于它不是单纯发 HTTP 请求而是要真实打开浏览器、加载 JS、执行脚本、等待元素出现每一步都有额外开销。于是很多人在循环里写下类似driver.get(url)、time.sleep(2)、定位元素、提取字段这样的代码整个流程天然就是串行的。串行意味着 CPU、内存、网络带宽都闲着但任务就是快不起来。这里有一个关键认知爬虫的慢绝大多数不是慢在 Python 本身的执行速度而是慢在等待外部资源也就是网络 IO 和浏览器渲染。这种“等待密集型”任务恰恰是并发能带来巨大收益的场景。1.2 线程、进程、协程三选一说到 Python 并发很多人脑子里会立刻弹出三个选项多线程、多进程、协程。选哪个取决于你的任务到底是什么性质。先看一张对比表方案GIL 影响适用场景Selenium 适配难度资源消耗线程池IO 等待时主动释放影响小网络请求、文件读写、Selenium低每个 worker 持有一个 driver 即可中等多进程完全绕过 GILCPU 密集计算、数据清洗高每个进程要独立启动和销毁浏览器很高协程单线程内切换无 GIL 问题纯异步 IO如 aiohttp高需要完全重写 Selenium 调用方式低对于 Selenium 爬虫核心操作是等待页面渲染、等待元素出现、等待网络返回这些场景下线程池的优势非常明显。线程在等待外部资源时GIL 会被释放其他线程可以继续执行自己的逻辑互不阻塞。协程理论上更轻量但 Selenium 的webdriver是同步阻塞模型你要在协程里调用它就得用线程池再把同步操作包一层最后复杂度一点没降。多进程更夸张每个进程都要独立启动一套浏览器环境8 个进程开 8 个 Chrome内存轻松吃掉好几个 GB普通开发机根本扛不住。生活化类比一下多进程像是给每个员工配一台独立的电脑和一套浏览器多线程像是几台电脑共用一张办公桌协程则像一个员工在几台电脑之间来回跑。Selenium 这个活儿员工大部分时间都在等网页加载这时候让他占着一整台电脑纯属浪费。所以线程池是 Selenium 并发爬虫最务实的选择。实现简单、调试方便、不需要改项目整体架构把原来的for循环改成ThreadPoolExecutor.map就能跑起来。2. 线程池的原理与核心参数配置2.1 队列与阻塞机制ThreadPoolExecutor的底层机制其实不复杂线程池里维护一组工作线程外部提交任务时会先放进一个任务队列空闲线程从队列里取任务执行。Python 标准库用的是queue.SimpleQueue这是一个无界队列。关键点在于Python 的ThreadPoolExecutor不像 Java 的ThreadPoolExecutor那样可以显式配置有界队列和拒绝策略。它的队列是无界的所以你submit多少个任务任务就会全部塞进队列里线程池不会拒绝也不会阻塞调用方。这在某些场景下会有隐患如果任务数特别多比如一次性提交了几万个 URL队列会吃掉大量内存。实战中的对策是分批提交。比如每次只提交 20 到 30 个任务等线程池消化完一批再提交下一批。这样既能保持并发度又不会让任务队列无限膨胀。我会在后面的代码里演示具体做法。线程池还有一个细节任务队列是线程安全的多线程同时submit不会有数据竞争。你可以在主线程里安静地循环提交任务也可以在一个专门的调度线程里不停塞任务都没有问题。2.2 worker 数量怎么定max_workers是线程池最重要的参数。这个参数设小了并发效果出不来设大了机器会被浏览器进程拖垮线程之间互相抢 CPU反而更慢。Selenium 场景下需要把浏览器内存占用也考虑进去。一个 Chrome headless 进程大概要吃 150~300 MB 内存加上浏览器内部的多进程架构实际开销会更高。假设开发机有 8 GB 内存系统和其他程序占掉一半留给爬虫的空间大概 4 GB那么max_workers设置在 4~6 比较稳。但这不绝对。要结合你的页面复杂度和目标网站的反爬策略。如果目标页面很轻渲染很快8 个 worker 完全没问题如果页面里全是高清图片、复杂 JS一个页面加载就要 10 秒那 4 个 worker 就足以打满网卡了。我常用的估算公式很简单worker 数 min(CPU 核心数 × 2, 内存可用量 / 单个浏览器内存预估)如果目标网站响应很慢比如每次请求都要 5 秒以上那瓶颈在对方服务器而不是你的机器可以适当调大 worker 数去压等待时间。如果响应很快比如 1 秒内返回那 worker 数反而要收敛否则容易触发对方限流策略。另外worker 数量和浏览器实例数量不是一回事。线程池里的任务执行体需要自己持有 driver所以每个线程在首次执行任务时创建一个 driver线程结束前不销毁。这就有个设计选择是每个线程自己创建 driver还是提前准备好 driver 池让线程从池子里取。我建议用后者因为线程创建 driver 的时机不可控容易出现并发创建浏览器的高峰内存瞬间飙升。提前创建好固定数量的 driver并发行为更平稳。3. 环境准备与完整代码骨架3.1 环境搭建这套方案的依赖很简单Python 3.8 以上安装 Selenium 库再有一个能匹配你本机浏览器的驱动。我本机用 Chrome就下载对应的 ChromeDriver放到系统 PATH 里或者直接在代码里指定路径。pip install selenium新手常犯的错是驱动版本和浏览器版本对不上。Chrome 升级之后旧的 ChromeDriver 就失灵了报错往往很含糊。我的建议是下载驱动时先看一眼浏览器的实际版本号在浏览器地址栏输入chrome://version就能看到然后去 ChromeDriver 的官方下载页找完全一致的版本号。这一步比什么都重要。Selenium 4 之后官方还提供了Selenium Manager如果你不指定驱动路径它会尝试自动下载匹配的驱动。但这依赖网络环境实测中偶尔会失败所以我还是习惯手动管理驱动版本。3.2 代码结构设计整个并发爬虫的核心模块可以拆成四个部分浏览器驱动池负责创建和管理 N 个webdriver实例提供获取和归还的方法程序退出时统一回收。页面解析函数输入一个 URL用 driver 加载页面用解析库提取目标字段返回一条结构化记录。CSV 写入器负责把记录写入文件加锁保护保证多线程下每次写入不会串行、丢失。主调度器创建线程池批量提交 URL收集结果处理异常。这里有个容易踩的设计坑CSV 的写入不能放在 worker 里独立进行。多个线程同时 open 同一个文件会出现数据交错和句柄冲突。更稳妥的做法是只让 worker 返回解析结果统一由主线程或者一个专门的写入线程落盘。3.3 完整代码实现下面给出一个可直接运行的简化版本。场景是采集一批公开的图书列表页面每页返回书名、作者、价格、评分、评论数最后写入 CSV。import csv import queue import random import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed from bs4 import BeautifulSoup from selenium import webdriver from selenium.webdriver.chrome.options import Options # ---------- 1. driver 池 ---------- class DriverPool: def __init__(self, size): self._pool queue.Queue(maxsizesize) for _ in range(size): self._pool.put(self._create_driver()) def _create_driver(self): options Options() options.add_argument(--headlessnew) options.add_argument(--no-sandbox) options.add_argument(--disable-gpu) options.add_argument(--window-size1920,1080) # 这个路径改成你自己本机的驱动路径 return webdriver.Chrome(optionsoptions) def acquire(self, timeout10): return self._pool.get(timeouttimeout) def release(self, driver): # 如果 driver 已经失效主动丢弃并重建 try: driver.current_url except Exception: driver.quit() driver self._create_driver() self._pool.put(driver) def shutdown(self): while not self._pool.empty(): driver self._pool.get() driver.quit() # ---------- 2. 页面解析 ---------- FIELDS [title, author, price, rating, comment_count] def fetch_book(driver, url): driver.get(url) # 留一点随机等待模拟真实用户降低被限流概率 time.sleep(random.uniform(0.3, 0.8)) soup BeautifulSoup(driver.page_source, html.parser) # 下面选择器只是示例换成你自己目标页面的实际结构 title soup.select_one(.book-title).text.strip() author soup.select_one(.book-author).text.strip() price soup.select_one(.price).text.strip() rating soup.select_one(.rating).text.strip() comment_count soup.select_one(.comment-count).text.strip() return {title: title, author: author, price: price, rating: rating, comment_count: comment_count} # ---------- 3. CSV 写入器 ---------- csv_lock threading.Lock() def init_csv(path): with open(path, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesFIELDS) writer.writeheader() def save_record(path, record): with csv_lock: with open(path, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesFIELDS) writer.writerow(record) # ---------- 4. 主调度器 ---------- def worker_task(pool, csv_path, url): driver pool.acquire() try: record fetch_book(driver, url) save_record(csv_path, record) return url, True, None except Exception as exc: return url, False, str(exc) finally: pool.release(driver) def run(urls, csv_path, max_workers4): init_csv(csv_path) pool DriverPool(max_workers) # 分批提交避免无界队列塞入过多任务 batch_size max_workers * 2 with ThreadPoolExecutor(max_workersmax_workers) as executor: for i in range(0, len(urls), batch_size): batch urls[i:i batch_size] futures [executor.submit(worker_task, pool, csv_path, u) for u in batch] for future in as_completed(futures): url, ok, err future.result() if not ok: print(f[失败] {url} 原因: {err}) else: print(f[完成] {url}) pool.shutdown() if __name__ __main__: # 示例 URL 列表实际项目里从配置文件或数据库读 urls [fhttps://books.example.com/list?page{i} for i in range(1, 101)] run(urls, books_result.csv, max_workers4)这段代码有几点要说明。DriverPool.acquire用的queue.Queue.get(timeout10)如果 10 秒内拿不到 driver说明线程发起的并发请求数超过了池子容量这时候抛出异常而不是无限等待能帮助排查“线程数远大于 driver 数”导致的死锁。fetch_book里我故意加了一个随机 sleep0.3 到 0.8 秒。这个不是为了装模作样而是对目标站点负责。多个线程同时狂点一个站即使没有触发封锁也会对对方服务器造成无意义的压力。随机短等待能让请求分布更自然。CSV 部分有两个细节值得单独讲。第一是encodingutf-8-sig。如果你用普通的utf-8编码用 Excel 打开 CSV 时会看到乱码因为微软系软件默认按本地编码解析文本文件。加-sig之后文件开头会带一个 BOM 标记Excel 能正确识别 UTF-8 编码。第二是写入模式。init_csv用w模式清空并写入表头save_record用a模式追加。这个设计保证了即使程序中途崩溃已经写入的记录不会丢重启后只要不重新执行init_csv就能继续追加。还有一个改进空间失败重试。上面的worker_task遇到异常只是记录一下实际项目里最好把失败的 URL 存到一个failed_urls.txt里跑完第一轮之后统一重试。因为并发场景下的失败很多是超时或临时网络抖动重试一两次成功率会提升很多。4. 踩坑记录与排查技巧4.1 常见问题速查表我实际运行这套方案时踩过的坑比想象中多。整理成一个速查表方便你对照排查。症状根本原因解决方案Excel 打开 CSV 全是乱码编码用了普通 UTF-8改用utf-8-sig编码程序跑一会后 Chrome 进程越来越多worker 内的 driver 没及时quit用 driver 池统一回收异常时也要finally归还as_completed里拿不到结果忽略了 future 的异常被print吞掉在future.result()外加 try/except记录异常堆栈某个线程卡死整体进度停住页面加载永不返回driver.get卡死给浏览器设置page_load_timeout配合超时重试数据重复或字段错位多个 worker 同时写同一个 CSV使用单写者模式所有写入经过锁并发后反被目标站封锁请求频率太高、无固定节奏降低 worker 数增加随机 sleep报错invalid session iddriver 已经被 quit但线程还在用获取 driver 时检查存活状态失效则重建4.2 稳定性设计建议第一给浏览器设置超时。并发场景下一个 worker 卡住不动整个线程池的可用 worker 就会减少最终拖垮全局。Selenium 的默认行为是无限等待页面加载这个在单线程下还能忍并发下就是定时炸弹。driver.set_page_load_timeout(15) driver.set_script_timeout(10)第二日志要分级。并发爬虫的问题排查比单线程困难得多因为多个线程的 print 会交杂在一起。我建议统一走logging模块每个线程用threading.current_thread().name打上线程标记。这样看日志就知道是哪条“流水线”出的问题。import logging logging.basicConfig(levellogging.INFO, format%(asctime)s [%(threadName)s] %(message)s)第三增量断点续采。目标 URL 列表很大时一次跑不完很正常。我会在init_csv时先把已完成的 URL 读进一个set重新启动时跳过这些 URL只采集新增部分。这个逻辑看似简单实际能把采集系统的可用性提升一大截。第四失败隔离。不要让一个 worker 的异常影响其他 worker。线程池本身已经做了隔离但要注意future.result()如果抛出异常必须单独捕获否则整个for循环都会中断。建议把future.result()放进 try/except 里把失败 URL 往后备队列放。4.3 资源回收不能省很多人一开始图省事在run函数末尾直接让进程退出以为操作系统会回收所有资源。在纯请求型爬虫里这也许没问题但 Selenium 场景不同ChromeDriver 和浏览器进程如果没显式quit会变成僵尸进程长期占用端口和内存。我在实际项目里见过最夸张的情况脚本反复重跑了一个月系统里残留了上百个 chrome 进程内存直接爆掉。所以 driver 池的shutdown一定要在程序结束时执行而且最好封装在try/finally里保证异常退出也能回收。pool DriverPool(max_workers) try: # 执行采集逻辑 pass finally: pool.shutdown()5. 实测效果与后续扩展5.1 实测数据我用这套方案采集过一批公开的图书列表页总共 500 个 URL页面都是动态渲染每个页面加载 2.5 到 4 秒。单线程跑完大概 26 分钟。换成max_workers5的线程池后总耗时降到 5 分多钟提速接近 5 倍。这个提速比例和 worker 数基本线性相关前提是页面加载速度没有成为瓶颈。当我把 worker 调到 8 时耗时只降到 4 分半再往上加到 10反而有几次超时重试总耗时略回升。原因很简单8 个浏览器实例同时下载页面本机 CPU、内存和网卡都接近饱和页面加载速度开始互相拖累。所以实操上建议不要盲目追求 worker 数量。先用 4 个跑一小批数据观察本机负载和目标站响应速度再决定是否继续增加。5.2 后续扩展思路这套线程池架构已经能解决大多数 Selenium 批量采集问题但它还能继续演进。如果采集目标是纯静态页面不需要等待 JS 渲染可以考虑把解析部分换成requests加BeautifulSoup再配合线程池或者asyncio速度和资源占用都会再上一个台阶。Selenium 的定位是兜底方案只在必须渲染 JS 时才用。如果想彻底绕开线程的 GIL 限制可以把任务分成多个批次用multiprocessing跑多个进程每个进程内部再用线程池。这种“进程池套线程池”的架构适合机器资源非常充裕的情况但调试复杂度会上一个量级不建议新手一上来就尝试。还有一个很实用的扩展是数据管道化。把采集和存储解耦worker 只解析并返回记录解析结果放进一个queue.Queue另外一个消费者线程批量写入 CSV。这样写入操作不阻塞爬取流程吞吐量还能再提一些。我在实际使用中还有一个体会线程池方案真正难的不是写代码而是对目标站点的礼貌程度和资源规划。你完全可以写出 30 行代码就跑起来但稳定跑完几千个页面而不被封锁、不把机器拖垮需要对超时、重试、随机等待、断点续采这些细节做整体设计。如果让我给一个具体的起步建议那就是先用小批量数据把整个链路跑通确认 CSV 格式、字段内容、失败重试都符合预期再放大到全部 URL。一次跑太久、跑挂了还要从头来才是最伤的坑。先小后大稳中求快。