Scrapy爬虫分布式改造:基于Redis调度的架构实践与踩坑指南
做了几年采集方向的开发我发现自己大部分时间都在跟同一个问题较劲不是抓不到而是抓不完。早期用 Scrapy 框架写单机爬虫并发调高之后 CPU、内存都还好队列却越积越长——等到第二天早上几十万条 URL 还排着队那种挫败感很真实。后来我把项目改造成基于 Redis 调度的分布式爬虫用几台主机同时消费同一个请求队列整体吞吐量近乎线性翻了倍而代码改动量比预想中小得多。这篇内容把我实际改造过程中的调度原理、部署细节、踩坑点一次讲透适合已经用过 Scrapy、但还没把项目拆成多机跑的朋友。1. 从单机阻塞到分布式架构我踩过的调度瓶颈1.1 单机 Scrapy 为什么慢——问题不在抓取速度在排队很多第一次接触 Scrapy 的朋友有个误解觉得慢就是抓取速度慢于是拼命调高CONCURRENT_REQUESTS。实际上单机环境下Scrapy 是典型的异步事件循环模型它在同一时刻可以发出大量并发请求而每个请求的大部分时间都花在等待响应上。真正限制吞吐量的反而是一台机器到目标站点的链路质量、DNS 解析速度、连接复用效率以及进程内请求队列的处理节奏。我用一个很直观的例子说明。假设单个请求从发出到收到响应需要 300 毫秒CONCURRENT_REQUESTS 32理想情况下每秒大约能处理 100 多个请求。听起来不错但如果目标 URL 总量是 50 万条单机即便稳定跑满也需要接近两个小时。如果中途网络抖动、超时重试、某段 IP 速率被限制实际耗时可能翻倍。问题不出在抓得快不快而是所有待抓请求都堆在一个内存队列里爬虫进程只能一头扎进队列抓到天荒地老。单机的另一个隐性瓶颈是内存。Scrapy 默认把待处理请求放在内存的优先级队列里几十万条 Request 对象、它们的 headers、meta、回调函数引用会占掉可观的内存。我印象最深的一次某次任务塞了上百万条 URL进程 RSS 直接冲到 6GB 多最后只能分批导入。这些经历让我意识到单机爬虫早期死在内存上的情况远比死在 CPU 上的多。把队列从进程内存搬到外部队列是分布式要解决的第一件事。1.2 分布式并非银弹什么时候真的需要拆先说结论如果你的目标只是几千条、一两万条公开页面我不建议上分布式。单机 Scrapy 把并发调合理、DOWNLOAD_DELAY控制好已经完全够用。分布式会引入 Redis 部署、多机运维、日志聚合、指纹共享、故障恢复这些问题为了多抓几万条数据就把复杂度拉上去不值得。我自己判断是否需要分布式的标准有三条。第一目标 URL 数量是否达到百万量级附近单机内存已经很难容纳完整待抓队列第二时效性要求是否达到小时级比如每天凌晨必须抓完前一天的全量公开数据晚了业务那边就拿不到当天的统计第三是否已经有稳定的内网主机资源可以横向扩展而不是只有一台开发机。只要上面三条里命中两条再考虑拆分布式。另一个容易被忽略的判断维度是去重规模。分布式场景下指纹去重要做到所有 worker 共享这意味着去重集合也会跟着 URL 规模线性增长。如果你抓的是几百万个页面去重集合本身就可能占用几个 GB 的 Redis 内存。这些成本都要提前算进去不能只看请求吞吐。我的经验是先写一个单机版本跑一小批数据用scrapy crawl的日志观察积压趋势如果队列越跑越长再把改造提上日程。1.3 选型理由为什么我用 Scrapy-Redis 做粘合层分布式爬虫的架构方案不少我见过有人用 Celery 把请求拆成任务丢给 worker也有人完全不用 Scrapy直接用 Redis 加线程池手搓一套。但我的选择始终是 Scrapy 加 scrapy-redis 这套组合理由是它把分布式最关键的三件事做了封装请求队列从内存换到 Redis、指纹去重从单机内存换到 Redis、启动 URL 从代码里的 start_urls 换成 Redis 里的共享列表。这带来的好处是爬虫本身的代码结构几乎没有变化你只需要在settings.py里替换调度器和去重过滤器再把start_urls的生成逻辑改成从 Redis 读取即可。相比自己写一套分布式调度省去了处理优先级、指纹存储、断点续跑这些底层细节的精力。当然 scrapy-redis 不是万能的它没有内置请求确认机制worker 突然宕机确实会丢少量请求这个我在后面专门讲。选它不是因为完美而是因为改动最小、文档多、社区里的人踩过的坑你基本都能搜到。2. 环境搭建与依赖组合三台主机拉通的全过程2.1 基础环境规划我的标准部署是一主两从一台主机跑 Redis负责调度和共享状态两台 worker 跑 Scrapy负责真正执行抓取和解析。Redis 主机不需要多强的 CPU内存才是关键建议给够 8GB 以上因为指纹集合和请求队列都住在里面。worker 反而是 IO 密集角色CPU 四核往上就够内存给 4GB 也绰绰有余。Python 环境我统一用venv管理避免系统 Python 被装乱。依赖核心是三件scrapy、scrapy-redis、redis。版本上我没有用最新版强迫症只要 Python 版本兼容、代码能正常 import 就行跑生产环境多一事不如少一事。注意scrapy-redis这个库已经很久没有大更新了它对新版 Scrapy 的兼容性偶尔会有点小摩擦装完第一件事就是跑一个最小 demo 验证调度器能不能正常启动。依赖版本经验组件建议版本区间原因Python3.9 到 3.11各依赖兼容性最稳Scrapy2.8.x 往上异步机制成熟中间件 API 稳定scrapy-redis保持默认安装即可核心逻辑稳定不必追新Redis6.x 或 7.x数据结构命令兼容性好安装依赖的命令很简单但每台 worker 都要做一遍。我习惯把依赖写进requirements.txt在主机上一次解决然后用pip freeze requirements.txt导出完整版本传到另外两台机器上统一安装。这个细节能避免因为版本不一致导致的玄学问题。2.2 Redis 的部署与只跑一次的初始化Redis 的部署看着简单实际有几个坑。首先是配置绝不能为了省事直接bind 0.0.0.0然后裸奔到公网这相当于把自己的调度中心暴露给全世界。我实际用的是内网 IP 绑定配合防火墙规则只允许 worker 网段访问同时开启requirepass设置访问密码。即使是内网密码这层也不能省这句提醒是拿惨痛教训换的。Redis 配置里我还会打开持久化尤其是appendonly yes。做分布式爬虫时Redis 里存的不只是请求队列还包括已经抓过的 URL 指纹一旦 Redis 重启丢了数据worker 端去重失效很容易造成大规模重复抓取。开启 AOF 之后即使主机重启队列和指纹都能恢复代价是写入性能略有下降但对爬虫这种场景来说正确性比性能重要得多。初始化本身只需要在 Redis 主机上执行一次。先确认redis-cli ping能返回PONG再用redis-cli设置密码和基本参数。数据层面lpush一批起始 URL 到约定的 key 里worker 的REDIS_START_URLS_KEY就会自动读取这些 URL。注意一个细节如果SCHEDULER_FLUSH_ON_START默认开启worker 启动时会清空 Redis 里的去重集合和队列所以推送 URL 一定要先于 worker 启动或者明确知道清空逻辑之后再做否则容易出现刚推完就被清掉的尴尬。2.3 Scrapy 项目的关键配置真正把单机爬虫变成分布式爬虫核心就在settings.py里几行配置。我把最关键的配置项列出来直接抄到项目里就能生效# 调度器替换为基于 Redis 的调度器 SCHEDULER scrapy_redis.scheduler.Scheduler # 去重过滤器换成基于 Redis 的指纹去重 DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter # 爬虫结束后不自动清空队列保证断点续跑 SCHEDULER_PERSIST True # Redis 连接信息 REDIS_HOST 10.0.0.2 REDIS_PORT 6379 REDIS_PARAMS {password: yourpassword} # 从 Redis 中读取 start_urls 的 key REDIS_START_URLS_KEY %(name)s:start_urls REDIS_START_URLS_AS_SET False # 队列类型使用优先级队列 SCHEDULER_QUEUE_CLASS scrapy_redis.queue.SpiderPriorityQueueSCHEDULER_PERSIST True这一行尤其重要。默认情况下爬虫正常结束时 scrapy-redis 会清空本次运行相关的队列和指纹集合设为 True 后即使所有 worker 都空闲退出Redis 里的请求队列和指纹依然保留下次启动可以接着跑。对几十万级的任务来说这个配置决定了你是否有后悔药可吃。REDIS_START_URLS_KEY里的%(name)s是动态替换的对应你定义 spider 的name属性。这让我可以在一个项目里同时维护多个爬虫而互不干扰每个爬虫维护自己的队列 key 和去重 key。2.4 替换调度器后请求入队的直观理解单机 Scrapy 里start_requests生成的请求会进入调度器的内存队列然后由下载中间件逐个发出。改造后启动 URL 是从 Redis 里读的所有新生成的请求也都push回 Redis 队列worker 上的调度器则负责pop下一条。可以这样理解原来的队列是每个进程私有的改造成共享队列之后每个 worker 变成队列的消费者。谁空闲谁就从 Redis 里取任务取得快就干得多取得慢就干得少天然实现了负载均衡。这里不需要手动分配 URL 段也不用担心两台机器同时抓同一个页面调度器的原子指令会避免冲突。换完调度器之后我发现 spider 代码里start_requests方法可以保留也可以删掉改用 Redis 里预置的 URL。实际操作里我一般留一个空的start_requests然后通过REDIS_START_URLS_KEY控制入口这样后续新增一批待抓 URL只需要往 Redis 里 push连重新部署爬虫都不需要。3. 核心机制拆解请求调度、指纹去重与队列消费怎么配合3.1 调度器从内存队列到 Redis 队列发生了什么Scrapy 默认调度器维护的是一个进程内的优先级队列请求对象按优先级排序优先级高的先出队。而 scrapy-redis 的调度器把push和pop操作映射到了 Redis 的队列数据结构上。我用的是SpiderPriorityQueue内部是 Redis 的 ZSet分数对应请求优先级也可以换成SpiderQueue内部是简单的 List先入先出。这个替换带来了一个本质变化队列不再属于某个进程而成为所有 worker 共享的状态。任何一台 worker 抓取过程中生成了新的请求都会立刻被其他空闲的 worker 发现并消费。这正是分布式扩展的关键——请求的生产者和消费者完全解耦了你加一台 worker就能多一个消费者吞吐量自然跟着上去。调度器的next_request逻辑每执行一次都会先从 Redis 里pop一个请求enqueue_request则是先把请求指纹写入去重集合再决定要不要把它放入队列。这里有个值得注意的顺序指纹检查和入队是一个整体动作。指纹已存在就直接丢弃不存在则写入指纹并入队。因为 Redis 单线程执行命令这一连串判断在并发场景下也不会出现两个 worker 放进去同一个请求的情况。3.2 指纹去重用 BloomFilter 代替 Set 之后的收益与风险分布式爬虫最容易被忽略的资源消耗其实就是去重指纹。默认RFPDupeFilter会在 Redis 里维护一个 Set每个请求的指纹是 SHA1 摘要的十六进制形式大约 40 字节。100 万个请求就有 40MB 左右的开销千万级请求那就是 400MB还没算 Redis 本身的数据结构开销。任务规模一大去重集合占的内存甚至会比请求队列还多。有个很自然的优化思路是换成 BloomFilter也就是布隆过滤器。它的核心特点是用远小于原始指纹的内存表示这个元素是否出现过代价是有一定误判率。布隆过滤器只会把没见过的请求误判为见过导致极少数新 URL 被过滤掉但反过来已见过的请求绝不会被漏掉。对爬虫这种场景宁丢不用少用还算能接受但你不能不评估这个误判。我实现过一版基于布隆过滤器的去重过滤器继承RFPDupeFilter重写请求指纹的存储方式。误判率设在 0.1%内存相比 Set 大概省了一个量级。实际跑下来损失确实存在几十万个 URL 里会有几百个被误判丢弃但如果你的目标页面总量足够大这点损失可以忽略。需要提醒的是布隆过滤器无法删除元素任务结束后 key 不会因为去重集合清空而缩小除非删掉整个 key 重建这一点在设计长期运行任务时要考虑清楚。3.3 消费者竞争为什么多爬虫同时跑不重复抓多台 worker 同时从同一个 Redis 队列里取任务会不会两个 worker 拿到同一个请求在 Redis 的RPOPLPUSH、BRPOPLPUSH之类的命令里因为命令本身是原子的同一时刻只可能有一个消费者取走一个元素。scrapy-redis 默认用的是POP类操作同样具备原子性所以两个 worker 不会同时拿到同一个队列元素这是第一层保障。第二层保障是指纹去重。当一个请求被某个 worker 抓完并生成新的请求后新的请求在入队前会先做指纹判断已经存在于去重集合中的请求就不会再次入队。所以即便因为某种异常导致同一个请求被两次入队真正抓取前调度器也会通过指纹过滤掉一次这个双重机制降低了重复抓取的概率。不过这里有个容易掉坑的细节scrapy-redis 的默认去重发生在请求入队阶段不是响应回来之后的阶段。假如一个 worker 从队列里pop了请求 A然后网络卡了很久这时候另一个地方又有人往队列里塞了同样的请求 A由于指纹在第一次入队时已经写入第二次入队会被丢弃。但第一台 worker 手里的请求 A 已经开始下载了。这种情况下不会重复抓因为同一请求只有第一次入队时写了指纹。我的经验是只要坚持往 Redis 里推 URL 之前先走一次统一的指纹校验基本不会出现重复消费。3.4 状态统计与爬取关闭流程分布式环境下一旦跑起来最需要关注的是整体进度。scrapy-redis 会在 Redis 里维护一些运行时状态但我更多依赖 Scrapy 自带的统计框架。启动 worker 时加-s STATS_CLASSscrapy_redis.stats.RedisStatsCollector就能把抓取量、请求数、异常数汇总到 Redis。想看实时进度直接redis-cli查看对应 key 的值比翻日志高效得多。关闭流程也跟单机不一样。单机 Scrapy 跑完一个 spider 就结束进程分布式模式下一个 worker 把队列抓空了会进入空闲等待但不会轻易退出因为其他 worker 可能随时往里塞新请求。这意味着你要么手动发送停止信号要么设置空闲超时自动关闭。我实际操作中采用了一个折中方案用CLOSESPIDER_TIMEOUT设置最长运行时间到点强制关闭 worker再用定时任务检查 Redis 队列为空后重启新一轮。还要记得确认SCHEDULER_PERSIST True否则爬虫关闭时整个调度队列和指纹集合会被清掉下次启动就算往 Redis 里重新推 URL去重集合却已经被重置之前抓过的 URL 会再抓一遍。这个状态在分布式环境下的影响会被放大因为只要有一台 worker 正常退出触发清理其他 worker 的去重依据也会一起丢失。4. 分布式部署与运行脚本、监控和故障恢复4.1 启动顺序与一键脚本分布式部署的第一步永远是启动 Redis然后才是 worker。我第一次改造时习惯性地先在两台机器上同时启动爬虫结果因为 Redis 还没就绪worker 全部启动失败堆了一堆连接报错。后来我把启动顺序写进脚本杜绝手误。一个简单的 worker 启动脚本可以这样写#!/bin/bash # worker 启动脚本 cd /opt/spider_project source venv/bin/activate for i in 1 2 3; do nohup scrapy crawl myspider \ --logfile logs/worker_$i.log \ /dev/null 21 echo worker_$i started, pid: $! done每台 worker 上跑一遍这个脚本会启动三个爬虫进程。注意一个问题worker 上的并发数是累加的。如果每台 worker 的CONCURRENT_REQUESTS是 32三台总共就是 96对目标站点的请求频率会明显上升。所以要把全局请求速率当作一个整体来设计而不是单纯看单机配置。我会先在单机上跑一小段时间统计每分钟请求数再乘以 worker 数量评估是否超过了合理范围。4.2 节点宕机后的数据安全分布式系统绕不开故障处理。我先说最不乐观的情况一台 worker 在从 Redis 队列中取出请求后、还没完成下载时突然宕机了。因为这个请求已经从队列里pop出来且指纹已经写入去重集合它既不会重新出现在队列里也会因为指纹已经存在而无法再次入队。简而言之这个请求丢了。这个缺陷是架构层面带进来的scrapy-redis 没有原生的 ACK 确认机制。我的应对策略分几层第一层对已经抓取的数据落库或者写入消息队列的时候基于业务主键做二次去重这样即便页面抓重复了数据层也不会重复第二层在 Redis 里保留一份原始 URL 全集的备份也就是最初推入队列的批次数据万一某批请求丢失可以手动把剩余部分重新推入队列第三层worker 宕机后立刻检查日志看是否是因为某个 URL 触发了异常导致进程退出先把“毒丸”请求找到并剔除再说。宕机后恢复的顺序我也总结出了固定套路先重启 Redis如果它没挂再检查各 key 的队列长度再逐台启动 worker最后用统计信息核对整体进度。不要在弄清楚丢失范围之前就盲目重推 URL否则很容易把已经抓过的 URL 又灌回队列造成大量重复请求。4.3 监控队列积压和爬取速率分布式跑起来之后我每天都在看三个数字队列积压量、处理速率、错误率。看队列积压量最简单直接对 Redis 里的请求 key 执行llen例如redis-cli -h 10.0.0.2 -a yourpassword llen myspider:requests这个数字会告诉你当前有多少请求还在等待处理。如果队列只减不增说明 worker 的消费能力大于产生速度如果积压长时间不下降就该检查是不是某个环节阻塞了。处理速率我会周期性地采样llen的差值比如每隔五分钟记录一次根据差值计算出每分钟消费的数量再乘上平均页面大小估算数据产出。错误率我主要看 worker 日志里的ERROR行数。Scrapy 的日志已经比较结构化把四个 worker 的日志用grep按小时统计就能看出是某个站点开始拒绝请求了还是整体网络出了问题。监控的意义不是让你在故障发生后才看到而是让你在故障发生前发现趋势。队列积压从 5 万降到 3 万又反弹到 4 万这时候就要注意而不是等到 Redis 内存被打满才去救火。4.4 常见故障连接重置、重复消费、Redis 内存暴涨我实际跑分布式的过程中碰到最多的问题有三个。第一个是连接重置报错通常是ConnectionResetError或者描述为远程主机强制关闭连接。这个问题的根源九成是请求频率过高触发了目标站点的访问限制。解决方式不是把中间件重试调大而是先降低整体请求速率让AutoThrottle或者手动DOWNLOAD_DELAY生效再观察错误率是否下降。第二个是重复消费的错觉。有时候日志里会看到两个 worker 似乎同时在抓同一个 URL但这不一定真的是重复消费更可能是同一个 URL 被多个批次反复推入了队列而指纹因为某次清理已经失效。我会先查指纹 key 里是否有这个 URL 的指纹再决定是修正入队逻辑还是重置去重集合。不要动不动就重启全部 worker重启只会让指纹集合变得更不可控。第三个是 Redis 内存暴涨。最常见的原因是去重集合太大加上队列积压过深两者叠加把内存吃满。我处理过最极端的一次去重集合占了 Redis 总内存的 70%队列只占一小部分。解决办法是给 Redis 设置合理的maxmemory和淘汰策略同时对已经完成的大批次任务定期清理去重集合或者干脆把不同批次放到不同 key 下结束后单独删除。5. 合规意识与公开数据抓取的底线5.1 robots 协议与访问频率控制聊到爬虫我不能回避一个基础问题抓取行为必须在合理边界内。我接手任何采集项目都会先看目标站点的 robots 协议Scrapy 里可以通过ROBOTSTXT_OBEY True让它自动遵守。有人觉得 robots 只是君子协议但实际项目里遵循它既是基本原则也是降低自身风险的方式。对允许抓取的部分也依然要控制频率不要因为分布式就肆无忌惮地并发。控制频率的核心是全局而不是单机。分布式环境下的访问频率是各 worker 之和所以我习惯设置一个全局的DOWNLOAD_DELAY再配合RANDOMIZE_DOWNLOAD_DELAY True让请求间隔有一定随机性。这样能有效降低访问特征过于规律带来的负面影响。还要注意同一个站点尽量不要让多个 worker 并发打过去可以把站点域名划分给特定 worker或者用统一的调度策略控制域名级的并发数。5.2 数据使用边界与公开信息判断分布式爬虫本质上是把公开数据的获取过程自动化了但公开可见不等于可以任意使用。我的原则是只采集目标站点明确公开、不需要登录、不需要绕过任何访问控制的页面数据不采集个人隐私信息不抓取需要付费或者受权限保护的内容。虽然技术上可能做得到但这条底线不能碰。在抓取之前我会先评估数据的性质这是站方公开宣传的信息还是用户在私密场景下产生的数据公开宣传类数据比如一个站点的公开新闻列表、公开产品目录、公开文档说明相对稳妥用户私密数据、未公开接口的数据一律不做。这个判断要在项目启动前完成而不是抓了一半才想起来评估。采集完之后的数据存储、脱敏、使用范围也要一并考虑清楚。5.3 请求身份标识与日志留痕分布式爬虫的另一个习惯是让所有请求的身份清晰可辨。我在settings.py里设置明确的USER_AGENT写清楚这是何种采集程序还会在请求 headers 里带上能够标识任务来源的信息。这样做的原因很简单如果目标站点有疑问至少能通过日志追溯到任务来源而不是被当成匿名的恶意流量。同时我保留了完整的日志体系。每个 worker 的访问日志、错误日志、异常详情都会落到独立的文件里按日期滚动。一旦出现访问异常我能快速定位是哪个 worker、哪个 URL、什么时间点出了问题。日志不是给别人看的它首先是排查问题的第一手资料。分布式环境下没有单机调试那么方便清晰的日志比口头描述可靠得多。5.4 留退路限速、退避与错误处理最后说说留退路这件事。分布式爬虫跑起来以后我不会把所有 worker 都调到极限而是会预留 20% 左右的余量。遇到目标站点返回 429、502 这类状态码时第一时间不是重试而是先停下来用退避策略等待一段时间再继续。Scrapy 的RetryMiddleware可以配置重试次数但重试次数我通常不会超过 3 次避免反复给对端压力。更稳妥的方式是给每个站点设置独立的限速规则。有的页面更新频率低不需要频繁访问有的页面本身变化快可以允许相对密集的抓取。我会在中间件里维护一张按域名的延迟表动态调整不同站点的请求间隔。分布式环境下这种精细控制的复杂度虽然上升了但换来的是更可持续的运行状态不会出现头一天跑得飞快、第二天被限制访问的情况。把调度器交给 Redis 之后我日常的精力分配发生了挺明显的变化大部分时间不再盯着抓取代码有没有 bug而是盯着队列深度、指纹集合大小和日志错误率。有一次任务积压到三十万三台 worker 轮流消费指纹集合稳定增长错误率一直压在低位那一刻我心里是踏实的。这套架构跑顺之后遇到新的采集需求反而会先问一句单机真的跑不动了吗如果只是验证想法一台机器加几个并发完全够但如果你的目标是小时效内处理几十万甚至百万级公开页面共享队列的路子是目前性价比最高的方案之一。过程中记得守住 robots 和访问频率的底线执行这些爬虫时可持续远比短跑重要。