资讯动态

Scrapy+Redis分布式爬虫架构设计与实战

发布时间:2026/10/17 17:03:03 来源:尧图企业网站定制
先说一个大家都遇到过的问题很多人手里有一堆代理IP、几百个URL要抓但单机Scrapy跑起来又慢又容易挂数据库压力也扛不住。折腾了一周之后我最终把架构改成了Scrapy Redis的分布式方案把之前平均每小时只能处理几万条请求的速度提升了一个数量级。这篇文章不讲大而全的框架理论只说我实际踩坑、反复调优之后的方案和细节希望能帮到正在做同样事情的人。做分布式爬虫之前我建议你先冷静评估一下你真的需要分布式吗如果只是几千个URL、对时效性要求不高单机加Scrapy的多线程完全够用。分布式不是锦上添花而是实打实的运维成本和调试成本。但如果你要抓的是百万级以上的公开页面并且需要在几个小时内完成一轮全量更新那分布式就从可选项变成了必选项。接下来我完整拆解这套方案。1. 分布式爬虫的整体架构与设计思路1.1 为什么单机Scrapy撑不住大规模抓取在动手改造之前得先理解瓶颈在哪。单机Scrapy的请求调度是进程内完成的——请求对象经过Scheduler放进队列Downloader取出来执行Response交回Spider解析。这个流程本身没问题但有几个硬约束单机的并发数受限于CPU核心数和内存大小常规配置下开个32并发就顶天了再往上加会导致超时率飙升。内存队列的大小有限几百万个待抓取URL会直接撑爆内存。即便可以通过磁盘队列缓解调度性能也会剧烈下降。如果正常页面采集过程中断内存队列里的URL全部丢失只能从头再来。这个我遇到过太多次。所以核心问题不是“跑得不够快”而是“撑不住多大规模”。分布式架构的本质是把单机的内存队列和调度逻辑抽离出来放到一个所有节点共享的外部存储中——实战里我选的就是Redis。1.2 架构模型与核心组件划分这套架构的角色分配很清晰Redis作为中心调度枢纽每个Scrapy实例作为工作节点从Redis取URL、执行抓取、回传数据。细节拆解如下Redis承担所有URL的存储与分发职责。我用两个集合来模拟队列状态——一个是待抓取队列一个是已抓取集合。核心是“待抓取的URL构造成有序集合节点通过阻塞弹出获取任务已抓取的URL指纹写入去重集合”。工作节点每个节点就是一台服务器上跑的Scrapy实例。它们不存任务状态只从Redis获取任务抓完后把解析结果发给数据库然后把新的URL回填到Redis。数据库层我使用MySQL做结构化存储用Blob字段存原HTML和JSON原始结果。你也可以根据数据形态选择MongoDB之类。监控与调度外部通过管理脚本查看队列深度、节点心跳和抓取进度。这个模型的关键好处是节点无状态。任何一个节点宕机其他节点自动分摊剩余任务不需要人工干预。我后面在压测时故意杀掉一个节点做了故障验证队列里的任务确实会被其他节点取走不会卡死。1.3 技术选型为什么是Redis在架构设计时我对比过三套方案Redis、RabbitMQ、Kafka。在MQ场景下Scrapy的调度器要重新实现和MQ深度交互的接口原有的基于内存队列的诸多特性如优先级、调度策略都要被替换模块重写工作量巨大在Kafka场景下虽然吞吐量极高但原生不支持集合去重需要对消息做二次处理和消费确认而且重放机制反而会引入重复抓取。最终我选择了Redis理由非常务实Scrapy社区已有成熟组件比如scrapy-redis改造成本极低。Redis的有序集合天然支持按优先级取任务可以控制某些重要页面的优先抓取。Redis的集合操作做URL去重性能极高。Redis的内存存储能支撑几百万条待抓取URL单机4GB内存就能跑得很稳。如果你对可用性要求极高可以主从部署Redis。我在实际项目中就用一主一从故障自动切换数据几乎零丢失。2. 核心细节改造Scrapy的关键步骤2.1 改造调度器与去重模块这一步是整个改造中技术密度最高的地方千万别想着用补丁程序修修补补直接替换Scheduler和Dupefilter才是正解。我需要重写两个核心类新增一个基于Redis的Scheduler类替代默认的Scheduler。关键在于把enqueue_request方法的逻辑改写为直接把请求对象序列化后写入Redis的指定列表。实现上我选择将所有请求对象在入队时就序列化处理避免在节点间传递非可序列化对象时出错。新增一个基于Redis集合的Dupefilter类替代默认的去重模块。默认去重是基于内存集合的seen单机可用但分布式下必须改成Redis集合。写入Redis的集合成员是URL的SHA1指纹。import hashlib import redis class RedisDupeFilter: def __init__(self, server, key): self.server server self.key key def request_seen(self, request): fp hashlib.sha1(request.url.encode(utf-8)).hexdigest() added self.server.sadd(self.key, fp) return added 0这段代码的逻辑是把URL哈希后写入集合如果返回0说明已经存在代表已抓过直接跳过。这里有个关键经验一定要用哈希指纹而不是URL字符串本身做去重。原因有三个一是URL可能很长存原始字符串浪费内存二是带参数的URL可能超过Redis单键值限制三是哈希后的指纹长度固定性能更快。但注意指纹相同不代表页面内容一定相同——比如某些页面URL不变但内容动态刷新这种情况要靠强缓存策略处理不在本文讨论范围。2.2 让Spider变成无状态的任务消费者改造后Spider不再自己产生初始请求而是从Redis队列中获取任务。我的做法是通过自定义Pipeline或中间件让原始请求源种子URL只负责“播种”把种子URL写入Redis队列每台工作节点启动时只从队列取URL不自行构造请求。具体的实现思路是在Spider的start_requests方法里不再yield任何请求。取而代之的是在Spider的__init__里建立起Redis连接然后在parse逻辑里通过一个自定义的RedisCrawler类从队列取URLimport redis import scrapy class RedisCrawler(scrapy.Spider): name redis_crawler def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.server redis.Redis(host127.0.0.1, port6379, db0) def start_requests(self): while True: url self.server.lpop(crawl:start_urls) if not url: break yield scrapy.Request(urlurl, callbackself.parse) def parse(self, response): # 解析页面提取新URL并回填Redis for url in self.extract_links(response): self.server.rpush(crawl:start_urls, url) # 提取数据交给pipeline处理 yield {url: response.url, title: response.xpath(//title/text()).get()}这段代码有个坑直接用while True循环同步弹出URL会导致括号内阻塞影响Twisted事件循环进而导致下载器无法并发处理多个请求。正确做法是用协程或异步方式或者干脆用scrapy-redis组件中的调度器来处理队列消费让Scrapy框架调度器去管理取任务节奏不要自己在spider里粗暴阻塞。我最终的选择是配合scrapy-redis改造但核心去重逻辑还是自己写的相互不冲突。2.3 数据导出的稳定性设计解析结果我们不能直接在各节点上写MySQL否则高并发下数据库会崩。合理方案是每个节点把结果推送到同一台MQ或独立的数据队列中再由专门的进程批量写库。我用的是Redis的列表做数据缓冲消费者进程批量取数据、批处理写库速率控制小于节点抓取速度保证数据库不出现大事务阻塞。具体实现每个爬虫节点解析完成后只做一件事情——把结构化数据以JSON字符串形式通过rpush推入Redis的data:items列表。数据入库进程监听这个队列攒够200条或每5秒批量插入一次效果非常好。数据文件我还会同时写一份本地JSON Lines文件作为备份防止数据库写入失败时数据丢失。3. 实操过程从代码到部署的完整记录3.1 安装与环境准备我不再重复官方文档的安装步骤只说必要项。我的工作环境是CentOS 7.9Python 3.8Scrapy 2.5.1redis-py 3.5.3MySQL 8.0。需要特别注意的是Scrapy的版本需要和scrapy-redis兼容。我踩过坑scrapy-redis 0.14版本配合Scrapy 2.x会报依赖错误换用1.0.0版本之后就正常了。另外redis-py版本不要太新否则有些老接口会不兼容。3.2 改造的完整代码框架为了便于说明我把完整的项目结构列出来。这个框架我在模拟项目X中验证过多次可以直接作为参考project/ ├── scrapy.cfg ├── project/ │ ├── __init__.py │ ├── items.py │ ├── middlewares.py │ ├── pipelines.py │ ├── settings.py │ └── spiders/ │ ├── __init__.py │ └── redis_crawler.py ├── redis_utils/ │ ├── __init__.py │ ├── dupefilter.py │ └── scheduler.py ├── seeds.py └── run_crawler.shredis_utils/dupefilter.py自定义去重类。redis_utils/scheduler.py自定义调度器。这个类我从默认Scheduler改造成连接Redis的逻辑核心是入队时序列化Request对象出队时反序列化。seeds.py负责把种子URL和初始URL写入Redis队列。settings.py里关键的改动SCHEDULER redis_utils.scheduler.RedisScheduler DUPEFILTER_CLASS redis_utils.dupefilter.RedisDupeFilter SCHEDULER_PERSIST True CONCURRENT_REQUESTS 32 DOWNLOAD_DELAY 0.5SCHEDULER_PERSIST True的意思是调度器状态持久化节点重启时会从断点继续跑。这里有个比较我个人很喜欢但如果你不需要任务续跑设成False可以保证每次爬完队列清空避免长期占用Redis内存。3.3 调度器类的核心实现自定义调度器是整个改造的骨架。我基于scrapy-redis的思路重新实现了精简版核心逻辑如下import redis from scrapy.utils.reqser import request_to_dict, request_from_dict class RedisScheduler: def __init__(self, server, queue_key, dupefilter): self.server server self.queue_key queue_key self.dupefilter dupefilter def enqueue_request(self, request): if not self.dupefilter.request_seen(request): self.server.lpush(self.queue_key, request_to_dict(request)) def next_request(self): data self.server.rpop(self.queue_key) if data: return request_from_dict(eval(data), spiderNone)这段代码只是示意真实场景建议直接用pickle而不是eval否则会有严重的安全隐患。另外request_from_dict需要传spider实例我这里省略了。重点理解思路入队时先经过去重判断再去向Redis写入出队时从Redis弹出并还原成Scrapy的Request对象。在此基础上你还可以给队列加上优先级和超时重试机制。优先级我用有序集合实现请求对象先放入默认队列同时维护一个时间戳分数优先弹出分数大的请求。简单做法是给URL加一个数值前缀数值越小优先级越高但这样会让URL看起来不干净影响某些解析逻辑。所以我在实际中使用了两套队列高低优先级各一个低优先级队列的弹出频率低一些。3.4 去重模块的扩展去重模块除了URL指纹我还会对POST请求的参数做指纹。因为多个POST请求可能URL相同但参数不同默认的URL指纹会导致大量误杀。我的方案是把请求方法、URL、请求体哈希值拼在一起再做一次SHA1def request_fingerprint(self, request): parts [ request.method, request.url, hashlib.sha1(request.body).hexdigest(), ] return hashlib.sha1(:.join(parts).encode(utf-8)).hexdigest()这样同一个URL的不同参数请求就可以被精确区分。这个细节在大规模采集时非常关键细节决定成败。3.5 部署与启动分布式集群的启动我写了一个用于快速分发并启动脚本的工具利用SSH批量推送代码并发起nohup启动命令。每个节点只干一件事运行Scrapy爬虫进程。种子URL的初始化单独执行一次。python seeds.py for node in node1 node2 node3; do ssh $node cd /opt/project nohup scrapy crawl redis_crawler done在实际项目中我会用进程守护工具来监控爬虫进程挂了自动重启。比nohup靠谱得多。但如果在没有这类工具的环境下nohup加日志配合crontab定时检查也能用前提是每个人知道这个方案并不完美。3.6 监控与运维分布式爬虫跑起来之后如果不监控基本上是“裸奔”。我最少会做三件事监控Redis队列深度llen crawl:queue数值如果长期不变说明某个节点挂了或队列被消费完了。监控节点抓取速率每个节点定时向Redis写入当前抓取数量外部脚本拉取算总量。监控数据库写入速率查看data:items队列积压数量如果持续增长说明数据库写入瓶颈了。日志管理也非常重要。每个节点的日志统一写到本地文件同时通过日志采集组件汇总到中央日志平台。排查问题时直接看汇总日志比登录七八台机器挨个看舒服得多。4. 常见问题与排查技巧实录4.1 队列堵塞任务堆积但节点空闲这个是我遇到最多的问题。现象是Redis的crawl:queue里积压大量URL但所有节点都空闲抓取速率为0。排查思路先查节点日志看调度器是否还在正常拉取队列。如果next_request长时间没有返回多半是队列Key名配置不一致。用redis-cli keys crawl*确认实际存在的Key名看看是不是scheduler写入的Key和spider读取的Key不一致。注意lpush和rpop的匹配关系。我用lpush写入、rpop弹出模拟FIFO队列。如果你代码里混用了rpush和lpop问题容易失控。选一种模式写清楚不要混用。4.2 Request序列化失败Scrapy的Request对象在入队时要序列化某些情况下会失败。原因通常是回调函数无法序列化或者请求头里有不可序列化的对象。解决办法不要直接存Request对象而是只存URL、方法、参数、回调名称、优先级等基本属性。回调函数名用字符串标识在Spider内通过动态分发找到方法。这样既稳又快。4.3 去重失效同一URL被多次抓取去重失效的常见原因有三个指纹算法不同导致同一URL生成了不同的指纹或者多个节点同时请求同一个URL时Redis的sadd操作时序不同导致先到的被后到的覆盖或者节点重启后重新初始化了去重集合历史指纹丢失。我的解决方案指纹生成必须统一并集中在一个模块中用完全相同的代码。SCHEDULER_PERSIST True时去重集合也要持久化不能依赖Redis的临时Key否则Redis重启后集合数据会全部丢失。用Redis的setnx或者使用事务锁来保证同一指纹只第一次写入后续返回0。我最终选择在写入前先判断集合大小再用pipeline事务执行实测并发一千时也没有重复。4.4 反爬策略的应对分布式爬虫更像是一组独立的浏览器如果目标站点有严格的反爬机制你需要在蜘蛛层面做足功课给每个节点配置不同的User-Agent和Cookie池不要所有节点共用同一身份特征。随机化下载延迟不要所有节点固定同一间隔。我用的是0.5秒到1.5秒的随机区间。优先使用固定来源的代理IP池而不是免费代理池。免费代理稳定性差高频抓取时反而会增加失败率和指纹风险。设置合理的RetryMiddleware重试次数避免无限重试拖垮队列。这里补充一句抓取公开数据必须尊重目标站点的服务协议和版权声明遵守robots协议是底线控制频率不搞抓崩对方服务器也是基本的职业操守。好的爬虫工程师应该知道什么该抓、什么不该抓。4.5 节点故障导致的数据不一致如果节点在抓取页后、写入结果之前崩溃那这条数据就丢了。这不是严格意义上的重复问题而是“丢失”问题。我的处理方案是在数据库里建立一个临时表节点写入数据后立即回写一个Redis状态位后台程序每隔一段时间扫描临时表把状态位缺失的记录重新调度入队。这个“补偿机制”虽然增加了一定复杂度但在关键业务上非常值得做。我建议你也设计自己的补偿任务策略不要依赖“节点不会挂”的愿景。4.6 数据库写库速度跟不上当你把抓取并发调到很高时数据库写库速度会成为新的瓶颈。我遇到过把MySQL事务写满导致锁表的情况非常痛苦。解决方案有三个层级第一层把写库操作从同步改为异步节点不写库只塞队列。这层我已经做了。第二层批量写库每次插入100条左右事务快速提交。第三层分库分表。如果数据量过亿单表必然扛不住。按数据源或时间分表是最简单的方案。我用的是按月分表历史数据自动归档查询压力大幅下降。5. 实战经验与调优心得5.1 参数调优的优先级在分布式爬虫的中后期优化时我的调优顺序是去重效率大于调度吞吐量调度吞吐量大于并发数。很多初学者一上来就调并发结果把反爬触发得厉害得不偿失。先优化数据流去重命中率要低队列积压要少数据库写入要快。这三项优化完后再基于实际瓶颈加大并发一步步来。我在某个模拟项目X上从默认并发8调到了32中间经历了无数次的DNS超时和连接重置最后发现瓶颈是DNS解析库的并发限制用专门的异步DNS解析组件才彻底解决。5.2 任务优先级的实践前面提过用有序集合做优先级队列但有些熟练开发者会在积压任务量大的情况下优先抓取深度小的页面首页、目录页再抓详情页。这个策略可以对某些时效性强的业务带来很大收益。我实现的方式是每个URL入队时根据URL正则打上优先级标签数字越大优先级越高然后放入有序集合。弹出时提供带正则判断的取数逻辑。有一个小坑有序集合的score是浮点数不要用整数太稀疏否则后续插入会出问题。用Unix时间戳作为score可以自然支持时间优先。def add_task(self, url, priority): self.server.zadd(self.queue_key, {url: -priority}) def get_task(self): data self.server.zrange(self.queue_key, 0, 0) if data: self.server.zrem(self.queue_key, data[0]) return data[0]这里我用负数分数实现优先级排序优先级数值越大分数越小排序越靠前。这个方法简单稳定我一直在用。5.3 存储层的选择与优化很多项目喜欢用MongoDB存爬虫数据因为它schema灵活、扩展容易。但我个人更喜欢MySQLJSON字段的组合原因是结构清晰、事务支持好、排查方便。遇到需要全文搜索的场景再加ES同步不要为了存储而存储。大规模数据写入性能最优的是批量插入加异步提交。我提供几个实际验证的参数供你参考参数建议值说明批量大小100-200条事务提交频繁会导致性能下降刷新频率每5秒一次避免I/O频繁也避免数据积压过久重连策略每10秒重试数据库短暂故障要能自愈连接池大小5-10每个节点保持的连接不宜过多5.4 日志与错误追踪分布式环境下日志分散在各节点不统一就是灾难。建议每台节点的日志文件名包含节点标识日志格式里带时间戳、节点ID、请求URL、错误类型五个字段。排查的时候先按错误类型聚合再看具体节点定位效率快很多。我见过太多人抓取失败时只是打印一个ERROR: 500就完事了最后问题找了一整天。正确做法是至少在异常里附带目标URL、当时的请求头、响应体的前200字符、节点名称、任务ID。这五点缺一不可。6. 踩过坑之后对分布式爬虫的重新理解从单机到分布式第一步是解决“性能不够”的问题第二步是解决“稳定不够”的问题。但做到后面你会发现真正困难的是“可维护性”。我记得第一次跑十节点集群时一个节点频繁被目标站封锁导致它抓到的数据质量特别差其他节点也受连带影响。后来我才意识到节点之间不要共享出口IP池和Cookie池每个节点的身份特征要独立甚至IP段最好都不同。这个坑花了很久才排查清楚。另外不要把所有数据都堆在Redis里。Redis虽然快但它毕竟持有的是内存尤其当你想把原始HTML都放进去时内存会爆炸。我的原则是Redis只放轻量级的状态和任务描述原始数据直接交给文件存储或数据库别想着统一缓存。在这些实践基础上我的结论是分布式爬虫不是起点而是你的抓取系统发展到一定规模后不可回避的演进方向。它解决的是不确定的规模问题但前提是你得把单机版爬虫写到足够好、测试足够充分再来考虑架构升级。否则你会被分布式带来的复杂度淹没抓取效率不一定有提升反而多了几个新的瓶颈。如果你拿不准现阶段该不该上分布式我的建议是先跑单机用压力测试把单机的极限探出来再模拟三倍流量对比如果单机靠着调优还能扛就先别上分布式。等到真正需要了再按这篇文章的思路一步步改造切记不要为了用分布式而用分布式。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑