做过爬虫的人大概都经历过这样一个阶段:单机Scrapy跑得好好的,协程并发调到32,请求间隔压到0.1秒,带宽跑满,硬盘一天写几GB数据,当时觉得"这不挺快吗"。直到某天接到一个需要抓取千万级数据量的需求,跑了一周才完成20%,这时候才发现,Scrapy单机的性能天花板不是CPU也不是带宽,而是调度模型本身。我这篇文章就想聊聊,怎么用Scrapy把单机爬虫改造成一套真正可横向扩展的分布式爬虫,包括核心架构、实操步骤,以及我在真实环境里踩过的坑。
1. 当单机Scrapy撞上性能天花板:先搞清楚瓶颈在哪
1.1 我遇到的那个"跑不完"的爬虫
先交代背景。去年我做了一个电商比价项目,需要采集某个垂直品类下所有商品的SKU信息。数据量大概800万条,商品详情页还要解析变体、规格、库存状态。单机Scrapy怎么跑都不太对劲:请求协程从16调到40,稳定跑了三天,Redis里存了多少条数据呢?120万。照这速度,跑完全量数据要20天,而且中间还得处理IP封禁、登录失效、详情页结构改版这些幺蛾子。更尴尬的是,这台机器如果夜里死机了,前面所有断点续传全靠MySQL里job_id维度的状态标记去拼。
当时我第一反应是"加机器",但很快意识到,无脑加机器是没用的。因为Scrapy默认的去重是基于内存中的RFPDupeFilter,每一台机器都有自己独立的去重集合和调度队列。你就算起10台机器,它们也会各自为政,抓取任务大量重复,甚至可能同时抓同一个URL。真正的解法是先让"调度"这件事从单机进程里解耦出来,让所有机器共享同一个任务队列、同一个去重集合,然后才有资格谈扩展。
1.2 单机瓶颈到底卡在哪:CPU、带宽还是调度逻辑?
很多人以为瓶颈在网络带宽,但我在实际压测中发现,对于中等规模页面(50KB到200KB),瓶颈通常在任务调度和网络等待的权衡逻辑上。
Scrapy的调度器是进程内的,它负责从优先队列里拿出Request,交给下载器执行。单机模式下,一个Request从入队到出队、到发送、到处理响应,整个生命周期都在同一个进程里,协程切换开销再小,它也是单点调度。而且当爬虫需要处理多种类型的URL(比如列表页和详情页优先级不同),或者遇到下载超时要重试时,调度器就开始变得力不从心。
另外一个隐藏瓶颈是去重集合的内存占用。Scrapy默认的指纹集合就是一个不断增长的Python set。如果你跑了千万级URL,这个set占用的内存会轻松超过2GB。关键问题是,这个set没法持久化,进程一重启就没了,你还得靠JOBDIR去延续。这是一种非常脆弱的单机状态。
所以单机Scrapy的问题,不是"不够快",而是没法在故障恢复、任务分发、去重共享三个维度上做到协作。你需要的是一个能够横跨多台机器的调度中心,而不是一台一台地堆爬虫实例。
1.3 分布式不是银弹:它解决什么,不解决什么
这里我先泼一盆冷水。分布式爬虫解决的是吞吐量和容错问题,它不解决目标网站反爬策略问题。如果你单机跑都被封IP,那分布式只会更惨——因为多台机器同时访问,被封的维度更多,暴露得更快。所以决定上分布式之前,先确认你的目标网站允许一定频率的访问策略(或者说,你有充分的合规理由和合适的限速方案),再考虑架构改造。
分布式真正解决的问题有三个:
- 任务队列共享,多个worker节点从同一个队列取任务,天然避免了重复分配。
- 去重集合共享,URL指纹只需判断一次,不需要每台机器各维护一个。
- 调度状态持久化,队列和指纹都存在Redis里,单台worker宕机不影响整个集群的任务流转。
这套思路其实非常成熟,早些年大家用Celery做分布式任务队列,后来Scrapy生态里冒出了scrapy-redis这个库,把Scrapy的调度器、去重器用Redis重写了一遍,整个接入成本就低了很多。下面我详细拆解它的工作机制。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分布式爬虫的核心机制:从进程内调度到跨机器协作
2.1 关键组件的外部化:调度器、去重器、数据管道
要理解scrapy-redis的原理,首先要理解Scrapy在单机模式下哪些东西是"进程内私有"的。
- 调度器(Scheduler):Scrapy默认的
Scheduler维护一个优先队列(默认是内存队列),负责管理待爬取的Request。 - 去重器(Dupefilter):一个基于内存
set的指纹过滤器。每当一个Request到达调度器之前,先去这个set里查指纹是否已存在。 - 爬虫队列逻辑:
start_requests生成的第一批URL,会直接进入调度器。
这三样东西在单机模式下全都在内存里,进程死了就全没了。scrapy-redis做的事,就是把这三样东西全部搬到一个所有机器都能访问的Redis里去。
听起来好像是"用Redis存队列"这么简单,但细节比这个多。在scrapy-redis里,调度器不再读取进程内的start_requests,而是读取Redis里的一个List类型key(默认是spider_name:start_urls)。worker节点通过RedisSpider或者手动把起始URL推送到这个key里,所有机器就能从这个共享队列里抢占任务。谁来抢?谁先执行lpop或者brpop,谁就拿到这个Request。
2.2 scrapy-redis 到底做了什么
具体来说,scrapy-redis做了四件关键的事:
- 替换Scheduler:把原来的
PriorityScheduler替换为基于Redis的调度器。所有待抓取的Request都被序列化以后扔进Redis的List或者ZSet(如果你启用了优先级,可以用ZSet实现按优先级调度)。 - 替换Dupefilter:把原来的内存
set替换成Redis的SET数据结构。指纹判断变成SISMEMBER+SADD两个操作。去重集合可以跨进程共享,并且可以通过Redis的持久化机制保留下来。 - 提供Spider基类:
RedisSpider和RedisCrawlSpider。它们不读取start_urls属性,而是从Redis里读取起始URL。 - 实现数据管道:
scrapy-redis还提供了一个把Item推入Redis List的Pipeline。通常不用它存最终数据,而是用它把Item暂存到Redis,再通过另一个消费者进程写入MySQL或HBase。这样爬虫进程本身不直连数据库,IO压力也不会阻塞抓取循环。
这四件事联动起来的效果是:你可以把任意数量的worker节点挂在同一个Redis上,它们共享队列、共享去重指纹、共享调度状态。任何一个节点宕机,其他节点会自动继续消费队列里的任务,不需要人工干预。
2.3 请求指纹与去重:为什么分布式下同一URL不会被重复抓取
很多刚接触分布式的朋友最容易问:多台机器同时跑,真的不会重复抓同一个URL吗?
答案是:不会,但也可能——这取决于你的指纹生成逻辑。
Scrapy的默认指纹算法是基于url、method、body、headers中的关键字段做SHA1哈希。相同的Req
