ARTICLE DETAIL

资讯详情

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

Selenium爬虫提速实战:线程池并发让等待时间重叠

Selenium爬虫提速实战:线程池并发让等待时间重叠 前阵子帮朋友处理一个数据采集需求任务量其实不大——三百个链接需要抓页面里的标题和几个关键字段。结果我用 Selenium 单线程一跑整整等了快四十分钟浏览器一个页面一个页面地慢慢转圈。中途还因为超时重试了几次整个人盯屏幕盯到怀疑人生。后来我把线程池接进来同样的任务量耗时直接砍到原来的五分之一还多而且代码结构比之前更清晰。这篇文章就把我从单线程卡顿到并发提速的全过程拆开讲清楚线程池到底怎么解决等待问题、Selenium 在并发场景下怎么管理浏览器实例、采集结果怎么安全落到 CSV 里以及我实际踩过的那些坑。无论你是刚接触爬虫的新手还是被 Selenium 性能折磨过的老手这篇都值得一看。1. 单线程爬虫慢的真相等待而不是下载1.1 网络速度并非唯一瓶颈很多人一开始会下意识觉得爬虫慢是因为网速不行或者目标网站服务器响应慢。放在普通 HTTP 请求的场景下这个判断基本成立。但一旦换成 Selenium问题就完全不是一回事了。Selenium 并不是简单发一个 HTTP 请求拿 HTML而是实实在在启动一个完整的浏览器进程。浏览器要做的事情远比 requests 库多得多先加载主文档再解析其中的 JS、CSS、图片和各类子资源然后执行 JavaScript 脚本触发异步请求最后等待页面事件才把控制权交回给你的代码。这里面的每一个环节都会产生等待时间。我用 Chrome DevTools 的 Network 面板统计过一个典型的动态渲染页面整个加载过程的时间分配大概是这样的加载阶段耗时占比说明浏览器冷启动10%创建浏览器进程、初始化渲染引擎主文档与子资源加载55%网络往返、TCP 连接、资源下载JavaScript 执行15%框架初始化、动态渲染、数据绑定页面事件与稳定15%onload、DOMContentLoaded、React/Vue 挂载其他开销5%代理、插件、缓存策略等你看真正花在下载上的时间其实只占一半多一点。剩下的一半全耗在浏览器内部处理和渲染等待上。换句话说就算你把带宽拉到千兆光纤单线程 Selenium 爬虫也快不到哪去因为大部分时间是在等浏览器干活而不是等网络传输。1.2 用时间账本算清代价我习惯把单线程爬虫的耗时拆成一笔时间账来算。假设你要采集 300 个页面每个页面从启动浏览器到拿到数据平均要 8 秒。单线程跑下来300 × 8秒 2400秒 ≈ 40分钟如果里面还有一部分页面因为加载超时或者验证码导致失败需要重试两三次那么总时间轻松跑到 50 分钟甚至一个小时。这对任何实际业务来说都是不可接受的。更气人的是这 40 分钟里CPU 占用率可能一直趴在 10% 以下。也就是说你的机器大部分算力都在空转真正被闲置的不是网络资源而是你本机的处理能力。单线程模型相当于只有一个工人在干活哪怕前面有三百个任务排队他也只能一个个来。而这个工人大部分时间还在等页面加载真正动手取数据的时间少得可怜。想明白这个逻辑之后我的第一反应不是优化单线程里的等待时间而是换一个思路让多个工人同时干这件事。这就引出了线程池。2. 线程池提速的核心逻辑让浏览器真正并行干活2.1 为什么选线程池而不是手动多线程或直接上多进程谈到并发Python 里可以选的办法其实不少。最原始的是自己写Thread手动创建线程也可以用concurrent.futures的ThreadPoolExecutor或者用multiprocessing直接开多进程。我为什么最终选了线程池先说手动创建线程。写起来代码很啰嗦你要自己维护线程列表、手动join()、处理线程异常任务一多就很容易乱。而且线程池能帮你解决的任务排队和并发上限控制手动写法全都要你自己实现。说白了线程池是一个已经封装好的工人调度中心你只管提交任务它负责分配线程、管理队列、回收资源。再说多进程。multiprocessing能绕开 GIL但它有一个致命问题每个进程都要启动独立的 Python 解释器内存开销比线程大得多。配合 Selenium 使用时每个线程/进程都要启动一个浏览器实例Chrome 单进程的内存占用量通常在 200MB 以上。开 4 个线程就是 4 个浏览器开 4 个进程也是 4 个浏览器但后者还要额外叠上 Python 解释器的内存。算下来线程池方案的内存占用明显更低。顺便澄清一个常见的误区很多人担心 GIL 会影响 Selenium 并发效率。但实际上Selenium 的大部分时间都是在等待浏览器内核完成加载这个等待发生在 C 扩展层面不占用 Python GIL。也就是说在线程等待的间隙GIL 会自动释放其他线程照样能执行自己的逻辑。所以线程池和 Selenium 搭配GIL 造成的性能损耗几乎可以忽略不计。2.2 并发模型选择一个线程持有一个浏览器实例确定了用线程池紧接着要解决一个更关键的问题到底让多个线程共享同一个 WebDriver还是每个线程单独启动一个浏览器实例这里有个知识点需要明确Selenium 的 WebDriver 对象不是线程安全的。多个线程共用一个 driver同时执行get()跳转经常会出现会话混乱、元素找不到、超时莫名其妙的报错。因为浏览器内部只有一个标签页同时接收多个导航指令状态根本无法保持一致。所以我的做法是每个线程独立创建一个 WebDriver 实例。一个线程手里的浏览器从头到尾只服务这个线程的任务。虽然内存开销大了点但换来的是绝对的稳定性和可预测性。这个过程可以类比成在食堂开多个打饭窗口——每个窗口一个师傅互不干扰整体效率远超一个师傅同时接待几十个人。当然你也可以采用浏览器复用 标签页切换的方式让多个线程共享一个浏览器进程、动态创建新标签页。我在早期试过这个方案省了内存但引入的问题非常烦人线程切换标签页时很难避免竞态有时还会触发浏览器的渲染进程崩溃。如果你不是在极简环境里采集不建议碰这条路。老老实实一个线程一个浏览器实例这是并发爬虫里性价比最高的选择。3. Selenium ThreadPoolExecutor 实战落码3.1 环境准备依赖安装与驱动管理进入正文之前先把眼前的环境问题解决掉。我的开发环境是 Windows 11 Python 3.10。需要安装的东西有两个一个是 Selenium 库本身另一个是浏览器的 WebDriver。早期的做法是去 Chrome 官网手动下载对应版本的 chromedriver再解压放到 PATH 路径下。这个办法最大的痛点在于 Chrome 浏览器一旦自动升级版本号变了原来的 chromedriver 就失效爬虫立刻罢工。所以我强烈建议安装webdriver-manager这个工具它可以自动检测本机 Chrome 版本、自动下载匹配的驱动并缓存起来省去所有版本同步的麻烦。pip install selenium webdriver-manager装完之后写一个最简单的浏览器初始化函数from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager def create_driver(): options Options() options.add_argument(--headless) options.add_argument(--disable-gpu) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) options.add_argument(--window-size1920,1080) service Service(ChromeDriverManager().install()) return webdriver.Chrome(serviceservice, optionsoptions)这里的几个参数值得展开说一下。--headless是无头模式不弹出浏览器窗口在服务器和后台采集场景下几乎是标配。--disable-gpu是关闭显卡硬件加速在无头模式下 GPU 渲染没有实际意义反而容易引发奇怪的报错。--no-sandbox和--disable-dev-shm-usage通常是为了解决 Linux 容器环境下权限和共享内存不足的问题如果在 Windows 本机上用可以不加但加上也不会有副作用。窗口大小要设置为 1920x1080 这类合适的尺寸否则部分前端页面在窄窗口下会触发移动端适配导致元素定位发生变化。3.2 核心代码任务函数如何拆分写并发代码最忌讳的是把一堆逻辑全塞进一个巨大的for循环里。线程池的优雅之处在于它要求你把每个链接要做的事情抽象成独立的函数。这个函数只负责单次任务不关心是谁调用它、有多少个任务排队。下面是我用线程池实现采集的完整骨架流程。先定义单页采集函数import csv import time from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_single_page(url): driver create_driver() try: driver.get(url) time.sleep(2) # 等待页面关键元素渲染完毕 title driver.title # 这里可以再加具体的元素定位和数据提取逻辑 return {url: url, title: title} except Exception as e: return {url: url, title: f抓取失败: {str(e)}} finally: driver.quit()注意finally块里的driver.quit()这一点极其重要。如果漏掉了它每个任务执行完浏览器进程都不会退出内存会在几十个任务之后彻底耗尽程序直接崩溃。很多线上事故都是从这里来的。主流程里的线程池调度代码如下urls [...] # 你的链接列表 results [] with ThreadPoolExecutor(max_workers4) as executor: future_map {executor.submit(fetch_single_page, url): url for url in urls} for future in as_completed(future_map): result future.result() results.append(result)这里有两个细节值得琢磨。第一executor.submit会立刻返回一个Future对象它代表这个任务将来的结果。用字典future_map把 Future 和原始 URL 关联起来方便后期定位哪个任务失败了。第二as_completed并不是按提交顺序返回结果而是谁先完成就先处理谁。这个特性非常适合并发场景——任务 A 加载了 10 秒任务 B 只用了 3 秒B 的结果会先被处理不会因为 A 慢而阻塞整个队列。3.3 并发参数调优线程数、超时与等待策略聊到线程池避不开的一个问题是max_workers到底设成几。很多人以为越大越好实际上盲目开三五十个线程结果就是内存瞬间爆掉或者被目标网站直接封了 IP。我自己的经验是线程数要综合看机器内存和目标网站的抗压能力而不是拍脑袋决定。如果你在 8GB 内存的机器上跑一个 Chrome 无头实例大概占 200~300MB那么 4 个线程对应 4 个 Chrome 实例内存余量就已经不大了。再加上系统自身开销建议谨慎一点设成 4 到 6 个线程。如果你的机器是 16GB 内存可以放宽到 8 个线程。目标网站如果响应慢、经常超时还需要适当降低线程数避免大量请求同时积压触发的雪崩。另外等待策略尽量别用time.sleep()写死时长它太脆弱了网络快的时候纯浪费网络慢的时候又不够用。更稳妥的做法是用 Selenium 的显式等待from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By wait WebDriverWait(driver, 10) element wait.until(EC.presence_of_element_located((By.CLASS_NAME, item-title)))显式等待的本质是轮询等待某个条件成立条件满足立刻返回不再浪费多余的秒数。这里要注意WebDriverWait 的轮询间隔默认是 0.5 秒。也就是说最多会有 0.5 秒的判断延迟。这个开销完全可接受。我一般会在任务函数里搭配使用显式等待和连接超时控制尽可能把等固定秒数的坏习惯改掉。还有一点容易被忽视线程池任务的异常处理。如果fetch_single_page内部没有捕获异常future.result()会抛出异常导致整个主循环中断。所以在上面的代码里我对单页函数做了全局except把错误信息写进结果里而不是让异常直接炸掉线程池。采集任务本身就充满了不确定性页面的结构变一下、网络波动一下、验证码弹出来都是家常便饭。宁可这一单记录失败也不能让整个任务流崩溃。4. 数据落盘 CSV并发结果不丢不乱的秘诀4.1 线程里直接写文件的翻车现场把采集结果保存成 CSV 看起来是最简单的一步但这里藏着一个特别经典的并发陷阱如果你让每个线程在抓取完数据后直接打开同一个 CSV 文件写入那么大概率会出现 CSV 行内容交错、数据丢失甚至文件损坏的问题。原因很简单Python 的文件写入不是原子操作多线程同时向同一个文件句柄写内容底层会因为文件指针互相抢占而出现交错写入。你自己单独写一个小测试可能复现不出来因为写入的内容太短、概率不够。一旦数据量上来每条记录几十上百个字节多个线程同时写很快就出问题。我一开始偷懒没注意直接在任务函数里写了with open(output.csv, a, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([url, title])结果跑完一看CSV 里有些行的字段错位了原本该在第 3 列的内容跑到了第 5 列还有几条数据直接被后来的写入覆盖。当时排查了半天最后才意识到就是并发写入竞态导致的。4.2 主线程统一收集再写盘的方案前面代码里其实已经透露了我的处理思路每个线程只负责把结果作为返回值传回来所有的数据收集交给主线程统一完成采集全部结束后再一次性写入 CSV。with open(output.csv, w, newline, encodingutf-8-sig) as f: fieldnames [url, title] writer csv.DictWriter(f, fieldnamesfieldnames) writer.writeheader() writer.writerows(results)这里有两个细节很关键。一是encoding参数我用了utf-8-sig而不是utf-8。区别在于utf-8-sig会在文件开头写入带 BOM 标记的内容Excel 打开 CSV 时才能正确识别中文字符。如果你用纯utf-8编码Excel 打开后中文经常变成乱码必须手动在 Excel 里选编码才能救回来。这是和 pandas 打交道的人都知道的细节很多新手在这里栽过跟头。二是newline必须带上。Python 的 CSV 模块在 Windows 平台上如果不指定newline写入时会出现多余的空行。原因是csv.writer内部处理换行符的方式和 Windows 自身的\r\n机制撞了车。加上newline之后换行符由 CSV 模块统一管理问题自然消失。这种结果汇总后统一落盘的模式本质上就是把并发问题从写盘环节里彻底剥离出来。主线程的写入根本不存在并发冲突数据的一致性和完整性有了保障。而且这种做法还给你留了一个好处主线程可以在写盘前对数据做二次过滤、排序、去重而不需要动子线程的任何逻辑。4.3 断点续跑与增量数据保护如果你的任务不是一次性结束而是可能中途崩溃、之后还要继续采集那一次性统一写盘就有风险了。我通常在项目的生产版本里会加一个已完成记录的追踪集合把已经成功抓取的 URL 先记录到一个 txt 文件里。每次任务开始前读入这个集合如果 URL 已经在里面就直接跳过不重新发起请求。实际上我用的是更轻量的方案在任务函数 return 之前把 URL 追加写入一个单独的done.txt。因为每次只追加一行单行写入的原子性足够可靠多个线程看似并发追写实际每个写入都是独立的小块操作不太容易互相交错。主程序跑完后再统一把结果写进 CSV两套机制各管各的事。5. 实测提速对比与踩坑记录5.1 一份真实的耗时对比数据理论讲了这么多没有实测数据都是空谈。我拿一台 16GB 内存的笔记本对一个测试站点的 200 个页面做了完整的采集测试。采集内容包括页面标题、发布时间和正文前 100 字每个页面有动态渲染需要用 Selenium 等待约 2 秒。数据如下线程数总耗时相对单线程提速1 线程8分32秒基准2 线程4分41秒约 1.8 倍4 线程2分05秒约 4.1 倍8 线程1分27秒约 5.9 倍注意看从 1 线程到 4 线程加速比接近线性非常舒服。但 8 线程并没有达到 8 倍只有 5.9 倍。原因也不复杂线程数增多之后本机 CPU 和内存开始成为瓶颈再加上 Chrome 每个实例都要独立加载页面资源磁盘缓存 IO 也成为争夺点。结论是在这台设备上4 到 6 个线程是性能和稳定性的甜点区。这些数据是在无头模式下测的。如果不开无头模式每个浏览器都有真实窗口渲染内存占用会更高4 线程以上的加速收益会更不明显。所以凡是能跑无头的场景尽量用无头模式。5.2 踩过的大坑登录态失效、内存暴涨、隐式等待失效先说登录态失效的问题。我的采集任务需要登录后才能看到数据于是用浏览器先手动登录一次然后把 cookie 信息取出来在每个线程创建浏览器后注入进去。一开始没做这步直接开线程池跑结果 4 个线程里面 3 个返回都是未登录跳转到登录页。原因很简单Selenium 默认每次创建的都是全新临时浏览器环境没有 cookie也没有任何登录记忆。解决办法是在create_driver()里通过add_cookie批量注入。注意add_cookie必须要在driver.get()访问一次域名之后才能调用否则浏览器不知道当前访问的是哪个站点拒绝写入。所以我通常会先driver.get(https://目标站点首页)再用一个循环把 cookie 字典写进去然后重新driver.get()真正要抓的页面。再说内存暴涨。这个问题我在前面已经反复提示过driver.quit()一定要放在finally里。但还有一个更隐蔽的问题即使每个线程都正确调用了quit()如果你的代码在等待WebDriverWait时抛了异常且异常被全局except捕获后直接返回某些 Chrome 子进程仍然有概率残留。尤其是在 Windows 上Chrome 的特殊多进程架构会让主进程挂掉之后一些 renderer 和 GPU 进程还赖在内存里不退出。后来我在正常quit()之外还定期加了一个兜底每次跑完一批任务用subprocess调用系统命令清理残留的 Chrome 进程。这个做法有点粗暴但确实有效。在 Linux 服务器上对应的是pkill -f chrome命令Windows 上则等价于taskkill /F /IM chrome.exe /T。注意执行这个命令的进程必须和爬虫主程序是分开的不能把正在正常运行的浏览器实例也给误杀了。最后一个是隐式等待失效的问题。Selenium 文档里明确说过隐式等待driver.implicitly_wait()和显式等待不要混用否则等待时间会叠加到不可控的地步。我在一个并发场景里同时设置了隐式等待 10 秒和显式等待 5 秒结果页面元素明明在 2 秒时就出现了硬是卡了 7 到 8 秒才继续。排查下来就是隐式等待轮询机制和显式等待wait.until()在底层发生了排斥。解决办法很简单统一用显式等待彻底删掉隐式等待设置。这个改动让我整体的采集耗时又下降了一截。5.3 线程数的经验参数表最后送上一张我自己整理的线程数配置参考表适合大多数常规 Selenium 动态爬虫任务。当然具体数值一定要结合你自己的机器配置和网络环境做微调。机器内存推荐线程数适用场景4GB2简单页面、任务量小8GB4常规动态页面采集16GB6~8数据量较大、页面较重32GB 及以上8~12大规模批量采集需严守目标站点并发限制判断线程数合不合适最简单的方法是开着资源监视器看内存占用一旦接近 80%立刻把线程数降下来。不要等到 Chrome 直接崩溃报页面引起进程崩溃才去调整那样所有线程都会同时失败之前抓的数据全白费了。还有一个小技巧给每个线程设一个独立的任务队列而不是把所有 URL 一股脑塞进去。实际上ThreadPoolExecutor.submit本身就是把任务放到一个共享队列里由空闲线程去取。这个设计已经足够合理你不需要自己额外做任务分片。但要注意的是如果某个 URL 反复失败、总是占用线程很长时间它会影响队列整体的推进速度。我一般会在任务函数里设置单页最长执行时间超过 20 秒没抓完就直接把这条任务标记为超时并返回避免一颗老鼠屎坏了一锅汤。写到这里回到开头的问题。Selenium 本身并不慢真正慢的是你用它的姿势。单线程串行等待是所有性能问题的根源而线程池通过一个简单的ThreadPoolExecutor就能把等待的时间重叠起来。配合独立的浏览器实例、显式等待、结果统一落盘这几个关键设计爬虫从龟速爬行变成批量流水线其实并不复杂。我还是建议手上在写爬虫的朋友花半天时间把手头的单线程逻辑改成线程池版本一旦跑起来会发现原来等待真的可以靠并发来解决。
返回列表