1. 单机Scrapy跑不动了:从一次数据任务聊起
1.1 我遇到的瓶颈:不是CPU不够,而是等待太多
在给爬虫做分布式改造之前,我一直觉得抓取慢就是机器性能不行。直到我盯着监控看了两天,才发现单机Scrapy的瓶颈往往不在CPU和内存,而在于大量请求都处在等待状态。Scrapy默认的并发数是16,意味着同一时刻最多发16个请求,大多数时间都在等目标站点返回响应。带宽和连接数一旦被占满,继续调高并发不仅提不了速,反而容易触发目标站点的限流机制,大量请求直接进入重试队列。我那台8核16G的云主机,CPU利用率长期只有30%左右,反倒是磁盘排队很严重,因为日志和中间落盘文件都在抢IO。
后来我把并发从16调到32,又加上了自动限速扩展,吞吐确实提升了一些,但很快就触顶了。根本原因在于单机只有一个调度器,待抓取的URL队列和已经抓过的指纹集合都保存在进程内存里。换句话说,不管怎么调优,单机网络IO、连接数、解析速度这三件事总有一个先到极限,日抓取量从40万往100万爬,靠一台机器基本不现实。
1.2 什么样的任务才值得上分布式
这里必须说清楚,分布式不是万金油。我自己评估要不要上分布式,会先过一遍这几条判断:
- 如果总量只有几万条URL,跑一次就不动,那单机加合理的重试机制就够,没必要引入Redis、多节点这些重装备。
- 如果是千万级甚至上亿级URL的存量抓取,或者每天都有增量更新、对时效性要求高,单机哪怕再优化也很难在指定窗口内完成,这时才值得考虑横向扩展。
- 如果目标站点分散在不同区域、不同网络环境,节点分布反而能降低整体被限流的概率,这是单机方案做不到的。
- 如果对容灾和断点恢复有硬性要求,比如凌晨节点崩溃了、第二天要接着跑,那么单机进程一挂,整个任务就得从头来,分布式在这个场景下几乎是刚需。
我遇过的典型场景是:周增量200万条,要求6小时内完成全量刷新。单机吞吐每小时最多5万条,算下来需要40小时,明显超时。而上一套两节点的分布式集群,就算单节点每小时只有4万条,两个节点加起来也有8万条,6小时能抓48万条,余量不多但能跑通。所以先拿“总量除以截止时间”算出需要的吞吐量,再去测单机实际吞吐,差距多大就知道该不该上分布式了。
1.3 分布式带来的三个核心价值
很多人一提分布式就想到高并发,其实它带来的好处至少有三个,并发只是其中一个。
第一是横向扩展能力。单机有上限,但两台、三台、十台机器可以近乎线性地扩展吞吐,成本就是加机器和保证Redis不成为新瓶颈。第二是容灾与断点续跑。单机爬虫逻辑上是一条单链,进程挂了,队列在内存里面跟着消失;分布式把所有待抓取URL放进公共队列,一个节点挂了,其他节点继续消费,重启后的节点可以从队列里接着领任务。第三是统一去重。多个节点同时跑,最怕的是同一个URL被两台机器同时抓到。把去重指纹集合放到Redis里,任意节点发起抓取前都先查一下全局指纹,重复请求能被挡在下载之前。
我当时决定改造,打动我的其实就是这三点。不是因为我喜欢“分布式”这个听起来很厉害的词,而是业务要求我必须让任务在指定窗口内完成,同时不能容忍节点重启后推倒重来。如果你也处在同样的处境,那么这篇文章接下来的内容会非常有用。
2. 拆开Scrapy引擎盖:分布式改造到底改了什么
2.1 单机Scrapy里,调度器和去重器是“独立王国”
想改造一个东西,首先得知道它内部是怎么转的。Scrapy单机版的核心组件包括:Scrapy Engine(引擎)、Scheduler(调度器)、Downloader(下载器)、Spider(爬虫逻辑)、Item Pipeline(数据流水线),还有一个容易被忽略的Dupefilter(去重器)。
正常情况下,Spider解析页面后生成新的Request,引擎把这些Request交给Scheduler。Scheduler内部维护一个待抓取队列,同时调用Dupefilter去判断这个URL是不是已经抓过。如果没抓过,它就被排在队列里等待下载。Downloader请求回来的网页再交给Spider解析,如此循环。问题在于这个队列和指纹集合都是进程内的私有状态。A机器上的爬虫抓过了一万条URL,B机器完全不知道,这就是单个Scrapy实例没法直接拼成多节点集群的根本原因。
我用一个比较生活化的类比来帮助理解:单机Scrapy就像一个小饭馆,厨师自己写菜单、自己备菜、自己炒菜,整个流程都在同一个厨房里完成。要让两个厨师一起干活,就必须至少共享一个“已经做过哪些菜”的清单和一个“接下来要做哪道菜”的任务板,不然两人很可能同时做同一道菜,或者不知道剩下的菜由谁来接。
2.2 改造的本质:把URL队列和指纹集合搬进Redis
分布式改造的核心不是重写Spider逻辑,而是把调度器使用的“待抓取队列”和“已抓过指纹集合”从本地内存搬到Redis。这样,多个节点上的Scrapy实例读到的就是同一个队列、同一份指纹库。
具体实现上,很多团队选择使用scrapy-redis这个开源库。它做的事情说穿了并不复杂:实现了Scrapy自定义调度器的接口,让Scheduler不再从本地队列取URL,而是通过Redis的列表结构LPUSH和BRPOP来存取待抓取URL;同时实现了Dupefilter接口,用Redis的SET来保存请求指纹,查询和写入都走Redis。Spider代码里只需要改动一个基类,设置好一个Redis队列的key,后续的逻辑都可以沿用原来的写法。
改造后的典型数据流是这样的:运维人员先往Redis的某个list里LPUSH几条种子URL,各节点的Spider通过RedisSpider监听这个队列。谁抢到URL,谁就去下载、解析、提取新的URL,然后把新URL再LPUSH回Redis队列。与此同时,每个请求在入队前会先计算一个指纹,去Redis的SET里查一下看有没有出现过。整套流程里,“谁是调度器”已经不重要了,重要的是Redis作为任务中转发什么、转发给谁、是否重复。
2.3 为什么替换的是调度器和去重器,而不是下载器
我当时也走过弯路,一开始想直接改下载器,让多台机器并行下载。后来发现方向错了。问题不在于下载这个动作本身,而在于下载任务该怎么分配、怎么避免重复、怎么在某个节点挂掉后把任务收回来。这些恰恰是调度和解耦的职责,和下载器一毛钱关系都没有。
Downloader做的事情就是根据一个Request拿Response,它天然是无状态的,多台机器各下载各的没有任何问题。真正需要全局状态的是任务队列和去重集合。所以只要把Scheduler和Dupefilter这两个有状态组件抽出去,其他所有组件几乎都可以保持原样。这也就是为什么scrapy-redis只替换了两个核心类,其他部分不动的原因。理解了这个原理,你再看网上那些配置教程就不会觉得是在背参数,因为你清楚每一行配置在全局链路里的作用。
2.4 去重的内存账:精确集合和布隆过滤器怎么选
这里要先算一笔内存账。一个请求指纹默认是用sha1生成的40位十六进制字符串,一个指纹大约占40字节。一千万条URL就意味着至少400MB的Redis内存,这还没算SET结构自身的指针开销。如果项目规模是百万级,用Redis SET完全没问题;一旦URL条数上亿,一个纯字符串SET轻轻松松吃掉几个GB内存。
所以当数据量继续往上走,就需要考虑布隆过滤器方案。布隆过滤器用位数组加多个哈希函数来判断一个URL之前是否见过,空间占用可以压缩到SET的几十分之一,代价是存在误判。布隆过滤器说“没见过”,那一定没见过;它说“见过”,却有一定概率其实没抓过。反应到爬虫里,就是少量URL因为误判被告知“抓过了”而漏抓。
我在实际项目里的选择标准是:总量低于5000万条,用Redis SET,简单可靠;总量上亿且允许极少量漏抓,才上布隆过滤器。很多人一上来就追求“高逼格”的布隆过滤器,反而在排查漏抓问题时被内存外的第二层逻辑绕得够呛。先把精确去重跑明白,再谈优化,这个顺序不能乱。
3. 实操改造:配置项、爬虫类和Redis队列的前后对比
3.1 先备好基础环境:Redis、依赖和两台节点
改造前的第一步是准备环境。我这里说的“节点”可以是一台云主机上的多个Scrapy进程,也可以是真正的多台机器。但为了让改造效果有明显对比,建议至少有两个独立的节点环境,比如两台物理机或两个容器。如果只有一台开发机,用两个终端分别启动两个Scrapy进程再观察日志,也能模拟分布式效果,只是网络隔离的差异看不出来。
Redis建议独立部署,不要装在业务节点上。配置时我一般会做四件事:开启AOF持久化,设置maxmemory上限,配置requirepass访问密码,并把maxmemory-policy设置为allkeys-lru。前三点是为了防止进程崩溃后队列丢失、防止内存被拖垮、禁止非法访问;最后一点是为了在Redis内存写满时优先淘汰不怎么用的key,避免新写入直接报错。
依赖方面,只需要在原有Scrapy环境里额外安装scrapy-redis。安装命令很简单:pip install scrapy redis scrapy-redis。这里要注意scrapy-redis对Scrapy版本有一定兼容要求,建议先用一个干净的虚拟环境测试,避免依赖冲突把原有的爬虫项目搞崩。
3.2 settings.py里必须改的几个字段
打开你的Scrapy项目settings.py,找到调度器和去重器相关位置,把它们替换成Redis版本。下面是一份我常用的配置,每行都做了注释说明:
# 使用scrapy_redis的调度器 SCHEDULER = "scrapy_redis.scheduler.Scheduler" # 指纹去重改为Redis版本 DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" # 调度器是否在爬虫结束时保持队列状态 SCHEDULER_PERSIST = True # Redis连接地址,格式: redis://:密码@主机IP:端口/数据库编号 REDIS_URL = "redis://:yourpassword@10.0.0.12:6379/0" # 如果想看抓取结果先进Redis再落到数据库,可以临时开启RedisPipeline ITEM_PIPELINES = { "scrapy_redis.pipelines.RedisPipeline": 300, }这里面最关键的两个配置是SCHEDULER和DUPEFILTER_CLASS。第一行把调度器从本地队列切到Redis队列,第二行把去重逻辑从进程内集合切到Redis集合。REDIS_URL必须保证所有节点都能访问,如果防火墙挡着,整个集群会在启动后一直空转,这个坑我后面还会提到。
SCHEDULER_PERSIST=True的含义是:爬虫收到正常关闭信号时,不清空Redis里的待抓取队列和指纹集合。这样下次启动时,队列还在、指纹还在,实现断点续爬。生产环境建议始终开着,否则一不小心就会丢掉一批任务。
3.3 Spider代码:从Scrapy.Spider换成RedisSpider
接下来改动Spider代码。以前你会写一个继承自scrapy.Spider的类,里面把start_urls定义成一个列表。现在改成继承scrapy_redis.spiders.RedisSpider,并且不再需要定义start_urls,取而代之的是指定一个redis_key。看一个最简示例:
import scrapy from scrapy_redis.spiders import RedisSpider class MyInfoSpider(RedisSpider): name = "my_info_spider" redis_key = "my_spider:start_urls" def parse(self, response): # 原来的解析逻辑完全不用动 for item in self._parse_detail(response): yield item # 解析出来的新URL会继续进入Redis队列 next_page = response.css("a.next::attr(href)").get() if next_page: yield scrapy.Request(url=response.urljoin(next_page), callback=self.parse)这个redis_key就是种子URL队列的名字。Spider启动后会一直监听这个key,等Redis里有数据了才开始消费。注意,它不会像单机版那样因为“队列空了”就自动关闭。哪怕所有页面都抓完了,它也继续在那边等新任务,直到你手动终止它。
种子URL的放入方式也变了。以前写在代码里,现在通过客户端命令塞进Redis:
# 往种子队列里放入初始URL redis-cli LPUSH my_spider:start_urls "https://example.com/category/a" redis-cli LPUSH my_spider:start_urls "https://example.com/category/b"放进去之后,任何正在运行的节点都可能立刻消费掉这些URL。如果你有多个不同的爬虫任务,可以给每个任务分配不同的redis_key,互不干扰。这样一套代码、多个队列,任务之间天然隔离。
3.4 多节点启动顺序和验证方式
我第一次改造时犯过一个低级错误:先启动了两个节点,然后才往Redis里放种子URL。结果看到两个节点的日志都在“等着”队列有数据,界面非常安静。所以正确的顺序是:先把种子URL放进Redis,再启动节点,或者启动节点后立刻放入种子URL。
验证是否真的分布式运行,不要只看日志里“爬了多少条”的错觉。我会同时开三个终端窗口:第一个窗口跑节点A日志,第二个窗口跑节点B日志,第三个窗口随时执行Redis命令观察队列。核心命令是这几个:
# 观察待抓取队列长度(每秒都会变化) redis-cli LLEN my_spider:start_urls # 观察请求总量,了解整个集群的整体推进节奏 redis-cli SCARD my_spider:dupefilter # 观察Redis实时内存,排查异常增长 redis-cli INFO memory如果节点A和节点B的日志都在稳定输出抓取记录,并且LLEN的值在持续下降,说明两个节点确实在共享同一个队列。此时你还可以做个对比实验:单机运行时每分钟抓取数为N,两个节点同时跑时每分钟为约2N,那就说明横向扩展生效了。如果两个节点的日志加起来速度还不如单机,那就要检查是不是某个节点的网络带宽或者代理配置出了问题。
4. 分布式后的第一波雷:断点续爬、数据倾斜与内存膨胀
4.1 断点续爬的真相:队列还在,但你的代码未必接得住
SCHEDULER_PERSIST=True确实保留了队列和指纹,不过我第一次“断点续爬”时遇到了一个莫名其妙的坑。任务跑了一半,我用Ctrl+C把进程停掉,然后重启节点,发现新放进Redis的URL一条都不抓。排查了半天才意识到:指纹集合还在,我在测试时把同一个URL重新放进了seed队列,但它的指纹早就存在于dupefilter集合里了,于是每次入队前都被当成“已经抓过”而过滤掉。
这不是bug,是特性。真正要断点续爬时,千万不能把已经抓过的URL重新塞回种子队列。如果确实有补抓需求,要先清理对应请求的指纹。最粗暴的做法是直接删除dupefilter这个key,但要承担后续所有请求重新放行的代价。更精细的做法是单独建立一个“补抓队列”,不经过全局去重,只抓人工确认的少量URL。
另一个需要提醒的是:RedisSpider不会因为队列为空而自动退出。任务完成后,爬虫进程会一直挂着监听。这是分布式设计下的预期行为,在定时任务场景里通常配合外部调度器来管理进程生命周期,比如在任务完成后主动发终止信号,而不是干等着它自己结束。
4.2 数据倾斜与顺序乱套:谁快谁拿带来的副作用
默认的scrapy-redis调度模式就是“谁快谁拿”,所有节点从同一个队列里抢URL。这在大规模场景下很高效,但会产生两类问题。
第一是数据倾斜。集群里某台机器网络好、代理稳定,它就会抢到更多任务;另一台机器网络抖动,可能大部分时间在重试和等待,接到的任务少得多。我见过最极端的情况是两节点集群中一个节点干了九成的活,另一个几乎在摸鱼。解决思路有两种:要么给慢节点调低并发、单独设置超时时间,让它的单位时间任务分配和实际能力匹配;要么把任务按域名或URL规则分段,不同节点订阅不同的Redis队列,靠路由规则而非抢占逻辑来分配任务。
第二是结果落库的顺序和单机版本完全不同。单机版抓取顺序相对规律,分布式下Item的产出顺序是随机的。如果你的存储逻辑对顺序敏感,比如一对多的详情页和列表页,存在库里的引用关系就会错乱。我的处理方式是在Item里补充抓取时间和来源任务标识,存储端自行处理替代关系,完全不依赖数据到达先后。
4.3 Redis内存和队列堆积是生产环境最大的隐形炸弹
我见过有人把分布式爬虫跑了一个月,某天突然发现Redis内存占用90%以上,所有写入开始报OOM。打开一看,除了队列和指纹,还有一堆忘了清理的临时key和旧任务队列。这个隐患很容易被忽视,因为开发环境数据量小,根本看不出来。
我的日常习惯是三件事:其一,给所有Redis key设置合理的过期时间,种子队列抓完就删,临时数据至少设定TTL;其二,部署一个定时清理脚本,把不再使用的队列key、重复任务产生的备份key定期删除;其三,用redis-cli配置好maxmemory并设置监控告警,内存达到70%就通知值班人员去清理。否则分布式爬虫跑得越久,Redis里面存的历史垃圾就越多,最后整个集群的性能都会被拖垮。
关于队列堆积也有一个经验:抓取速度不是越快越好。如果Storage端的写入能力跟不上,队列里积压的数据会越来越多,最终造成Redis内存撑爆或者磁盘排队。理想状态是抓取速度和消费速度保持动态平衡。这通常需要在上游做好限速,在下游优化批量写入,而不是盲目加节点把吞吐硬拉上去。
4.4 数据库连接池、代理池和全局限速:容易被忽略的隐性瓶颈
分布式改造完成后,我第一次跑全量任务就发现数据库连接数直接爆了。Scrapy单机版时只有一台机器连着MySQL,连接池默认几个连接就够用;变成三节点之后,每个节点都开了连接池,连接数一下翻了好几倍,数据库日志里全是too many connections。解决办法很直接:把连接池上限调大一点是一方面,更关键的是在Pipeline里做批量攒批写入,减少连接占用频率,而不是让每个节点都频繁地开新连接。
代理池也是一个容易被忽略的点。多节点各自维护一套代理池,会导致同一个代理被多个节点同时使用,违规风险很高。需要把代理池也做成一个公共模块,放在Redis或者独立服务里统一管理,节点用到时候就去公共池借IP,用完归还,这样整个集群的代理使用状况才能被统一掌控。
最后是全局限速问题。很多刚从单机迁移到分布式的团队,给每个节点都配置DOWNLOAD_DELAY=1秒,觉得每台机器都很客气。可如果集群有十台机器,那目标站点实际感受到的就是每秒十个请求,压力一点不小。我建议在全局限速上加一个Redis计数器或令牌桶,让整个集群共享一个速率上限。这样既保证了抓取效率,又不会因为节点增多而突然“折磨”目标站点。
5. 最后的经验:分布式爬虫不是银弹,怎么做才稳
5.1 先排查单机问题,再想要不要加机器
分布式改造解决的是“任务量大到单机吞吐不够”的问题,但很多团队其实根本不是吞吐不够,而是单机配置不合理。比如并发数设置过低、Downloader中间件加了太多耗时的逻辑、解析函数里做了大量同步IO、或者目标站点的反爬策略根本没有针对性处理。这些问题在单机上解决掉,很多任务根本用不着上分布式。
我通常建议做一次“单机极限测试”:把并发调到64或128,开启连接复用,去掉不必要的时间等待,观察吞吐量到底能到多少。如果单机优化后吞吐翻了三倍,并且离任务窗口要求已经很近,那优先买单机升级而不是上分布式。如果优化后仍然差一个数量级,那再考虑架构层面的改造。这个顺序反了会很痛苦,因为你会在调试分布式集群的各种故障时发现,根因不过是某个节点的代理配置有问题。
5.2 摸清目标站点的规则,才不会被网络波动教育
做任何采集项目,都必须先理解目标站点的robot限制和访问频率约束。这不是空话,而是工程上的硬约束。分布式爬虫把多台机器的流量叠加起来,如果不加全局限速,极容易给目标站点造成明显压力,进而触发更高级的访问控制,最后整个集群的抓取效率反而暴跌。
针对不同的站点行为,我会把节点策略拆开。有的站点对同一个IP的访问频率很敏感,那我就让每个节点配合代理池轮换IP,同时将全局限速压低;有的站点页面量大但没有严格频率限制,那就可以在保证伦理和合规的前提下把并发拉高,用Redis队列做削峰填谷。目的很朴素:在不对目标站点造成大压力的前提下,完成自己合法的数据采集任务。
5.3 后续演进方向:从scrapy-redis到更重的采集架构
当你把scrapy-redis这套跑稳之后,大概率会面临下一步选择。如果你只是接一个小型业务,单队列单Redis已经够用。但如果任务规模继续膨胀,或者业务方希望做到更细粒度控制,那么可以考虑把Redis队列替换为Kafka这类消息中间件,做真正的异步消息流。
Kafka方案下,调度、去重、抓取、解析、存储各个环节可以拆成独立服务。优点是吞吐上限更高,消息回溯方便,失败重试和审计也更清晰;缺点是运维成本明显增加,代码复杂度上升,不是所有团队都需要。我个人经历是,先靠scrapy-redis顶住了四五百万级的日抓取量,后面才在架构升级时引入消息队列,把爬虫、解析、存储分成不同的数据管道。
这里还有一个实际建议:不管用哪种方案,最好把“种子URL的维护”和“任务状态的监控”纳入版本控制和自动化运维。种子队列的key、清理脚本、重启后的恢复步骤,都应该写成文档或者脚本。分布式爬虫最怕的不是跑得慢,而是某一天节点全挂了以后,你站在键盘前不知道该怎么恢复。
5.4 我实际用下来最有帮助的几个习惯
最后分享几个我在多次分布式爬虫项目里沉淀下来的小习惯,它们未必记录在任何官方文档里,但对我自己排查问题非常有效:
- 给每个爬虫分配独立的Redis数据库编号或key前缀,避免不同任务之间互相污染。
- 定期用
SCAN命令代替KEYS去遍历Redis key,生产环境用KEYS会让Redis短暂卡顿,量小没事,量大就是事故。 - 在Item里增加一个统一的“抓取唯一标识”,用于后续判断数据是否重复和缺失,这比在数据库里再做一层去重更有效率。
- 日志集中采集,多个节点最好把Scrapy日志统一推到同一个日志平台,否则排查问题时要在好几台机器之间来回翻文件,非常痛苦。
- 每次改完配置后,先在一台节点上验证再推全部节点,避免因为一个不兼容的依赖导致集群整体启动失败。
我自己最大的感受是,分布式改造真正难的并不是代码,而是对任务模型、数据量级和目标站点行为的清晰预估。把Redis看紧了,把去重逻辑想透了,再多的节点也只是数量上的叠加,不会带来质的麻烦。如果你也正准备给自己的Scrapy项目做分布式化,希望这篇文章能让你少走一些弯路。