
做爬虫的朋友应该都有这种感觉单机 Scrapy 跑得再快量一上来就卡在 CPU、带宽、反爬封 IP 这些事儿上。于是大家很自然地想到加机器但加机器不是简单把爬虫复制一遍关键是怎么让多台机器协同工作。今天这篇就聊聊我用 Scrapy 框架构建分布式爬虫的完整思路和实操从单机瓶颈、Scrapy-Redis 的核心原理到多节点调度和动态 iframe 页面的处理尽量把每一步都说透。这套内容适合谁如果你已经用 Scrapy 写过几个爬虫但面对千万级 URL、多台服务器不知道怎么分配任务或者老是碰到重复抓取、队列混乱的问题那么分布式改造就是你要走的路。我会把架构设计、配置参数、启动方式、常见坑全部摊开讲你照着做就能搭出一个能横向扩展的分布式爬虫系统。1. 项目背景与整体设计思路1.1 单机 Scrapy 的瓶颈在哪先把话说明白Scrapy 本身是一个高效的异步爬虫框架单机跑几千个 URL 完全没压力。但数据量到百万、千万级别以后会遇到几个绕不开的坎。第一个是调度能力有限。Scrapy 的 Scheduler 默认把待抓取 URL 放在内存队列里爬虫进程一重启队列就没了只能重头开始。虽然可以配置 JOBDIR 做磁盘持久化但单机内存和磁盘空间总是有限的队列越长调度效率越低到最后可能光排队就占掉大半资源。第二个是去重指标容易被撑爆。Scrapy 默认用RFPDupeFilter把已请求的 URL 指纹存在内存集合里。百万级别的 URL 指纹大约要占用几百 MB 内存千万级直接几个 GB单机扛不住。而且去重集合是进程内的多开几个爬虫进程各去各的同一个 URL 被每台机器各抓一遍等于白干。第三个是抓取吞吐量受单机资源限制。带宽、CPU、内存、文件描述符都有上限想要翻倍吞吐最省事的方式就是加机器但加机器之后如果没有一套共享调度机制每台机器各跑各的不仅重复抓取严重维护成本也会直线上升。我在早期做电商价格监控时单机跑 50 个并发每天大概能抓 20 万条数据勉强够用。后来站点数量翻了三倍单机硬扛到 120 并发结果经常被反爬策略盯上IP 被临时封禁不说Redis 里的待抓取队列积压越来越严重。那时候我才意识到分布式不是可选项而是数据量上来后的必选项。1.2 分布式爬虫到底在“分布”什么很多人以为分布式就是把同一份爬虫代码复制到多台服务器上各自跑这其实是误区。真正的分布式爬虫核心是让多台机器共享三个东西请求队列所有待抓取的 URL 放进一个中心队列谁空闲谁取。去重集合全局只有一个指纹集合所有节点共享避免重复抓取。调度状态比如爬虫暂停、恢复、统计信息需要集中管理。实现方式上最常见的方案就是Scrapy-Redis。它把 Scrapy 默认的 Scheduler、DupeFilter、Item Pipeline 替换成基于 Redis 的实现。请求指纹、待抓取队列、抓取结果都放在 Redis 里多个 Scrapy 节点连接同一个 Redis 服务自然就构成了分布式系统。除了 Scrapy-Redis也有一些团队直接用消息队列RabbitMQ、Kafka作为 URL 缓存配合自定义 Scheduler 实现。但说实话对绝大多数场景来说Scrapy-Redis 已经够用部署简单、社区资料多、踩坑成本低。只有在需要复杂的任务分发策略、优先级队列、定时调度时才需要考虑更重的消息中间件方案。1.3 架构设计与组件选型我最终采用的架构是多台爬虫节点 Redis 单节点/集群 MySQL/MongoDB 存储。爬虫节点只负责执行抓取和解析Redis 负责请求队列、指纹去重、统计计数存储层根据业务数据形态选择 MySQL 或 MongoDB。为什么选 Redis 而不选别的因为 Scrapy-Redis 原生的数据结构list和set刚好对应请求队列和去重集合。lpush写入待抓取 URLbrpop阻塞式获取任务天然支持多消费者竞争消费。指纹去重用saddsismemberO(1) 时间复杂度性能非常稳定。而且 Redis 可以开 RDB/AOF 持久化即使爬虫节点全部挂掉队列数据也还在重启后能继续消费。存储层我建议单独抽出来不要让爬虫直接写库写到 Redis 里。实际项目中抓取 Item 经过 Redis Pipeline 暂时缓存再由独立的消费进程写入数据库这样即使数据库短暂故障也不会导致爬虫进程阻塞。对于起步阶段也可以直接把 Item 写入 MySQL但要注意数据库连接池配置避免高并发下连接数被打满。2. 核心组件解析Scrapy-Redis 与动态页面处理2.1 Scrapy-Redis 的工作原理要理解 Scrapy-Redis先要知道它替换了 Scrapy 的哪些组件。Scrapy 默认的调度流程是爬虫产生的 Request 交给 SchedulerScheduler 从队列中取出 Request 交给下载器下载完成后还要经过 DupeFilter 检查是否重复。Scrapy-Redis 把这些组件全部改成了 Redis 后端。Scheduler 被替换为RedisScheduler。它不再使用内存队列而是通过 Redis 的 list 结构保存待抓取 Request。每台爬虫节点使用同一个 Redis key比如my_spider:requests通过brpop从右侧取出任务实现多节点竞争消费。DupeFilter 被替换为RFPDupeFilter的 Redis 版本。Request 是否重复的判断不再使用内存 set而是通过 Redis 的 set 结构保存指纹。所有节点共享同一个指纹集合重复请求会被全局拦截。Item Pipeline 中有一个可选组件RedisPipeline把解析出的 Item 先推入 Redis list再由其他程序或爬虫节点自己异步消费避免多节点同时写数据库造成压力。另外Scrapy-Redis 提供了一个RedisSpider基类。继承它之后爬虫可以接收 Redis 中lpush进去的起始 URL而不用在代码里写死start_urls。这样你就可以随时向 Redis 推送新的种子链接爬虫不用重启就能消费到新任务。有一点要注意Scrapy-Redis 的请求序列化不是简单存储 URL 字符串而是把 Request 对象序列化成二进制字节存进 Redis。默认使用 Python 的 pickle 协议所以理论上不同机器上的爬虫代码版本必须保持一致否则反序列化会出错。2.2 为什么需要动态页面与 iframe 处理做爬虫最烦的不是数据量大而是目标页面不肯老老实实把数据放在 HTML 源码里。很多现代网站采用前后端分离内容由 JavaScript 动态渲染而 Scrapy 默认的下载器不执行 JS拿到的 HTML 是空壳解析不到数据。更麻烦的是 iframe 嵌套。有些第三方登录、数据图表、广告区块都会通过 iframe 嵌入主页面而这些 iframe 内部往往是独立文档用普通 XPath/CSS 选择器在主页面 DOM 里根本查不到。处理动态页面的主流方案有三种使用 SplashScrapy 通过 middleware 调用 Splash 渲染服务但需要单独部署 Docker 容器体积较大而且并发性能一般。使用 Selenium集成简单但浏览器实例很吃资源多节点跑起来成本高。使用 Playwright较新方案异步支持好可以接管浏览器上下文、拦截请求、切换 iframe非常适合在 Scrapy 中做动态页面抓取。我实际比较下来最终选择 Scrapy Playwright。原因很简单Playwright 是异步驱动和 Scrapy 的 Twisted 事件循环兼容性较好它提供了frame_locator方法可以直接定位 iframe 内部元素处理嵌套 iframe 非常顺手而且可以用page.route()拦截不需要的图片和字体请求节省带宽。2.3 分布式环境下动态抓取的注意事项当分布式爬虫节点需要处理动态页面时要注意一个问题如果让每个节点都启动一个 Chromium 实例内存占用会非常高。比如一个节点开 4 个并发 Playwright 页面每个 Chromium 进程大概占 300-500MB 内存4 个并发就是 2GB服务器配置低一点直接卡死。所以分布式动态抓取一般有两种做法方法一单独部署一个渲染服务集群Scrapy 节点只负责发送 URL 给渲染服务渲染服务返回渲染后的 HTML 或截图。这样渲染节点可以独立扩缩容和 Scrapy 节点解耦。方法二在每台 Scrapy 节点上限制 Playwright 并发数并通过asyncio.Semaphore控制同时打开的页面数量。我建议前期数据量不大时采用方法二控制每台机器浏览器实例数在 2-3 个稳定后再考虑独立渲染服务。毕竟分布式本身是为了提高吞吐如果因为渲染进程占用过高导致节点频繁 OOM反而得不偿失。3. 分布式爬虫的实操实现3.1 项目初始化与环境准备先建立一个干净的 Python 环境。我用的是 Python 3.10 Scrapy 2.11 Scrapy-Redis 0.7.2 Playwright 1.40。安装命令如下# 创建虚拟环境 python3 -m venv venv source venv/bin/activate # 安装 Scrapy 和 Scrapy-Redis pip install scrapy scrapy-redis # 安装 Playwright pip install playwright playwright install chromiumScrapy-Redis 的 pip 包名是scrapy-redis导入模块时是scrapy_redis。注意别搞混。接下来新建 Scrapy 项目scrapy startproject distributed_crawler cd distributed_crawler scrapy genspider product_spider example.com生成的product_spider.py默认继承scrapy.Spider我们要改成继承scrapy_redis.spiders.RedisSpider。改完后爬虫结构大概是import scrapy from scrapy_redis.spiders import RedisSpider class ProductSpider(RedisSpider): name product_spider redis_key product_spider:start_urls def parse(self, response): # 解析列表页中的数据 for item in response.css(.product-item): yield { name: item.css(.name::text).get(), price: item.css(.price::text).get(), } # 提取下一页链接 next_page response.css(a.next::attr(href)).get() if next_page: yield response.follow(next_page, callbackself.parse)继承RedisSpider后爬虫启动时不读取start_urls而是等待你向 Redis 里的product_spider:start_urlskey 发送起始链接。发送命令redis-cli lpush product_spider:start_urls https://example.com/products这时候爬虫节点就会消费这个 URL 开始抓取。3.2 settings 配置与关键参数Scrapy-Redis 不安装额外插件也能用只要在settings.py里替换几个核心配置。下面是我常用的配置# 使用 Scrapy-Redis 的调度器和去重组件 SCHEDULER scrapy_redis.scheduler.Scheduler DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter # Redis 连接参数 REDIS_HOST 10.0.0.5 REDIS_PORT 6379 REDIS_PARAMS { password: your_redis_password, } REDIS_ENCODING utf-8 # 不清理 Redis 队列方便断点续爬 SCHEDULER_PERSIST TrueSCHEDULER_PERSIST True这个参数非常关键。如果设为 False爬虫关闭时会自动清空 Redis 里的请求队列和去重集合下次启动从零开始。我们在生产环境必须设为 True这样即使节点宕机队列数据还在重启后能继续消费。还有一个容易被忽略的参数是SCHEDULER_FLUSH_ON_START。默认 False表示爬虫启动时不清空历史队列。如果你在调试阶段希望每次启动都从干净状态开始可以临时设为 True。但生产环境千万不要开否则一重启就把队列清光了。另外如果只跑静态页面CONCURRENT_REQUESTS可以调高一点比如 32 或 64。但加上了 Playwright 动态渲染后建议降到 4-8具体看服务器内存。不要盲目堆并发否则节点不稳定反而影响整体吞吐。3.3 集成 Playwright 处理动态 iframe 页面下面演示一个实际案例抓取一个商品详情页商品参数在一个 iframe 里且页面本身是 JavaScript 动态渲染的。我们需要在 Scrapy 的 Downloader Middleware 中调用 Playwright。先安装scrapy-playwright插件它封装了 Scrapy 与 Playwright 的集成pip install scrapy-playwright然后安装 Chromium 浏览器playwright install chromium在settings.py中启用下载中间件DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, } TWISTED_REACTOR twisted.internet.asyncioreactor.AsyncioSelectorReactor DOWNLOADER_MIDDLEWARES { scrapy_playwright.middleware.ScrapyPlaywrightDownloaderMiddleware: 100, }在爬虫中对需要渲染的请求添加meta标记def parse(self, response): # 对目标详情页请求, 开启 Playwright 渲染 yield scrapy.Request( urldetail_url, callbackself.parse_detail, meta{ playwright: True, playwright_include_page: True, }, ) async def parse_detail(self, response): page response.meta[playwright_page] # 等待 iframe 加载完成 frame page.frame_locator(iframe#params_frame) await frame.locator(.param-item).first.wait_for() # 提取 iframe 中的文本 params await frame.locator(.param-item).all_text_contents() await page.close() yield {url: response.url, params: params}这里有几个关键点回调函数必须写成async def才能使用await执行异步操作。获取到playwright_page后一定要在解析完成后手动await page.close()否则浏览器进程会被一直占用。frame_locator是 Playwright 提供的专用方法可以直接在 iframe 内部查找元素比page.frames再手动查找要简洁得多。如果页面里还有嵌套 iframe可以继续用frame.frame_locator(iframe#inner)往下找链条式写法很清楚。3.4 多节点启动与调度演示假设你有一台 Redis 服务器和两台爬虫节点服务器节点 A 和节点 B。两台服务器代码相同配置相同只是REDIS_HOST指向同一台 Redis。启动方式# 节点 A scrapy crawl product_spider # 节点 B scrapy crawl product_spider两个节点启动后都会去 Redis 的product_spider:requests队列里阻塞等待任务。当你在 Redis 里 push 起始 URL 后两个节点会竞争消费谁先brpop到任务就由谁抓取。可以用命令实时观察 Redis 中的队列状况# 查看待抓取请求数量 redis-cli llen product_spider:requests # 查看已抓取请求指纹数量 redis-cli scard product_spider:dupefilter # 查看爬虫运行状态 redis-cli hgetall product_spider:statsproduct_spider:stats里会记录爬虫运行过程中的请求总数、抓取成功数、异常数等统计信息多个节点共享同一个 key最终数据是所有节点累加的结果。通过这个 key 可以大致判断系统运行状况。我实测下来两台 4 核 8GB 的节点Redis 单机静态页面并发 32 时整体抓取速度约是单机模式的 1.8 倍。没到 2 倍的原因是 Redis 的brpop存在一点网络延迟以及节点间任务分配不均匀。但相比单机吞吐提升已经很可观了。4. 常见问题与排查技巧实录4.1 节点争抢与重复消费分布式爬虫最常见的现象是多个节点同时取到同一个 URL。虽然 Scrapy-Redis 的brpop能保证同一时刻只有一个节点能取到某个任务但如果你看到日志里出现重复抓取通常有两个原因。第一个原因是去重检查只发生在 URL 变成 Request 之前。如果同一个 URL 被多个节点从起始列表里重复添加而DUPEFILTER_CLASS没有正确替换成 Redis 版本那么每台节点各用一个内存 set自然无法全局去重。解决办法就是确认settings.py中的DUPEFILTER_CLASS确实指向scrapy_redis.dupefilter.RFPDupeFilter。第二个原因是你手动向 Redis 里lpush了重复的起始 URL。RedisSpider 对起始 URL 本身也会经过去重但如果你在外部程序里生成任务时没有查重可能导致重复 URL 被多次写入。建议在生成任务时先用sismember对目标 URL 做一次预判或者把任务生成也变成一个独立的去重流程。4.2 Redis 内存膨胀与性能优化Redis 里保存了所有待抓取请求对象以及所有已抓取请求的指纹。数据量达到千万级以后Redis 内存可能会涨到 10GB 以上。这时候你需要做两件事。第一件事是精简请求序列化。Scrapy-Redis 默认把整个 Request 对象 pickle 后存进 Redis其中可能包含 headers、cookies、meta 等数据非常占空间。如果只是抓取普通页面可以把 Request 中不必要的字段置空比如request scrapy.Request(urlurl, callbackself.parse, meta{dont_merge_cookies: True})减少 cookies 和 headers 的存储能显著降低 Redis 内存占用。第二件事是定期清理过期数据。比如去重集合dupefilter在项目结束后已经没有用处可以按业务周期删除。还可以启用 Redis 的maxmemory-policy allkeys-lru让 Redis 在内存不足时淘汰不常用的 key。但注意去重集合一旦被淘汰就可能导致重复抓取所以这个策略要结合实际情况来用。4.3 Playwright 集成后最常踩的坑我集成 Playwright 时踩得最深的坑是回调函数异步导致数据丢失。第一次用parse_detail写成了普通函数结果await语法直接报错后来改成async def但又忘了在函数结束时await page.close()结果爬虫跑了半小时服务器内存被 Chromium 进程占满最后 OOM 被杀。另外Playwright 和 Scrapy 的并发模型要特别注意。Scrapy 的请求并发是 Twisted 协程调度的但 Playwright 是 asyncio 事件循环二者通过scrapy-playwright的中间件桥接。如果你不控制CONCURRENT_REQUESTSScrapy 会同时启动大量页面每个页面都是一个 Chromium 进程内存瞬间爆炸。建议动态渲染时把并发数压到 4 左右。还有 iframe 的定位问题。有些 iframe 的 URL 是动态生成的但 iframe 元素本身加载需要时间。如果直接用frame_locator查找元素可能遇到元素不存在的问题。我的习惯是先等待 iframe 里的某个关键元素出现再做后续操作。比如await frame.locator(text商品参数).first.wait_for(timeout10000)如果超时再走备用逻辑例如刷新页面或者记录错误日志。生产环境一定要有超时和重试机制否则一个页面卡住会拖垮整个节点。4.4 监控与运维建议分布式爬虫的运维压力比单机爬虫大很多节点多了以后光靠人眼盯日志不现实。我建议至少做三件事。第一日志集中采集。用FileBeat或者Logstash把每台节点的日志收集到统一的 Elasticsearch 中再用 Kibana 看抓取成功率、异常分布、节点状态。没有这套的话至少也要用redis-cli定期查看stats统计及时发现节点异常停止。第二任务积压监控。Redis 的llen product_spider:requests可以看作待抓取任务的积压量。如果这个数字持续上涨说明消费者集群能力不足考虑增加节点如果长时间为 0说明任务已抓完需要补充起始 URL 或扩大抓取范围。第三断点续爬验证。建议定期在测试环境演练节点宕机、Redis 重启等场景。因为SCHEDULER_PERSIST True的机制保证了队列不丢但如果你没配置好持久化Redis 一重启队列就没了那就麻烦了。我在生产环境开启了 Redis 的 AOF 持久化至少保证宕机后能恢复到近一分钟的数据。最后再分享一个小技巧在爬虫类里重写close方法退出时把本次抓取的统计信息和时间写入 Redis 的一个 hash 中这样后续运维可以快速对比每次运行的效率。不要依赖 Scrapy 默认的 stats 输出那个只打在控制台分布式节点多了以后根本没法汇总。踩过几次坑之后我的感受是Scrapy 分布式并没有想象中那么复杂核心就是让请求队列和去重集合变得全局可见但真正让系统稳定运行的往往不是框架本身而是你对资源、并发、异常恢复这些细节的重视。希望这篇分享能帮你少走一些弯路。