做了几年爬虫,大部分时间都在跟 Scrapy 打交道。第一次动分布式爬虫的念头,是因为单机已经明显顶不住了:目标是一个垂直电商站的商品列表页,每天要跑几百万条链接,4 核 8G 的服务器开到 32 个并发,一跑就是七八个小时,中间稍微断网或者内存被挤爆,又得从头再来。后来加了台机器,本以为要重写一套调度逻辑,结果发现 Scrapy 配合 Redis 就能把活干完,而且代码改动量远比想象中小。这篇就把我从单机迁移到分布式的完整过程和优化思路整理出来,给同样卡在这一步的人一个能直接上手的参考。文章围绕 Scrapy、Redis 和分布式爬虫这几个关键词展开,会讲清楚为什么这样组合、核心原理是什么、代码怎么改、跑了之后会踩哪些坑,以及后续可以怎么优化。
1. 为什么要从单机爬虫迁移到分布式:三个让我下决心的场景
1.1 单机架构的天花板其实不是速度,而是容错
很多入门教程会把并发数从 8 调到 32、64,感觉快了很多,就以为单机够用了。但真正让你想迁移到分布式的,不是速度,而是两个很现实的问题。
第一个是任务中断成本。单机爬虫的队列、去重集合都在内存里,进程一挂,所有状态全部丢光。Scrapy 本身有JOBDIR可以支持断点续爬,但实战中你会发现它只对单机有效,而且恢复过程比较脆弱,item 管道里的半成品数据也不会帮你处理。我第一次跑百万级任务时,第二天凌晨机器被 OOM Killer 干掉,早上起来看日志,跑了 6 个多小时的数据全废,那种感觉相信做过大爬虫的人都懂。
第二个是扩容困难。加机器很简单,但怎么让两台机器协同工作、不重复抓同一批 URL、共享抓取进度,才是核心问题。当时我仔细翻了 Scrapy 文档,发现框架本身不支持跨进程共享去重队列,两台机器各跑各的,等于把同一份工作做了两遍。这还只是两台,哪天要上五台十台,问题会更严重。
1.2 分布式爬虫到底要解决哪三件事
真正想明白了,分布式爬虫其实就解决三件事:
- 请求队列共享:所有节点从同一个队列里拿 URL,谁空闲谁取,任务自然就被分配了,不存在“分工不均”的问题。
- 去重集合共享:同一个 URL 只能被一个节点抓取一次。这就需要全局去重,而不是每台机器各去各的。
- 状态与统计共享:哪些任务爬完了、还剩多少、当前每秒请求数是多少,在所有节点上看到的是同一份数据。
这三件事,单靠 Scrapy 做不到,但 Redis 的数据结构天然适合。List 可以当队列,Set 可以做去重集合,String 可以做计数器,Pub/Sub 可以做节点间通知,Hash 可以存元数据。Scrapy 负责把每个请求变成任务,Redis 负责把这些任务在所有节点之间流转,两个东西一组合,上面说的问题就都解决了。
1.3 什么时候不该上分布式
这里必须泼一盆冷水。如果目标站点只有几万个页面,单机 Scrapy 开个并发完全能跑完,真的没有必要上分布式。分布式的代价是架构复杂度上升,光是要维护 Redis 实例、排查节点间异常、处理任务积压这些事,就够你喝一壶的。
我当时给自己定了一个判断标准:单机跑到超过 6 小时才能完成,或者任务量会持续增长且存在断点恢复需求,才考虑分布式。如果只是百八十个页面的小站点,老老实实用单机,把时间花在解析和数据处理上更值得。分布式解决的是规模问题,不是性能问题。
2. Redis 在 Scrapy 分布式里的真实角色:调度器与去重器的工作原理
2.1 先看清 scrapy-redis 组件到底替换了什么
Scrapy 原生架构里,每个 Spider 实例自带一个调度器(Scheduler)和一个去重过滤器(DupeFilter)。调度器管理待抓取队列,DupeFilter 负责判断某个 URL 是否已经抓过。这两个组件默认都存在内存里,所以多进程、多机之间完全无法共享。
scrapy-redis这个组件做的事情,说穿了就是把这俩组件换掉:调度器从 Redis 里取任务,去重过滤器把指纹写进 Redis 的 Set。这样每个节点跑起来后,拿到的都是同一个队列、同一套去重集合。Spider 的解析逻辑完全不用动,只是换一个“取号窗口”。
这也解释了为什么从单机迁到分布式时,业务代码几乎不用改。你写的 parser 还是 parser,item 还是 item,变的只是任务来源和执行环境。
2.2 Redis 的数据结构分别扮演什么角色
我用一张表总结一下 scrapy-redis 运行时 Redis 里都存了些什么,方便后面排查问题:
| Redis 数据结构 | 存储内容 | 在爬虫中的角色 |
|---|---|---|
| List | 待抓取 URL 队列 | 任务分发中心,节点从左侧或右侧取任务 |
| Set | 请求指纹集合 | 全局去重,防止同一 URL 被多节点重复抓取 |
| String | 每个 Spider 的 pending 数量、统计计数 | 节点状态和任务总量的实时指标 |
| Hash | 调度器状态等元数据 | 用于任务跟踪和故障恢复 |
其中去重指纹的生成逻辑值得多说一句。Scrapy 默认的指纹不是简单地对 URL 做 SHA1,而是把请求的方法(method)、URL、请求体(body)以及部分 meta 信息组合起来,再计算 SHA1。所以即使 URL 相同,如果 POST 的 body 不同,也会被当成不同的请求。这个机制在绝大多数场景下是合理的,但也会带来一个误区,后面我会单独讲。
2.3 节点之间是怎么“协作”的:没有主从,只有竞争
分布式爬虫最容易被误解的地方,是以为需要有个“主节点”来分配任务。实际上 scrapy-redis 的方案是去中心化的:每台机器上的 Scrapy 进程都往同一个 Redis 队列里取 URL,取到了就抓,抓完解析出新的 URL,再塞回队列。整个过程没有节点间通信,也没有任务分配协议,谁快谁多干。
这个设计的好处是极简,节点可以随时加入和退出。我实际扩容时,只需要在新机器上配好相同的REDIS_URL,然后执行scrapy crawl product_spider,它立刻就开始消费队列里的任务,不需要重启旧节点。当时第一台新机器加进来后,盯着两个终端的日志,看到不同节点在抓不同的 URL,那种感觉很爽。
缺点也很明显:节点之间没有分工概念,你没法指定某台机器只抓图片、另一台只抓详情页。而且如果 Redis 挂了,所有节点会慢慢空转等待,不会自动切换到别的存储。所以 Redis 本身的高可用在正式环境里很重要,入门阶段至少要做好持久化和定期备份。
3. 从单机到分布式的改造三步走:可直接照抄的代码级步骤
3.1 环境准备:先搞定 Redis 和 scrapy-redis 安装
先说安装,很多新手卡在这一步。我本机开发用的是 Windows,直接跑 Redis 官方 Windows 版本有各种兼容性问题,后来改用 Docker 一次性解决:
docker run -d --name redis-for-crawler \ -p 6379:6379 \ -v /my/redis-data:/data \ redis:7-alpine redis-server --appendonly yes如果生产环境有一台独立的 Linux 服务器,更建议直接在服务器上装 Redis,因为爬虫节点都需要连到同一个 Redis,把这个地址写在配置里,所有节点统一指向它就行。
Python 环境里装依赖也很直接:
pip install scrapy scrapy-redis redis我用的版本组合是 Scrapy 2.11 和 scrapy-redis 0.7.3。0.7.x 是维护比较稳定的版本,跟新版 Scrapy 配合没有明显兼容问题。装完之后可以在 Python 里执行import scrapy_redis验证一下。
3.2 改造 settings.py:关键的六行配置
单机项目改成分布式,核心就在settings.py里加一段配置。我把我的配置贴出来,每一行的作用写在注释里:
# scrapy_redis 分布式配置 SCHEDULER = "scrapy_redis.scheduler.Scheduler" DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" REDIS_URL = "redis://:your_password@10.0.0.5:6379/0" # 是否在关闭时保留 Redis 中的队列和去重集合 SCHEDULER_PERSIST = True # 是否在启动时清空去重集合 # True 表示每次启动都重新开始,False 表示接着上次的跑 SCHEDULER_FLUSH_ON_START = False # 队列类型:FifoQueue 表示先进先出 SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.FifoQueue'这里有两个容易犯错的点,我分别解释一下。
第一,DUPEFILTER_CLASS必须设置成scrapy_redis.dupefilter.RFPDupeFilter,否则去重还是在内存里,多节点照样会重复爬。有个很容易被忽略的地方是,很多示例代码里还会写REDIS_PARAMS这种旧参数,新版本已经不需要了,直接写REDIS_URL更可靠。
第二,SCHEDULER_FLUSH_ON_START这个参数很关键。我第一次跑的时候设成了True,结果每天早上重启爬虫,Redis 里的去重集合就被清空了,已经爬过的 URL 再次进入队列,造成了大量重复请求。False表示“接着上次的进度跑”,这是断点续爬的核心开关,日常使用都应该设成False。只有在你确认要彻底重跑全部数据时,才手动清空对应 key,而不是靠这个参数。
3.3 改造 Spider:必须继承 RedisSpider 吗
这里要给一个反直觉的建议:不是所有场景都需要继承RedisSpider。
RedisSpider的作用是让 Spider 从 Redis 的某个 List 里读取起始 URL。当你希望可以从外部动态往队列塞新的种子 URL 时,继承它是很方便的。比如我需要随时把新的分类页地址推给爬虫,就可以用 Redis 客户端直接执行:
redis-cli lpush product_spider:start_urls "http://example.com/category/electronics"Spider 侧代码会长这样:
from scrapy_redis.spiders import RedisSpider class ProductSpider(RedisSpider): name = "product_spider" redis_key = "product_spider:start_urls" def parse(self, response): # 和普通 scrapy.Spider 的 parse 完全一样 item = { "url": response.url, "title": response.css("h1::text").get(), } yield item但如果你只是想把现有单机爬虫快速改成分布式,不改任何业务解析逻辑,完全可以继续继承普通的scrapy.Spider,只改 settings 就够了。因为start_urls一旦写入 Redis,调度器和历史 URL 的流转就已经走 Redis 了,Spider 本身不用感知。我第二套爬虫就是这么干的,改完 settings,在服务器上往 Redis 塞了一下初始 URL,两台机器直接开跑。
所以我的建议是:新写项目优先用RedisSpider,方便后期动态注入种子;老项目迁移先用普通Spider减少改动面,跑通之后再决定要不要重构。
3.4 启动流程与任务注入:两台机器跑同一套代码
改造完成后,启动流程变成这样:
- 确保 Redis 已启动,并且节点机器都能访问到。
- 选定一台机器作为“种子注入器”,执行:
redis-cli lpush product_spider:start_urls "http://example.com/list-1" redis-cli lpush product_spider:start_urls "http://example.com/list-2"- 每台机器都执行相同的启动命令:
scrapy crawl product_spider- 观察日志,正常情况下不同节点日志里出现的 URL 不会重复。
这中间还有个细节:任务注入之后,节点需要一点时间才能开始消费,所以不要刚 lpush 完就盯着日志看,等个十几秒很正常。如果超过一分钟还没有任何输出,优先检查 Redis 连通性和redis_key是否配错了。
4. 跑通之后的一堆坑:我在迁移和扩容时踩过的排查链路
4.1 第一个坑:爬虫跑一会儿就停,日志显示 idle
这是我第一次跑分布式遇到最困惑的问题。两个节点启动后抓了几百个 URL,然后全部进入 idle 状态,日志里反复出现Spider idle,看起来像是没活了,但我明明还有很多 URL 在队列里。
排查链路是这样走的:
- 先看 Redis 队列长度:
llen product_spider:start_urls,发现队列确实还有值; - 再看 Redis 里的指纹集合大小:
scard product_spider:dupefilter,发现数量在涨,说明节点还在消费; - 最后看节点日志,发现 idle 之前其实有大量
Filtered duplicate request的日志。
真相是:种子 URL 的下一页提取逻辑出了问题,导致解析出来的链接全是重复的,去重过滤器把所有新链接都拦掉了,节点觉得“没有新任务”,就进入了 idle。这个问题在单机上也存在,但在分布式下更容易暴露,因为多个节点的去重集合合并在一起,重复链接被拦截的概率变高了。
解决方案是重新写链接提取规则,并且用response.urljoin()拼接相对链接,避免因为网站在列表页里加了各种追踪参数导致 URL 看起来不同、实际内容相同。
4.2 第二个坑:断点续爬时究竟是清了队列还是清了去重集合
有一段时间我发现爬虫每天跑到后面会越来越慢,经排查才发现是我自己清理数据时搞错了对象。如果你执行了:
redis-cli del product_spider:dupefilter就把所有节点的去重指纹全删了。这样再重启,之前爬过的 URL 又会重新进入待抓队列,节点会再次全部爬一遍。更麻烦的是,这种错误不会立刻报错,而是表现为“数据量暴增,但大量是重复内容”,非常难发现。
正确的清理姿势是分清三个 key 的用途:
| Redis Key | 内容 | 什么时候需要清理 |
|---|---|---|
product_spider:start_urls | 种子 URL 队列 | 想彻底重跑时清理 |
product_spider:dupefilter | 全局去重指纹集合 | 尽量不要动,动了就是重复爬 |
product_spider:items | 爬取结果队列 | 确认数据已经处理完可以清理 |
我现在的习惯是,每次清理前先keys "*product_spider*"看看所有相关 key,明确当前状态再操作,绝不在半路乱删。
4.3 第三个坑:并发数不是越高越好,多节点要按总量算
单机时CONCURRENT_REQUESTS = 32没问题,上了两台机器如果你还保持 32,那两台加起来就是 64 个并发请求。目标网站如果对并发有严格限制,很快就会出现大量 429 或者直接封 IP。
正确的做法是先确定总并发预算,然后分摊到每台节点上。比如目标站能承受 30 个并发,你有 3 台节点,每台就设CONCURRENT_REQUESTS = 10。分布式爬虫最大的优势是“把同一份并发预算分配到更多 IP 上”,而不是“无限制地加并发”。
除了并发数,下载延迟也要注意。单机 100ms 的DOWNLOAD_DELAY,三台机器就是每秒钟接近 30 个请求,如果目标站对频率敏感,一样会被识别。用上AUTOTHROTTLE_ENABLED = True,再根据实际响应时间动态调整延迟,会比手工拍脑袋靠谱得多。
4.4 第四个坑:Redis 内存暴涨,去重集合成了元凶
跑了一段时间后,我习惯用redis-cli info memory看内存占用,发现单日涨了几百 MB,点开才知道是dupefilter这个 Set 太大了。我当时的指纹集合里存了上千万条记录,而 URL 长度又很长,每条记录占的内存相当可观。
这个问题的本质在于:URL 指纹是 SHA1,本身就是 20 字节的二进制数据,但 Redis 为了方便查看,存的往往是十六进制字符串,长度直接翻倍。Scrapy 的指纹生成虽然有优化,但如果你自定义了指纹方法,很容易忽略这一点。
处理办法有两个方向。如果只是需要快速腾出内存,可以直接给 Set 设置过期时间,但这样做会导致指纹丢失,后续会有重复请求。所以我更推荐的做法是引入布隆过滤器,用极小的内存代价去重海量 URL,这个优化我在下一节详细展开。
5. 分布式抓取的进阶优化:从去重效率到稳定落库
5.1 用 Bloom Filter 替换 Set 去重,内存直接降到原来的几十分之一
当去重集合达到千万量级,Set 的内存压力会非常明显。解决思路是使用布隆过滤器(Bloom Filter):它用一个位数组来标记“某个元素是否出现过”,判断时只需计算几个哈希函数,检查对应位是否全是 1。
布隆过滤器允许极低的误判率,比如 0.01%,换取的是极低的内存占用。一千万条 URL 如果用 Set 存,可能需要 800MB 以上;用布隆过滤器,在误判率 1% 的前提下,大约只需要 50MB 左右。代价是极少数的 URL 会被误判为“已抓取”而跳过,但爬虫场景里漏掉万分之一的页面,完全在可接受范围内。
我实际采用的做法是写一个自定义的DupeFilter类,内部维护一个 Redis 的 bitmap 或者本地布隆过滤器,同时保留一个可选的精确指纹集合兜底。这里分享一个简化版思路,方便理解核心逻辑:
import hashlib import redis class RedisBloomDupeFilter: def __init__(self, redis_client, key, bit_size=2**32, hash_count=7): self.client = redis_client self.key = key self.bit_size = bit_size self.hash_count = hash_count def _hash(self, data, seed): # 用不同的 seed 生成不同哈希值 h = hashlib.md5(f"{seed}:{data}".encode()).hexdigest() return int(h, 16) % self.bit_size def request_seen(self, request): fp = request_fingerprint(request) # 复用 Scrapy 的指纹生成 bits = [self._hash(fp, i) for i in range(self.hash_count)] # 检查所有位是否都是 1 existed = all(self.client.getbit(self.key, b) for b in bits) if not existed: # 把对应的位全部置 1 for b in bits: self.client.setbit(self.key, b, 1) return existed实际生产里不需要自己写哈希逻辑,可以用pybloom_live或者mmh3+bitarray组合,我这里展示的是原理。关键点是:布隆过滤器的位数组大小和哈希函数数量,直接影响误判率和内存占用,千万别随手填一个 2 的 20 次方就完事。我的经验是位数组大小约等于预估元素数量的 10 倍以上,哈希函数 7 个左右比较均衡。
5.2 调度队列优化:FifoQueue、PriorityQueue 和分区队列怎么选
scrapy-redis 默认支持三种队列类型,我列个对比:
| 队列类型 | 行为特点 | 适合场景 |
|---|---|---|
FifoQueue | 先进先出,按入队顺序抓取 | 大多数通用爬虫,逻辑最直观 |
PriorityQueue | 按请求优先级出队 | 需要优先抓重要页面、后抓次要页面时 |
LifoQueue | 后进先出,类似栈 | 深度优先场景,或需要最新种子先抓 |
我早年做新闻聚合站时,会按时间倒序抓取最新文章,LifoQueue就很合适。而做电商商品采集时,分类页和详情页的优先级差距很大,我会给详情页请求设置更高的优先级,用PriorityQueue提升核心数据的产出速度。
不过选优先级队列要付出额外代价:每次入队需要计算并比较优先级,Redis 的 ZSet 操作会比 List 略慢。在请求量非常大的场景里,这会造成 Redis CPU 上升明显。所以我的建议是,默认先用FifoQueue,只有确认业务上需要优先级调度时,再切换到PriorityQueue。
5.3 Item 落库优化:先用 Redis 做缓冲,再批量写 MySQL
单机爬虫最常见的落库问题是:每抓到一个 item 就执行一次数据库 INSERT,在并发 40 的情况下,数据库连接会被快速打满。分布式场景这个问题更严重,多台机器同时写同一个库,很容易把数据库拖垮。
我的优化方案是在 MySQL 前面加一层 Redis 缓冲:item 管道先把数据写入 Redis 的 List,然后由单独的脚本定时批量取数据,攒够几百条或者每隔几秒再执行批量插入。这样既能降低数据库的写入频率,又能利用 Redis 的高速写入特性减少等待。
# pipelines.py 中写入 Redis 的示例 import redis class RedisBufferPipeline: def __init__(self, redis_url): self.client = redis.from_url(redis_url) @classmethod def from_crawler(cls, crawler): return cls(crawler.settings.get("REDIS_URL")) def process_item(self, item, spider): self.client.rpush(f"{spider.name}:items", json.dumps(dict(item))) return item然后写一个独立的消费者脚本,循环执行blpop或者批量lrange,拿到一批 item 后一次性写入 MySQL。这样做还有一个额外好处:即使某个 item 落库失败,数据还留在 Redis 里,可以重新消费,不会因为一次失败就丢失整条数据。
5.4 统计与监控:把每个节点的实时状态都记录到 Redis
分布式跑起来之后,最直观的问题是:你很难知道当前全 cluster 的抓取速度是多少、哪个节点出错了、任务总量还剩多少。单机可以用 Scrapy 的日志和 telnet 控制台,分布式下这些信息都是碎片化的。
我后来养成了一个习惯:在每个节点里加一个自定义扩展,定期把统计信息推送到 Redis。统计内容包括:当前节点请求数、item 产出数、失败数、队列剩余长度等。然后在任意一台机器上就能用一条命令看到全局状态:
redis-cli hgetall crawler:stats这个扩展其实就是利用 Scrapy 的stats_collector和信号系统,在spider_idle或者每个统计周期执行一次写入。具体代码不复杂,但带来的价值很大,尤其是任务出错时,你能一眼看出是哪个节点在哪个环节卡住的。
6. 给入门者的最后建议:哪些场景真的不需要分布式
6.1 我的选型判断方法
做了不少分布式改造的活之后,我总结了一套判断标准。如果你的项目符合下面任意一条,就有必要考虑分布式:
- 目标站点页面数量在百万级以上,而且会持续增长;
- 跑一次任务超过几个小时,中断后重跑成本很高;
- 有多台空闲机器可以利用,希望把资源都用起来;
- 需要多个爬虫共享同一个去重集合和任务队列。
如果只是几十万页面、单机四小时能跑完,我劝你还是把精力放在解析逻辑和数据质量上。分布式不是爬虫的万能药,它解决的是任务协同问题,不是解析效率问题。
6.2 最后一个实操习惯:把 Redis Keys 命名规范固定下来
这是我踩了不少坑之后特别想强调的一点。多个爬虫同时跑时,Redis 里会有大量 key,如果命名不统一,排查问题纯粹靠猜。我现在的规范是:项目名:爬虫名:用途,比如product_spider:start_urls、product_spider:dupefilter、product_spider:items。
另外,SCHEDULER_PERSIST = True的情况下,任务队列和去重集合在爬虫关闭后依然保留在 Redis。这带来一个好处,也有一个隐患:好处是重启后可以无缝续跑,隐患是旧数据集会一直躺在内存里,越积越多。我每月会检查一次所有 key 的大小,清理那些已经确认不需要的历史数据。
说回优化这件事。搞了两年分布式爬虫后,我最大的体会是:不要一开始就追求最复杂的架构,先从单机抓取跑通,加上settings.py那六行配置,把两台机器拉起来,观察任务队列的消耗速度,再逐步加入布隆过滤器、优先级队列和 Redis 缓冲落库这些优化点。每一步都踩在真实问题上,比看一百篇理论文章都管用。以后遇到所谓“scrapy redis 分布式爬虫入门”的问题,核心思路其实就是这几个:共享队列、共享去重、共享状态。把这三点吃透了,后面的优化都是往这个框架里填东西而已。