Python线程池并发爬虫:Selenium动态页面批量采集提速方案
发布时间:2026/10/8 3:56:43 作者:尧图编辑部 阅读量:1,286

爬虫变成性能瓶颈这种事做过批量采集的人应该都有体会。一开始写采集脚本无非是 requests 一把梭碰到页面是 JavaScript 动态渲染的就换成 Selenium 模拟浏览器慢是慢了点好歹能用。可一旦 URL 列表从几十条涨到几百条、上千条问题马上暴露——浏览器一个个启动、一次次等待页面加载跑完一轮往往要半个多小时中间哪怕有一条卡住后面全排队等着。我这次做的这个 Python 线程池并发爬虫项目就是把采集速度提上来的一次实战以 Selenium 处理动态页面用线程池把多个浏览器实例并行跑起来最终数据统一写进 CSV 文件。整套代码跑通之后同样一批 URL耗时从半小时压到了五六分钟提升非常直观。整体方案适合刚接触并发编写的爬虫学习者也适合手里有一堆动态页面链接、想快速出数据的从业者参考。1. 项目概述一个慢字引发的改造1.1 痛点到底在哪先说场景。我当时要采集的是一批商品列表页页面内容比较新很多关键字段是前端异步渲染出来的。直接用 requests 拿 HTML返回的只有空壳根本解析不到数据所以才上了 Selenium。Selenium 本身是个好东西但它有个绕不开的缺点慢。一个浏览器实例启动冷启动就要两三秒再加上页面加载、等待资源请求、定位元素正常访问一个页面平均要 5 到 8 秒。串行跑还好几十条 URL 撑得住。但数据量一上来比如换个品类链接数量直接到 800 条串行就非常坐牢了——800 乘以每页 6 秒自己算一下整整 80 分钟。更难受的是串行模式下没有容错机制。其中一条 URL 因为网络抖动页面加载失败整个循环就停在那里等超时。超时时间设得太短页面偶尔慢一点就误杀设得太长失败的代价被无限放大。我踩过一次坑某个耗时任务页把默认超时拖到 30 秒循环里有一条坏链接后面几百条全部排 30 秒的队白等半天。所以这个项目真正要解决的问题不是怎么写爬虫而是怎么写不慢、不卡、能容错的爬虫。线程池的引入就是回到并发模型上去解决 IO 密集型的等待问题。1.2 为什么选线程池 Selenium CSV这套组合先说线程池。Python 里有 asyncio、多进程、多线程三种主流并发方案。asyncio 对 Selenium 这种阻塞式的接口不太友好——Selenium 的每个调用都是同步阻塞的强行在协程里跑很容易把事件循环卡死。多进程太重每个进程都要重新启动浏览器环境进程间通信的序列化开销也很大。多线程配合 ThreadPoolExecutor 反而是最贴合的场景Selenium 调用天然是阻塞等待线程池里的线程可以在等待页面加载的时候让出 CPU让其它线程继续跑自己的浏览器实例。不需要 GIL 层面的算术密集运算纯粹是 IO 等待GIL 几乎不影响效率。再说 Selenium。它的价值在于完整模拟真实浏览器环境JS 执行、Cookie、渲染都能真实还原。配合无头模式headless就是看不见界面但正在干活的浏览器特别适合服务器端的批量抓取。缺点前面说了慢。但线程池恰好可以从调度层面把多个浏览器并行起来把等待时间摊薄两者是非常合拍的一对。CSV 的选择就更务实了。JSON 文件不适合增量追加SQLite 需要额外引入数据库驱动Redis 又要起独立服务。CSV 只要一个标准库 csv 就能读写Excel、WPS、Pandas 都能直接打开后续做数据清洗、做分析一个 read_csv 就能把数据灌进去。轻量场景完全够用。这个项目选型的关键点在于每一环都要能支撑线程池并发不然瓶颈会从网络转移到更下游。2. 线程池的核心设计并发不是开了线程就完事2.1 为什么用 ThreadPoolExecutor 而不是手写 thread我刚学并发的时候也喜欢手工 Thread Queue感觉自己掌握一切。但写多了就发现手写线程要管理的细节太多了线程的启动时机、队列的消费循环、异常如何从子线程传回主线程、结束后怎么回收线程。一个不小心就是线程泄漏或者内存里堆着一堆僵尸线程。concurrent.futures.ThreadPoolExecutor 把这层复杂度藏了起来。它内部维护了一个线程队列任务通过 submit() 提交进去线程池会根据当前的并发配置去分配线程执行任务完成后可以拿 Future 对象取回结果。它天然支持上下文管理器with 语法退出的时候自动等待所有任务完成并清理线程省心太多。我实际项目里用的是 submit() as_completed() 的组合。为什么不直接用 map()map 的好处是结果顺序与输入顺序一致但它必须等第一个任务返回才能开始产出结果如果第一个任务是最慢的后面的快任务结果会堵在队列里取不出来。as_completed 恰恰相反哪个任务先完成就先返回哪个配合完成一个写一个的落盘逻辑整个流程就不存在等待队头的问题。2.2 max_workers 到底设多大先算资源账这是老生常谈但必须讲的问题线程数不是越多越好尤其在 Selenium 场景里。每个浏览器实例要占多少资源我用 Chrome 实测一个普通的 headless 标签页打开一个中大型页面后内存占用轻轻松松到 200~300MB。如果不控制并发数8 核 16G 的服务器上你开 20 个浏览器实例内存直接爆掉系统开始疯狂 swap速度反而比串行还慢。我的经验公式是这样的先看内存假设单实例峰值占用 300MB留出 20% 系统余量然后用可用内存 × 0.8 ÷ 0.3得出理论上限。再结合 CPU 核心数单实例的 CPU 占用在页面解析阶段不低一般取 min(理论上限, 核心数 × 2) 作为初始值随后根据监控做微调。举例一台 4 核 8G 的机器可用内存按 7G 算7 × 0.8 ÷ 0.3 ≈ 18理论上限 18核心数 × 2 8取小值是 8。但我实际用下来 4 核机跑 8 个 Chrome 实例还是比较吃力页面复杂时 CPU 会飙到接近 100%所以我通常会在 6 左右做压测观察 CPU 和内存的稳定水位再定。总的原则是从低往高加而不是一上来就拉满。2.3 任务拆分与提交的取舍任务拆分的粒度也是个容易忽略的点。最粗的做法是整个 URL 列表平分给 N 个线程每个线程循环处理自己那份。缺点是任务分布天然不均衡有的线程分到的页面全是慢页面干到傍晚也干不完其他线程已经空闲很久了。更好用的方式是任务池模型不管列表多长统一由 executor 从任务池里取哪个线程空闲了就把下一个 URL 分配给它。ThreadPoolExecutor 内部就是这么调度的你只需要把所有 URL 一次性 submit 进去或者用分批 submit的方式控制流量。我推荐在 URL 总数比较小几千以内时直接一次性 submitas_completed 里逐条拿结果如果 URL 数量到了几万条一次性提交会把 Future 对象全挤在内存里这时候可以写一个生产函数用每批 100 个的方式循环喂给线程池既保证并发又不至于撑爆内存。另外有一个细节任务函数里尽量不要依赖共享的全局变量来改数据。如果多个线程都要往同一个 list 里 append 结果请务必给那个 list 加锁或者干脆让每个任务函数返回独立的结果由主线程统一收集。我后面讲的 CSV 写入方案就是走主线程统一写的路子避免子线程各自抢文件句柄。3. Selenium 并发实战一个线程一个浏览器3.1 WebDriver 实例千万不能共享这是并发 Selenium 最容易踩的坑。很多新手写多线程爬虫图省事在全局创建一个 driver然后丢给 8 个线程共用。结果就是各种 session 串号、元素找不到、窗口错乱甚至 driver 直接崩溃报 WebDriverException。原因很简单WebDriver 并不是线程安全的多个线程同时对一个 driver 实例发命令命令会在同一个 session 里交错执行页面上下文早就乱了。正确做法是一个线程 一个 driver。我把 driver 的创建放进任务函数里每个 URL 对应一个独立浏览器实例用完立刻 quit() 销毁。有人担心频繁创建销毁浏览器是不是更慢其实在并发的场景下创建浏览器的时间和页面加载时间并行走线程 A 在等页面加载时线程 B 的浏览器已经在启动了整体吞吐量反而高。实测下来 500 条数据每个任务都新建 driver 和复用 driver 的耗时差距不到 5%但稳定性高了一个量级。不过需要提醒的是浏览器实例的创建本身是线程安全的吗同一时刻多个线程各自执行 webdriver.Chrome() 没问题但如果用的浏览器驱动版本不同或者驱动器端口冲突可能出现 SessionNotCreatedException。遇到这个问题先检查 chromedriver 与 Chrome 版本是否匹配再看是不是有残留的 chrome 进程占着端口。3.2 等待策略显式等待是唯一可靠的选择Selenium 慢的很大一部分原因是开发者把硬等待写进了代码里。time.sleep(5) 这种写法我早期也常用但它有两个毛病页面 1 秒就加载完了你要白白等 4 秒页面遇到网络波动要 8 秒你又只等了 5 秒照样取不到元素。正确姿势是显式等待WebDriverWait它的核心逻辑是等到某个条件满足为止最多等待你设定的超时时间条件满足就立刻继续效率高得多。我的标准写法是这样的wait WebDriverWait(driver, 10) element wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, .product-title)) )超时时间 10 秒是个比较均衡的值。如果页面经常超过 10 秒才加载完那先把网络问题解决而不是无限拉长等待时间。另外要区分 presence_of_element_located元素出现在 DOM 中和 visibility_of_element_located元素不仅存在而且可见。某些懒加载页面上元素在 DOM 里存在但还没有渲染到可视区域这时候用 presence 会拿到一个还没数据的空元素取文本取出来全是空字符串。我一般会组合用先等 presence 确认结构出现再等 visibility 确认内容可读。3.3 无头模式与浏览器选项调优无头模式是必选项。headless 模式下 Chrome 不渲染界面CPU 和内存消耗都会降低而且不会被服务器上的锁屏或远程桌面干扰。加上以下几个参数通常能显著减少踩坑options.add_argument(--headlessnew) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) options.add_argument(--disable-blink-featuresAutomationControlled)--no-sandbox 是因为很多 Linux 服务器上 Chrome 默认沙箱需要特权不加这个会直接报错。--disable-dev-shm-usage 是防止容器环境下 /dev/shm 太小导致页面崩溃。最后一个参数的作用是隐藏 ChromeDriver 的一些自动化特征部分反爬站点会检测 window.navigator.webdriver 这个属性加上这个参数可以降低被识别成爬虫的概率——但它不是万能的反爬更强的站点还需要配合隐藏 UA、无头指纹伪装等操作。有一点要特别提醒headlessnew 是 Chrome 109 之后的推荐写法旧版本用 --headless 可能会有部分功能不支持。如果程序里发现有些页面在无头模式下加载不完整可以先切到有头模式排查确认问题在数据和渲染层面再回到无头模式做性能优化。4. CSV 存储细节并发写文件不是打开就写4.1 并发写入的线程安全问题数据抓回来总要落地CSV 看起来是最简单的环节但并发场景下它也会埋雷。多个线程同时以追加模式打开同一个 CSV 文件写入文件句柄之间的写入顺序是不确定的极端情况下会出现写坏的行——一行数据被拆成两半或者两个线程的内容互相穿插CSV 文件在 Excel 里打开就成了乱行。我的方案是任务函数里只负责抓数据、返回结果不碰文件CSV 写入统一放到主线程的 as_completed 循环里做。这样文件句柄永远只有一个写入者从根上规避了并发写文件问题。如果任务量特别大希望一边抓一边落盘而不是等全部跑完也可以用一个带锁的写入函数来包一层我项目里也写了这个版本write_lock threading.Lock() def save_row(row): with write_lock: with open(data.csv, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesFIELDS) writer.writerow(row)加锁以后同一时刻只有一个线程在写文件冲突问题就消失了。有人觉得加锁会降低效率但在 CSV 这种小文件的追加写入场景锁的粒度非常小开销可以忽略不计。4.2 文件打开模式与编码为什么是 utf-8-sigCSV 打开模式的坑踩过一次就记住了。用 open(data.csv, w)每次跑脚本都会把之前的文件清空重写相当于数据全部重来用 open(data.csv, a) 是追加模式不清空适合断点续跑。我通常按需求切换第一天全量采集用 w后续增量采集用 a。编码这里也有学问。直接用 encodingutf-8 写出来的 CSV用记事本打开没问题但用 Excel 双击打开中文全变乱码。原因是 Excel 默认按 GBK 或系统本地编码去解析 CSV不认 UTF-8。解决方法是写入时用 utf-8-sig 编码它会在文件开头写入 BOM 标记Excel 看到 BOM 就知道是 UTF-8 了。数据稍微量大一点之后你还会发现另一个问题一条数据里如果本身包含了英文逗号或换行符不处理的话 CSV 的列结构就被破坏了。csv 模块的 writer 会自动处理字段内部的逗号和引号转义但前提是你让 csv.DictWriter 去写而不是用 f.write 手工拼逗号串。4.3 字段设计与去重策略CSV 的字段设计也不建议拍脑袋。字段名一旦确定中途再改就很麻烦改列名意味着历史数据全部对不上。我一般会在项目启动前花几分钟把抓取目标列清楚按标识字段 业务字段 时间戳三类排布。比如采集商品信息标识字段是商品 ID业务字段是标题、价格、库存、销量时间戳是采集时间。这样后续做增量更新直接用商品 ID 做唯一键方便在内存里维护一个已采集 ID 的集合。去重逻辑我放在任务函数返回后、写入 CSV 之前。因为并发场景下同一个 URL 理论上不会被提交两次但如果任务池里有重 URL重复写入就会发生。我在主循环里维护一个 seen_ids 集合每拿到一个 row 先判断 ID 是否已经在集合里在就丢弃不在就写入并加入集合。这个集合只被主线程访问不需要额外加锁非常安全。5. 完整代码实现与跑分对比5.1 核心代码骨架说了这么多还是要看代码。下面是我在这个项目里实际能跑通的精简版本剥掉业务细节留下并发框架和 Selenium 处理的核心逻辑。from concurrent.futures import ThreadPoolExecutor, as_completed from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC import csv import threading import time URLS [ https://example.com/item/1, https://example.com/item/2, # 实际项目里这里可能是几百上千条 ] MAX_WORKERS 6 FIELDS [item_id, title, price, stock, scraped_at] write_lock threading.Lock() seen_ids set() def build_driver(): options Options() options.add_argument(--headlessnew) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) options.add_argument(--disable-blink-featuresAutomationControlled) options.add_argument(--user-agentMozilla/5.0 ...) return webdriver.Chrome(optionsoptions) def fetch_one(url): driver build_driver() try: driver.get(url) wait WebDriverWait(driver, 10) title_el wait.until( EC.presence_of_element_located((By.CSS_SELECTOR, .item-title)) ) title title_el.text.strip() price driver.find_element(By.CSS_SELECTOR, .item-price).text.strip() stock driver.find_element(By.CSS_SELECTOR, .item-stock).text.strip() item_id url.rsplit(/, 1)[-1] return { item_id: item_id, title: title, price: price, stock: stock, scraped_at: time.strftime(%Y-%m-%d %H:%M:%S), } finally: driver.quit() def save_row(row): with write_lock: with open(data.csv, a, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesFIELDS) writer.writerow(row) def main(): with open(data.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnamesFIELDS) writer.writeheader() results [] with ThreadPoolExecutor(max_workersMAX_WORKERS) as executor: futures [executor.submit(fetch_one, url) for url in URLS] for future in as_completed(futures): try: row future.result() except Exception as e: print(f任务失败: {e}) continue if row[item_id] in seen_ids: continue seen_ids.add(row[item_id]) save_row(row) results.append(row) return results if __name__ __main__: start time.time() data main() print(f完成 {len(data)} 条, 耗时 {time.time() - start:.2f} 秒)注意 main() 里面先写表头用的是 w 模式然后 save_row 用 a 模式追加两种模式在同一个运行流程里交替使用逻辑上是没问题的先清空并建立新文件再逐条追加。如果只想做增量采集把开头建表头那段去掉保存时直接 a 就行。5.2 关键点逐行讲解fetch_one 函数是整个并发单元它内部自己创建 driver、自己等待元素、自己 quit。这保证了每个线程的生命周期完全独立。finally 里的 driver.quit() 特别重要如果页面加载失败抛异常finally 也能保证浏览器被关闭不会残留一大堆僵尸 Chrome 进程。跑完一轮任务用 ps 查看进程数应该回到基线水平如果发现 chrome 进程越来越多八成就是某处 driver 没有正确 quit。as_completed 循环里我用 try 包住了 future.result()。这是必要的因为子线程里的异常不会在 submit 时暴露而是被包裹在 Future 对象内部只有调用 result() 时才会重新抛出。如果你不处理整个主循环会被一条坏数据打断后续所有结果都取不出来。我在生产环境里见过太多因为一条坏数据导致整体失败的案例所以在结果收集层做异常兜底是必须的习惯。数据落地的顺序用的是完成即写。这在 CSV 追加模式下没有副作用因为每条记录之间相互独立。既然采集时间戳也写进每条记录里即使最终 CSV 的行顺序和 URL 顺序不一致也不影响后续分析。5.3 串行与并行的实测对比我用一组 100 条 URL 做了对比测试页面都是带 JS 渲染的商品详情页。串行版本就是一个 for 循环顺序跑每个页面平均耗时约 7 秒100 条跑完用了 712 秒将近 12 分钟。换成线程池 4 个 worker耗时下降到 202 秒提速大约 3.5 倍再调到 6 个 worker总耗时稳定在 145 秒左右提速接近 5 倍。运行模式并发数总耗时提速倍数串行 for 循环1712 秒1.0xThreadPoolExecutor4202 秒3.5xThreadPoolExecutor6145 秒4.9xThreadPoolExecutor8160 秒4.5x继续调到 8 个 worker耗时反而略微回升到 160 秒左右原因就是前面说的机器内存和 CPU 被打满浏览器实例之间发生资源竞争页面加载反而变慢了。从这个结果能直观看到不是并发数越大越好存在一个吞吐量拐点。抓取类任务本身就是 IO 密集合理的并发数能显著摊薄等待成本但超过机器承载能力后资源竞争带来的开销会抵消并发收益。所以我建议每个项目都跑一次并发数梯度测试找到自己的拐点而不是照搬网上的8 个线程起步。6. 常见问题与排查技巧实录6.1 经典的异常与解决方案我现在把实际运行中最常碰到的六类问题整理成了一张表每一个都是踩过坑换来的。异常信息常见原因解决办法WebDriverException: unknown errorChrome 与 chromedriver 版本不匹配下载与 Chrome 主版本一致的 chromedriverSessionNotCreatedException浏览器驱动端口冲突或残留进程杀掉残留 chrome 进程检查驱动版本TimeoutException页面加载超时元素没出现先确认是网络问题还是选择器问题再调整显式等待时间NoSuchElementException元素不存在常因等待时间不足改用 presence 或 visibility 显式等待ElementClickInterceptedException元素被遮罩层挡住加滚动或强制点击改用 execute_scriptMemoryError并发浏览器实例过多调低 max_workers增加 --disable-dev-shm-usage这里面的 TimeoutException 最容易让人误判。我一开始以为是等待时间不够把超时从 10 秒改成 20 秒结果发现是某几个页面的商品标题选择器不对有些页面没有库存字段导致等不到元素。正确排查顺序应该是先用单个 URL、单线程跑一遍看是不是稳定复现稳定复现说明是页面结构差异问题而不是并发引入的随机失败。还有一类情况很隐蔽页面在无头模式下加载不完整元素长期不出现这时候切到有头模式调试一次往往就清楚了。6.2 如何定位偶发失败的线程问题线程池并发下最容易出现的现象是大部分任务成功偶尔几个失败。这种偶发失败特别讨厌因为重跑一遍可能又全成功了让人怀疑是不是代码有 bug 但又复现不出来。我的排查方法分三步。第一步把所有失败任务的 URL 打印出来比对失败 URL 的特征——是不是集中在某个域名、某个路径前缀、或者某个页码区间。如果特征明显多半是那一批页面的结构有差异或者是触发了对方的限流。第二步给 chromedriver 加上 log 输出用 service 参数指定日志路径Chrome 会把内部异常写到日志里很多隐蔽问题在那里一眼就能看出来。第三步做一个串行重试的兜底主流程跑完以后收集失败 URL 列表再用单线程重试一轮。串行重试有两大好处一是避开了并发期间的资源竞争二是能给出更稳定的错误上下文。我在代码里是这么接的failed_urls [] with ThreadPoolExecutor(max_workersMAX_WORKERS) as executor: futures {executor.submit(fetch_one, url): url for url in URLS} for future in as_completed(futures): url futures[future] try: row future.result() except Exception as e: print(f任务失败: {url}, 错误: {e}) failed_urls.append(url) continue # 正常处理 row 并写入 CSV # 串行重试失败任务 for url in failed_urls[:]: try: row fetch_one(url) except Exception as e: print(f重试仍失败: {url}, 错误: {e}) continue save_row(row)这个兜底策略不会显著拉长总耗时但能把成功率从 95% 拉到 99% 以上对于最终要入库的数据来说非常值得。6.3 资源监控与线程泄漏排查跑完一批任务之后记得看一下系统的资源状况。我在 Linux 服务器上一般用 top 和 ps 命令快速确认ps -ef | grep chrome | wc -l这样可以统计残留的 chrome 进程数。一轮任务结束后这个数字应该回落到接近 0。如果发现进程数持续累积首先怀疑 driver.quit() 没有执行常见原因是 quit 放在了业务代码的正常分支而异常分支直接 return 了finally 里没有兜底。另一个可能性是 WebDriver 的崩溃导致 quit 不生效这时需要强杀进程或者用 try-finally 包住整个任务函数。内存方面我习惯在跑大任务时用 watch -n 2 free -h 实时观察内存水位。如果内存持续上升而不回落说明可能有浏览器实例没被回收或者任务函数内部有循环引用导致对象无法释放。线程池本身不背这个锅问题几乎都在 Selenium 资源管理上。总之记住一句话并发爬虫的稳定性七成以上取决于你对浏览器实例生命周期的管理而不是并发的代码本身。最后再分享一个小技巧。线程池任务里我给每条数据都加了采集时间戳这个字段平时看着不起眼但后面做增量对比、去重、数据新鲜度分析的时候特别有用。另外CSV 文件如果跑了很多天行数特别多Excel 打开会很卡这时候可以直接用 Python 的 csv 模块写个小脚本做分割或者用现成的 CSV 分割工具按行数拆成多个小文件管理起来舒服得多。这个项目的并发框架完全可以复用到其他采集场景只要能确定数据源是动态页面、需要模拟浏览器把 fetch_one 里底层的页面解析逻辑换掉就能接着用。