Scrapy分布式爬虫实战:基于Redis的调度、去重与断点续爬
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机器上的爬虫抓过了一万条URLB机器完全不知道这就是单个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://:yourpassword10.0.0.12:6379/0 # 如果想看抓取结果先进Redis再落到数据库可以临时开启RedisPipeline ITEM_PIPELINES { scrapy_redis.pipelines.RedisPipeline: 300, }这里面最关键的两个配置是SCHEDULER和DUPEFILTER_CLASS。第一行把调度器从本地队列切到Redis队列第二行把去重逻辑从进程内集合切到Redis集合。REDIS_URL必须保证所有节点都能访问如果防火墙挡着整个集群会在启动后一直空转这个坑我后面还会提到。SCHEDULER_PERSISTTrue的含义是爬虫收到正常关闭信号时不清空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(urlresponse.urljoin(next_page), callbackself.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_PERSISTTrue确实保留了队列和指纹不过我第一次“断点续爬”时遇到了一个莫名其妙的坑。任务跑了一半我用CtrlC把进程停掉然后重启节点发现新放进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_DELAY1秒觉得每台机器都很客气。可如果集群有十台机器那目标站点实际感受到的就是每秒十个请求压力一点不小。我建议在全局限速上加一个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项目做分布式化希望这篇文章能让你少走一些弯路。