在做爬虫项目的时候,很多人一开始是“写个脚本跑一跑”,但等到数据量上来、目标站点一多、单机怎么也跑不完的时候,就会意识到一个问题——单机爬虫的瓶颈不是代码写得好不好,而是你只有一张网卡、一个 CPU、一块磁盘。我也经历过那个阶段:一台 16 核的服务器上挂着几十个 Scrapy 爬虫,内存吃满,Redis 里塞了几亿条 URL,跑两天就各种超时。后来痛定思痛,把项目整体迁到了分布式架构上,才真正体会到“多节点并行 + 统一调度”带来的效率提升。这篇就专门聊聊怎么用 Scrapy 框架搭建一套能扛住千万级 URL 的分布式爬虫,从架构选型、核心组件拆解,到动态 iframe 抓取、常见坑位排查,全程是实战视角,适合已经会用 Scrapy 写单机爬虫、但想往分布式方向进阶的同学。
1. 为什么选择 Scrapy 做分布式爬虫
1.1 分布式爬虫的本质需求:从单机到集群
先想清楚一个问题:分布式爬虫到底要解决什么?不是“用多台机器跑多个爬虫进程”这么简单。真正的需求是让 N 台机器上的爬虫进程像一个整体一样工作——共享任务队列、统一去重、协同抓取、集中存储结果。否则你只是在“多台机器上各跑各的”,那是多实例,不是分布式。
单机 Scrapy 的调度器是把待抓取 URL 放在内存里的 deque 中,去重用RFPDupeFilter(基于 URL 指纹的集合),这两个核心状态都只存在于本地进程。一旦进程退出,队列和去重集合全部丢失。而分布式架构要做的,就是把这两个“本地状态”外置到公共组件里:队列放到 Redis,去重指纹放到 Redis 的 Set,这样每个节点从同一个队列取任务,判重时查同一个集合,互相之间自然就协调起来了。
我在实际项目里最深刻的体会是:分布式爬虫的难点不在“怎么写代码”,而在“状态如何共享”。Scrapy 本身的设计是高度模块化的,调度器、去重器、下载器中间件、扩展都能自由替换,所以它天生适合被改造成分布式框架。你只需要把调度器换掉、去重器换掉,再加上一个共享队列,其余的数据管道、下载逻辑、中间件体系完全不用动,这就是为什么社区最终选择了 Scrapy 作为分布式爬虫的基底。
1.2 Scrapy 架构里天生适合分布式的部分
Scrapy 给人的印象是“一个爬虫框架”,但其实它的 Engine 机制非常像一个小型消息系统。核心流程是:Spider 产生 Request → Engine 交给 Scheduler 排队 → Scheduler 出队后交给 Downloader 下载 → Response 回到 Spider 解析 → 解析结果要么生成新的 Request 回到队列,要么交给 Item Pipeline 存储。这个流程里最关键的“胶水”就是 Scheduler 和 DupeFilter。
在单机版里,Scheduler 就是scrapy.core.scheduler.Scheduler,它内部维护一个优先级队列,并用DupeFilter判断 Request 是否重复。在 Scrapy 的默认实现中,这个去重是基于请求指纹(用请求的 URL、方法、请求体等信息哈希得到指纹)。指纹计算本身是平台无关的,所以任何节点计算同一个 URL 都会得到同样的指纹,天然适合做分布式去重。
另一个重要的点是 Scrapy 的Extensions机制。很多新手第一次看到scrapy.extensions目录会觉得很神秘,其实扩展就是框架生命周期钩子(信号)的集合。比如scrapy.extensions.corestats.CoreStats会统计item_scraped_count、response_received_count等指标;scrapy.extensions.logstats.LogStats会定期打印抓取速率;scrapy.extensions.throttle.AutoThrottle可以根据响应延迟自动调节请求速度。在分布式场景里,扩展的重要性被放大了——因为你需要跨节点观察整体运行状态,而标准输出被多进程打乱的时候,只有基于扩展收集的统计数据才是可靠的。
我当时看了源码之后才理解:想要把 Scrapy 变成分布式,你不需要重写整个框架,只需要实现一个自定义的 Scheduler 和一个自定义的 DupeFilter,然后在 settings 里通过SCHEDULER和DUPEFILTER_CLASS两个配置项替换掉默认类即可。这就是整篇博客的核心思路,接下来我会具体展开。
2. 分布式方案选型与核心组件拆解
2.1 常见方案对比:Scrapy-Redis 与自研调度
目前做 Scrapy 分布式,最成熟的方案就是scrapy-redis这个库。它的做法非常直白:把 Scheduler 改成从 Redis 的 List 或 ZSet 里取 URL,把 DupeFilter 改成向 Redis 的 Set 里写入 URL 指纹。这个方案最大的优点是“现成、稳定、社区案例多”,你只要安装后改一行 settings 就能跑起来。但它也有明显的短板:所有节点共享一个 Redis 实例,当 URL 量达到几千万、同时有几十个节点高频读写时,Redis 的网络 IO 和内存占用会成为瓶颈。
除了 Redis 方案,也有人会选择用消息队列(比如 RabbitMQ、Kafka)做任务分发,再用独立的服务做去重和状态管理。这种方案更灵活,吞吐量可以做得更高,但开发成本也大。我之前为了做定时全量抓取,就自己在一个项目里用 Kafka 做任务队列,把每个 URL 的关键信息作为消息体,consumer 端再根据任务类型构造 Request。这个方案的优点是天然支持任务回溯、分区分组,缺点是 Scrapy 的延迟重试、调度优先级这些机制需要自己重新实现,工作量不小。
到底选哪一种,取决于你的规模和团队能力。下表是我做过的一个简单对比:
| 对比维度 | scrapy-redis | 自研消息队列 + 去重服务 |
|---|---|---|
| 上手成本 | 极低,改两三个配置即可 | 高,需要设计队列协议和消费逻辑 |
| 吞吐量 | 中等,受限于单 Redis 实例 | 高,可横向扩展 MQ 集群 |
| 去重方案 | 直接复用 Redis Set,简单可靠 | 需要自建布隆过滤器或 Redis Cluster |
| 优先级调度 | 支持,但体验一般 | 可完全自定义 |
| 延迟重试机制 | 需额外封装 | 可利用 MQ 延迟队列 |
| 社区支持 | 非常成熟 | 靠自己踩坑 |
对于绝大多数中小型项目,我建议第一版直接用 scrapy-redis,跑通架构后再按需优化。没有必要一开始就上 Kafka,否则光调参和排错就能耗掉你一周。
2.2 核心细节:任务队列、指纹去重、状态同步
分布式爬虫有三个核心细节决定了系统能不能长期稳定运行,分别是任务队列、指纹去重和状态同步。
任务队列的第一个问题是“从队列里取 URL 之后,如果节点挂了怎么办”。scrapy-redis 默认从 List 左边pop,一旦一个节点取走了 URL,还没来得及请求就宕机,这个 URL 无论如何都会丢失。要解决这个问题,可以使用 Redis 的BRPOPLPUSH操作:从主队列取任务的同时,把任务备份到一个临时队列,只有任务成功完成后才从临时队列删除;如果节点崩溃,备份队列里的任务还可以被重新放回主队列。scrapy-redis 最新版本已经提供类似机制,但很多教程没提,这里大家要记住。
第二个核心细节是去重。用 Redis Set 直接存指纹,数据量大了之后会非常占用内存。举个例子:一个 URL 指纹是 40 位十六进制字符串,也就是 40 字节,存 1 亿条指纹大约要 4GB 内存,而且 Redis 还要额外维护哈希表的开销,实际占用可能翻倍。我后来做的优化是改用 Redis Bloom Filter(布隆过滤器),在允许极低误判率的情况下把内存降到原来的十分之一以下。具体做法就是维护一个 bit 数组和几个 hash 函数,判断“某个指纹是否见过”时不再需要完整存储所有指纹。要注意布隆过滤器有误判概率,会导致极少数 URL 被跳过,但对爬虫来说,漏抓一个两个可以通过后续的重试机制找补,影响不大。
再说状态同步。分布式环境下,每个节点不仅消费任务,还可能在运行中产生新的 URL(比如解析列表页翻页、拼接详情页链接)。新 URL 写入 Redis 队列之前,必须先通过去重检查,否则一个详情页可能被多个节点同时抓到。Scrapy 的调度器在enqueue_request时会先调用filter_request,也就是去重过滤,因此只要你的去重器是全局共享的,新产生的 URL 就不会被重复分发。这里要特别注意:如果你只改了调度器而忘了改去重器,那么每个节点还在用自己的本地去重集合,那等于白做分布式。
2.3 Scrapy 扩展(Extensions)机制到底是什么
在分布式爬虫项目里,要监控各个节点的状态,离不开 Scrapy 的扩展机制。我第一次接触 Extensions 时也一头雾水,总觉得它是给框架“加功能”的神秘入口,后来读了源码才明白:Scrapy 是一个基于事件(信号)驱动的框架,Extensions不过是在特定信号触发时执行特定逻辑的类而已。
Scrapy 内置了大量扩展,比如CoreStats监听engine_started、item_scraped、spider_closed等信号,维护一个 stats 字典;LogStats每隔一段时间打印一次抓取进度;CloseSpider可以在 item 数量达到阈值时自动停止爬虫。你也可以根据自己的需求写一个扩展:在from_crawler类方法中通过crawler.signals.connect连接到某个信号,然后在回调里处理自定义逻辑。
我在分布式项目里最常用的一个扩展是“心跳上报器”:每个节点每隔 10 秒把自己当前的爬虫名称、进程 ID、待处理请求数、抓取速度上报到 Redis 或监控系统。这样,运维人员打开一个面板就能看到整个集群里每个节点是否存活、负载是否均衡。另外,我还用扩展做了“爬虫健康检查”:如果某个节点连续 5 分钟没有抓到任何数据,就自动发送告警。这个逻辑如果写在 spider 内部会很臃肿,写成扩展就是最合理的方式。
所以,想要进阶 Scrapy,建议把官方文档里 “Extensions” 那一节多看两遍,尤其是信号列表。理解了信号和扩展之后,你会发现自己能做的事突然多出一大截。
3. 实操:从零搭建一个 Scrapy 分布式爬虫
3.1 环境准备与基础项目结构
先准备环境。依赖包括 Python 3.8 以上、Scrapy 2.x、scrapy-redis 2.x,以及一个可用的 Redis 实例(本地或远程都行)。强烈建议用虚拟环境,避免污染系统 Python。安装命令如下:
pip install scrapy scrapy-redis # 如果后续要集成本文提到的动态页面抓取,还需要额外安装: pip install playwright scrapy-playwright playwright install chromium然后创建项目。我习惯把项目结构设计成下面这样:
distributed_crawler/ ├── scrapy.cfg ├── requirements.txt └── distributed_crawler/ ├── __init__.py ├── settings.py # 分布式配置都在这里 ├── spiders/ │ ├── __init__.py │ └── news_spider.py # 示例爬虫 ├── middlewares.py # 下载中间件 ├── pipelines.py # 数据管道 ├── extensions.py # 自定义扩展(心跳上报等) └── items.pySpider 的写法跟单机基本一样,唯一要注意的是,spider 中的起始请求不再是直接start_urls列表,而是要从 Redis 队列里读取。在 scrapy-redis 中,你只需要让 Spider 继承RedisSpider,然后指定redis_key,这样爬虫启动时会去 Redis 的这个 key 对应的 List 里获取起始 URL。下面是一个极简示例:
import scrapy from scrapy_redis.spiders import RedisSpider class NewsSpider(RedisSpider): name = "news" redis_key = "news:start_urls" def parse(self, response): # 解析逻辑... yield { "url": response.url, "title": response.css("h1::text").get(), }启动前,你需要先向 Redis 的news:start_urls里推入一个种子 URL:
redis-cli lpush news:start_urls https://example.com/news/1然后直接用scrapy crawl news启动。只要 Redis 里有任务,爬虫就会持续运行;队列空了,爬虫也不会立即退出,而是进入等待状态。
3.2 改造为分布式:配置 Redis 连接与共享队列
有了基础项目,下面就是关键的改造步骤。打开settings.py,添加以下配置:
# 使用 scrapy-redis 的调度器 SCHEDULER = "scrapy_redis.scheduler.Scheduler" # 使用 scrapy-redis 的去重过滤器 DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" # 请求队列使用 Redis 的 List 类型(先进先出) SCHEDULER_QUEUE_CLASS = "scrapy_redis.queue.FifoQueue" # 是否持久化调度队列和去重集合 SCHEDULER_PERSIST = True # Redis 连接配置 REDIS_URL = "redis://:password@10.0.0.5:6379/0"这里重点解释两个容易被忽略的配置项:
SCHEDULER_PERSIST设置为True,表示在爬虫结束后不清空 Redis 中的队列和去重集合。这对于分布式场景非常重要。比如你启动了两个节点,其中一个节点因为网络问题提前退出,此时队列里的任务不应该被清掉,否则另一个节点会漏抓。我见过很多新手因为这个配置没设,导致每跑一次任务就全部丢失,所以务必记住。
FifoQueue是先进先出队列,适合绝大多数场景。如果你有优先级需求,可以换成PriorityQueue,它会根据 Request 对象里设置的 priority 字段在 Redis 的 ZSet 里进行排序,这样你能让详情页请求优先于列表页请求,提高抓取效率。
完成以上配置后,你就可以在两台机器上同时scrapy crawl news,它们会从同一个 Redis 队列里取任务,抓到的数据各自通过 Pipeline 写入同一个存储。为了验证分布式是否真的生效,我通常会在两个节点分别打印spider_id(通过kw参数传进去),然后在日志里确认两个节点抓到了不同的 URL。
如果你要抓取的数据量很大,还需要考虑一个问题:Redis 的单线程模型对大量小请求的吞吐能力很强,但延迟受带宽影响。我曾在一个项目中,四台机器同时跑 32 个爬虫进程,QPS 超过 3000,结果 Redis 的INFO commandstats显示lpop和sadd占比最高。这时候可以把 Redis 放在内网,避免公网带来的延迟;如果还顶不住,再用 Sentinel 或 Cluster 方案。
3.3 应对动态页面:Scrapy 中集成 Playwright 处理 iframe
往年做爬虫最头疼的除了分布式,还有动态渲染页面。很多网站的数据不是写死在 HTML 里,而是通过 JavaScript 请求接口后渲染,甚至放在多层 iframe 中。Scrapy 默认的scrapy.http.HtmlResponse是拿不到动态内容的,因为 HTML 文档里根本没有这些数据。这个场景下,就要引入浏览器自动化工具。
我的推荐方案是scrapy-playwright,它把 Playwright 集成到 Scrapy 的下载器里,让每个 Request 可以选择走普通 HTTP 下载还是走无头浏览器渲染。这样你不需要把整个爬虫推倒重写,只需要在 Requests 上打个标记。
先启用 Playwright 中间件:
DOWNLOAD_HANDLERS = { "http": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", "https": "scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler", } TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor" PLAYWRIGHT_BROWSER_TYPE = "chromium" PLAYWRIGHT_LAUNCH_OPTIONS = { "headless": True, "args": ["--no-sandbox"], }然后在 spider 中,对需要动态渲染的 URL 添加meta信息:
def start_requests(self): for url in ["https://example.com/price_info"]: yield scrapy.Request( url, meta={ "playwright": True, "playwright_include_page": True, }, callback=self.parse_dynamic ) async def parse_dynamic(self, response): page = response.meta["playwright_page"] # 如果数据在 iframe 中,需要先等待 iframe 加载完成 try: frame = page.frames[-1] # 简单示例,取最后一个 frame await frame.wait_for_selector("table#price-table", timeout=5000) content = await frame.content() # 也可以直接用 frame 里的选择器取文本 price = await frame.locator("#price-value").text_content() finally: await page.close() yield {"price": price}注意:Scrapy 的playwright_include_page设置为True时,response.meta里会有一个 Playwright 的 Page 对象。你必须在使用完毕后关闭它,否则会造成浏览器标签页泄漏,最终耗尽内存。我在生产环境里遇到过一晚上爬虫所有节点全部 OOM,查到最后就是忘了关闭 Page 造成的。
对于 iframe 的处理,核心逻辑是先识别 iframe 的加载顺序。一般页面会有多个 frame,包括主 frame 以及嵌套的iframe。你可以在 Playwright 控制台里先输出page.frames,看看目标数据在哪个 frame 里,找到后直接对这个 frame 进行操作。如果遇到跨域 iframe,Playwright 也能访问,因为它底层是真实浏览器,不受同源策略限制——这是它比单纯用 requests 模拟请求再解析 HTML 强得多的原因。
4. 常见问题与排查技巧实录
4.1 去重失效与重复抓取
分布式爬虫最常见的怪现象就是“明明配置了去重,但还是抓了一堆重复页面”。排查这类问题的顺序通常是:先看DUPEFILTER_CLASS是否生效,再看 Redis 里dupefilter对应的 key 是否存在,最后看指纹计算规则是否一致。
如果你用的是 scrapy-redis,默认去重 key 的格式是spider_name:dupefilter,比如news:dupefilter。你可以在 Redis 里执行:
redis-cli scard news:dupefilter如果这个集合的大小一直增长,说明去重过滤在正常写指纹。如果大小始终为 0,那说明请求根本没经过这个过滤器。出现这种情况时,多半是你在某些 request 上设置了dont_filter=True,强制跳过了去重。在分布式爬虫里,除非有明确理由,否则不要随意设置dont_filter=True,这会直接破坏全局去重。
还有一种隐蔽情况:你会遇到同一个 URL 因为 URL 参数顺序不同而被当成不同指纹。例如https://example.com/?b=2&a=1和https://example.com/?a=1&b=2,在 human 看来是同一个页面,但在 Scrapy 指纹算法里是不同的 URL。解决办法是自定义指纹计算类,在请求进入调度器之前用urlparse解析并规范参数顺序。官方默认的指纹类是scrapy.utils.request.request_fingerprint,你可以重写一个类并在DUPEFILTER_CLASS中引用。
4.2 爬虫进程崩溃与任务丢失
分布式环境下,单节点崩溃几乎是必然事件,所以设计时就要接受“节点会挂”这一事实。如果你发现一个节点停机后,它取走的那部分任务永远消失了,说明你的队列缺失了“备份”机制。
要确保任务不丢,可以换用scheduler_queue_class为scrapy_redis.queue.LifoQueue或FifoQueue都无法解决取走即删的问题,你需要用 Redis 的brpoplpush模式。scrapy-redis 官方其实提供了一个queue_base,但自定义程度不够。我的做法是在自己的 Scheduler 里重写enqueue_request和next_request逻辑,将任务 pop 出来之后立刻放入一个processing_queue,并设置一个较短的过期时间(比如 10 分钟)。任务正常完成后,从processing_queue里移除;如果节点崩溃,processing_queue里的任务会在过期后重新回到主队列。注意这个逻辑需要依赖 Redis 的 Lua 脚本才能保证原子操作,不建议用“先 pop 再写入”的 Python 语句分步执行,否则并发下会出现任务重复分发。
此外,为了防止任务丢失,我还习惯让每个节点在关闭前通过spider_closed信号把自己的当前状态上报到 Redis。这样即使队列真的丢了几个 URL,我们也能从日志里比对每个节点“取了多少任务、完成多少任务、失败多少任务”,找到具体的损失范围。
4.3 反爬封锁与请求频率控制
分布式解决了并发问题,但同时也把“反爬封锁”从单机变成了集群层面的问题。如果你的 10 个节点都用同一个 IP 去抓同一个网站,那基本等于自杀式攻击,封号 ID 或 IP 的速度远比你抓取速度快。
应对策略有几个层次。最基础的是控制单节点 QPS,在settings.py中设置:
DOWNLOAD_DELAY = 1.0 CONCURRENT_REQUESTS_PER_DOMAIN = 2 CONCURRENT_REQUESTS_PER_IP = 2 AUTOTHROTTLE_ENABLED = True同时开启 AutoThrottle 扩展,它会根据服务器响应时间和抓取成功率动态调整请求速度。这个方法能有效降低被封风险,但代价是抓取速度不可控,不适合对时效性要求高的场景。
再进阶一点就是代理池。分布式爬虫一定要设计多 IP 出口,否则无论你怎么调频,一个 IP 的请求频率一高就会被封。我常用的做法是让所有请求都走一个代理中间件,中间件从 Redis 的代理池里随机取一个代理 IP,打上meta["proxy"]。同时要处理代理失效的场景:当请求因为代理问题返回 407 或连接超时,要在下载中间件的process_exception里将失败的代理 IP 从 Redis 集合中移除,并重试请求。注意重试次数不要太多,一般 2 到 3 次就足够,否则死锁问题会放大。
对于验证码问题,如果页面频繁出现验证码,我建议“先绕后解”:先做指纹隔离和请求伪装,减少触发次数。真正需要解验证码时再接打码平台,不要一开始就配置打码,否则成本高且效率低。
4.4 监控与运维:统计 QPS、抓取成功率
一个分布式爬虫跑起来很简单,但要让它在生产环境稳定运行 24 小时,监控是必不可少的。Scrapy 自带的CoreStats扩展里已经统计了response_received_count、item_scraped_count、downloader/exception_count等数据,但如果只靠日志,多个节点看不过来。我的做法是写一个自定义扩展,定时把统计数据上报到 Redis 或者 Graphite。
这里给一个简单的心跳扩展示例:
import time from scrapy import signals from scrapy.exceptions import NotConfigured class HeartbeatExtension: def __init__(self, stats, interval=10): self.stats = stats self.interval = interval self.node_id = "node_" + str(time.time()) @classmethod def from_crawler(cls, crawler): if not crawler.settings.getbool("HEARTBEAT_ENABLED"): raise NotConfigured ext = cls(crawler.stats, crawler.settings.getint("HEARTBEAT_INTERVAL")) crawler.signals.connect(ext.spider_opened, signal=signals.spider_opened) crawler.signals.connect(ext.spider_closed, signal=signals.spider_closed) return ext def spider_opened(self, spider): self.task = spider.crawler.reactor.callLater(self.interval, self.ping, spider) def ping(self, spider): spider.crawler.engine.pause() # 这里实际写 Redis self.stats.set_value("node_id", self.node_id) self.stats.set_value("pending_in_queue", spider.crawler.engine.scheduler.get_queue_len()) # 恢复 engine spider.crawler.engine.unpause() self.task = spider.crawler.reactor.callLater(self.interval, self.ping, spider)注意上面代码里我使用了engine.pause()和engine.unpause()。为什么这么写?因为获取scheduler.get_queue_len()时,如果 engine 正在处理请求,队列长度可能变化,为了读数稳定,稍微暂停一下是一个简单粗暴的兜底办法。实际上你完全可以通过 Redis 的llen命令直接获取队列长度,不依赖 scheduler 内部状态,这样更省事。
除了这套扩展,我还建议在每个节点上部署一个简单的进程守护(用 systemd 或 supervisor),确保进程退出后能自动拉起。我曾用 supervisor 配置了autorestart=true,并设置了startsecs=5,每次节点异常退出后 5 秒内就会重新启动,基本可以做到无人值守。
5. 项目落地后的经验与优化方向
到这里,核心的分布式爬虫已经能跑起来了。但跑起来只是第一步,下面我分享几个在生产环境中反复踩过之后的优化心得,你可以当作 checklist 来用。
第一,数据管道一定要做幂等。分布式环境下,即使你去重做得再好,也无法 100% 避免重复写入(比如网络异常导致任务重发)。因此,在 Pipeline 写入数据库时,尽量用INSERT ON CONFLICT DO NOTHING(PostgreSQL)或replace into(MySQL)等幂等操作,避免重复数据污染。
第二,避免使用过大的 Item 和过重的回调。每个 Response 在多个节点之间不会有交互,但如果你的 Parse 回调里做了耗时的图片下载、OCR 识别等操作,会拖慢整个节点的抓取速度。建议把这类操作放到后续的独立消费服务中,从 Item Pipeline 写进消息队列,再由下游 worker 异步处理。
第三,定期清理 Redis 中的去重集合。Bloom Filter 也好,Redis Set 也好,长时间运行后会非常大,影响读写性能。可以写一个定时任务,每天凌晨将去重集合导出备份后清空,并重新从数据库中已抓取 URL 重建指纹。这样能有效控制内存。
第四,分布式爬虫的前期设计比后期维护重要得多。我见过太多项目,一开始就用 Redis 硬扛,等到每天几千万 URL 级别才想到要扩展,结果所有爬虫都要改。如果你预判未来数据量会快速增长,建议一开始就使用支持横向扩展的消息队列,比如 Kafka 或 Pulsar,同时把去重服务单独拆出来,这样后续扩容会从容很多。
第五,关于动态页面和 iframe 的处理,我建议“能不用浏览器就不要用”。Playwright 渲染虽然功能强大,但开销极大,一个无头浏览器实例要吃 200MB~500MB 内存,如果分布式有 10 个节点、每个节点开 4 个浏览器,那内存压力是灾难级的。优先分析网站的接口,看看数据是不是从某个 JSON API 里拉取的;如果是,直接请求接口。只有接口加密严重或者必须在浏览器环境里才能拿到数据时,再上 Playwright。
我在以往项目里遇到过一个典型场景:某个网站的数据放在一个嵌有多层 iframe 的页面里,最内层 iframe 的地址隐藏在 JavaScript 变量里。当时我通过 Capture 页面发起的网络请求,直接从 DevTools 的 Network 面板里找出了内层 iframe 的真实 URL,然后绕过浏览器直接请求那个 URL,成功拿到了数据。这个思路比“死磕 iframe 渲染”高效太多。
最后再分享一个小技巧:在分布式爬虫里,给每个 Spider 里的 Request 设置合理的dont_filter和下载优先级,对整个集群的抓取效率影响巨大。列表页的翻页请求优先级可以设高一些,因为列表页是生产 URL 的关键;详情页的请求优先级设低一些,避免海量详情请求把列表请求堵在后面。这个优化虽然简单,但在实测中能把整体抓全率提升好几个百分点。
一台机器能抓的数据总量是有天花板的,但分布式的潜力几乎是无限的。把 Scrapy 改造成分布式爬虫的过程中,你不仅是在堆机器,更是在重新理解“任务分发、去重、调度、监控”这一整套工程体系。希望这篇文章能帮你少走一些弯路,如果你在实操中遇到本文没写到的问题,欢迎带着具体现象来交流。