引言
单机Scrapy的日采集上限,通常在一万条以内。有团队用单机跑商品采集,单进程日抓取量只有8000条左右,面对大促期间10万+的需求完全不够用;更麻烦的是,任务没有持久化,服务器一重启,待爬URL全部丢失,只能从头再来;去重集合放在内存里,超过16GB就开始频繁OOM,重复请求还会触发目标平台的风控,IP被封得越来越频繁。
这些问题不是"调优"能解决的,它们指向同一个根源:状态没有离开单机。队列在本地、去重集合在内存、代理列表在每个Worker的配置文件里------每台机器都在各自为战,既无法协同,也无法在故障后恢复。
Scrapy-Redis的思路很直接:把Scrapy的调度器和去重组件"搬到"Redis上,让所有爬虫节点共享同一个任务队列、同一套去重规则、同一份代理状态。Redis成为整个集群的中枢,爬虫节点变成无状态的执行单元,可以随时增减、随时重启。这就是分布式爬虫从"能跑"走向"企业级"的关键一步。
本文将从单机瓶颈出发,系统拆解Scrapy-Redis的分布式架构、核心配置、代理调度、性能优化和运维监控,并说明站大爷隧道代理IP在分布式代理调度中如何避免"每个Worker各自为战"的问题。
免责声明:本文仅限技术学习与交流。请遵守目标网站的robots.txt协议及相关法律法规,控制采集频率,不得将技术用于非法用途。
一、单机Scrapy的四个结构性瓶颈
在讨论分布式之前,先看清单机到底卡在哪里。
第一,吞吐量有硬上限。 单进程的并发能力受限于机器资源和Scrapy的异步调度。即使优化到极限,日采集量通常也在万级,无法线性扩展。
第二,任务无持久化。 Scrapy默认的调度器把队列放在内存中,爬虫一停,未完成的URL就丢了。虽然有JOBDIR可以持久化到磁盘,但它只适用于单机,无法在多节点间共享。
第三,去重集合膨胀。 默认的RFPDupeFilter基于内存Set,百万级URL就会占用数百MB到GB级内存,千万级时单机内存根本扛不住。
第四,代理状态孤岛化。 单机时写一个ProxyMiddleware,在本地维护代理列表就能跑。但多台机器同时跑时,每个Worker各自持有一份代理列表,同一个IP可能同时被5个Worker用在同一个目标站上,目标站看到的并发量是预期的5倍;某个Worker发现IP失效并删掉它,其他Worker还在继续用这个死IP产生失败请求。
这四个问题的共同解法,是把"共享状态"从单机搬到中心化的Redis上。
二、Scrapy-Redis的核心原理
Scrapy-Redis本质上是一组"可插拔组件",它替换了Scrapy默认的调度器、去重器和部分管道,把状态存储从本地内存换成了Redis。
2.1 三个共享
| 共享维度 | 单机Scrapy | Scrapy-Redis |
|---|---|---|
| 请求队列 | 本地内存/磁盘 | Redis List/ZSet,所有节点共享 |
| 去重指纹 | 内存Set | Redis Set(或BloomFilter),全局去重 |
| Item暂存 | 本地Pipeline | Redis队列,供后处理程序消费 |
请求队列 是分布式的核心。Spider产生的Request被序列化后推入Redis的spider:requests键,所有爬虫节点从同一个队列中弹出请求执行。默认的PriorityQueue基于Redis的有序集合(ZSet),按优先级分数出队;FifoQueue和LifoQueue则基于List实现。
去重 通过RFPDupeFilter实现:每个Request生成一个指纹(通常是sha1(method + url + body)),在入队前检查Redis Set中是否已存在。由于所有节点共享同一个Set,去重是全局的,不会出现"Worker A爬过的URL,Worker B再爬一遍"。
任务抢占的原子性 不靠Python锁,而是靠Redis的原子操作。基于List的队列使用RPOP或BLPOP,多个节点并发弹出时,Redis保证每个URL只会被一个节点拿到;基于ZSet的优先级队列同样依赖Redis的原子性。
2.2 断点续爬的三个必要条件
很多人以为"配了SCHEDULER_PERSIST = True就能断点续爬",结果重启后还是从头开始。真正生效需要三个条件同时满足:
-
**
SCHEDULER正确指向scrapy_redis.scheduler.Scheduler**:拼写错误或漏配,持久化开关不会生效。 -
**
SCHEDULER_PERSIST = True**:告诉Scrapy-Redis在爬虫关闭时不清空Redis队列。 -
Redis本身开启持久化(AOF或RDB) :如果Redis没有持久化,机器断电或Redis重启后,队列数据依然会丢失。建议在
redis.conf中开启appendonly yes。
只有这三者协同,重启后才能从Redis中残留的队列和去重集合继续。
三、企业级架构设计
3.1 四层架构
一套可运行的企业级分布式爬虫,通常分为四层:
采集层:多台无状态 Scrapy Worker(可弹性增减)
↓
调度层:Redis(请求队列 + 去重集合 + 代理池状态)
↓
代理层:站大爷隧道代理(统一出口,自动轮换)
↓
存储层:MongoDB / MySQL / 数据仓库
采集层的Worker不保存任何本地状态,所有队列和去重都从Redis读写。这样Worker可以随时启动、停止、扩容,新节点加入后自动从共享队列领取任务。
调度层 的Redis是集群的单点,必须做高可用。生产环境推荐主从+哨兵 架构:哨兵监控主节点健康,主节点故障时自动将从节点提升为主节点,切换时间通常在10秒以内。如果使用Redis Cluster,需要注意Scrapy-Redis原生的PriorityQueue在Cluster模式下,多个Key可能被分配到不同slot,需要对Key加哈希标签(如{spider}::requests)确保相关Key落在同一slot。
代理层的职责在分布式环境下需要重新设计------代理状态不应该留在每个Worker本地,而应该由Redis统一管理。具体方案在第五节展开。
3.2 Redis的高可用配置要点
-
开启RDB + AOF双重持久化,避免断电丢任务
-
设置密码,并绑定内网IP,不暴露公网
-
使用连接池,避免每个请求新建连接
-
监控Redis的内存使用 和慢查询,去重集合膨胀是内存增长的主要来源
四、实战配置:从单机到分布式
4.1 依赖安装
pip install scrapy scrapy-redis redis
所有Worker节点安装相同版本的依赖,避免序列化不兼容。
4.2 settings.py核心配置
# 调度器:使用Redis共享队列
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
# 去重:使用Redis全局去重
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
# 持久化:爬虫关闭时不清空队列
SCHEDULER_PERSIST = True
# 队列类型:优先级队列(默认)
SCHEDULER_QUEUE_CLASS = "scrapy_redis.queue.PriorityQueue"
# Redis连接(生产环境使用密码+内网地址)
REDIS_URL = "redis://:your_password@redis_host:6379/0"
# Item管道:存入Redis供后处理
ITEM_PIPELINES = {
"scrapy_redis.pipelines.RedisPipeline": 300,
}
# 限流(合规采集的关键)
CONCURRENT_REQUESTS = 12
DOWNLOAD_DELAY = 4
RANDOMIZE_DOWNLOAD_DELAY = True
这里把CONCURRENT_REQUESTS和DOWNLOAD_DELAY放在一起是有意的。分布式环境下,单个Worker的并发不高,但多个Worker叠加后总并发会成倍放大。如果每个Worker都开很高的并发,目标站看到的总请求量会远超预期,风控很快触发。企业级配置通常会刻意压低单Worker的并发,靠"增加Worker数量"而不是"提高单Worker并发"来扩展吞吐。
4.3 爬虫文件:继承RedisSpider
from scrapy_redis.spiders import RedisSpider
class DistributedSpider(RedisSpider):
name = "distributed_spider"
redis_key = "my_spider:start_urls" # 所有节点监听同一个键
def parse(self, response):
yield {
"title": response.css("h1::text").get(),
"url": response.url,
}
for next_page in response.css("a::attr(href)").getall():
yield response.follow(next_page, callback=self.parse)
关键点:不要在start_requests()中直接yield Request(url) 。这样产生的请求走的是Scrapy内存队列,绕过了Redis调度器,分布式就失效了。所有初始URL应该通过redis-cli LPUSH my_spider:start_urls "https://..."注入,让所有Worker从Redis中领取。
4.4 启动集群
在每台Worker上执行:
scrapy crawl distributed_spider
所有节点启动后,向Redis推入起始URL:
redis-cli -a your_password LPUSH my_spider:start_urls "https://example.com/page1"
Worker会自动从队列中领取任务,去重集合和队列状态全部由Redis管理。
五、分布式代理调度:从"各自为战"到"借-还模型"
5.1 单机代理方案在集群中的失效
单机时,ProxyMiddleware在process_request里轮询本地代理列表,简单有效。但到了分布式环境,三个问题会同时爆发:
-
代理列表重复:所有Worker从同一个接口拉取代理,拿到的是同一批IP,同一个IP被多节点并发使用,目标站看到的频率是预期的N倍
-
状态不共享:Worker A发现IP失效并删除,Worker B、C、D还在继续用
-
没有全局频率控制:你以为每个Worker每分钟用某个IP发10个请求,合计50个,但目标站是按IP计频率的,50个已经触发了限流
根源只有一个:代理状态没有中心化。
5.2 "借-用-还"模型
企业级方案把代理池从Worker本地搬到Redis,所有Worker共享同一个代理池,通过"借-用-还"三个动作管理代理生命周期:
借 :Worker需要代理时,从Redis的有序集合(Sorted Set)中取一个高分IP,并尝试加锁(SET proxy:lock:{ip}:{domain} NX EX 30)。加锁成功才算借到,失败则换一个IP重试。锁的粒度是"IP + 目标域名",因为同一个IP同时用于不同目标站是允许的,目标站的频率控制是按站点的。
用:Worker用借到的代理发起请求,记录成功/失败/延迟。
还:请求结束后,Worker把使用结果写回Redis------成功则提高该IP的评分,失败则降低评分,失效则移入黑名单。下一次其他Worker借代理时,自然会避开低分IP。
这个模型的核心价值是全局频率控制 和状态共享。无论集群有多少个Worker,每个"IP + 域名"组合在同一时刻只会被一个Worker使用,目标站看到的并发量始终可控。
5.3 站大爷隧道代理的接入
站大爷隧道代理在分布式架构中承担的是"统一出口层"的角色。它的价值不是替代Redis代理池的调度逻辑,而是简化代理资源的管理:
-
自动轮换:隧道代理入口自动分配出口IP,Worker不需要自己维护IP列表
-
高可用:覆盖全国99%地域,连接成功率在长时间压力测试中稳定在99%以上,减少因代理节点失效导致的请求失败
-
地域定向:支持按城市选择出口,适合需要模拟特定地区访问的采集任务
在Scrapy中集成站大爷隧道代理,通过Downloader Middleware注入:
class TunnelProxyMiddleware:
def __init__(self, proxy_url):
self.proxy_url = proxy_url
@classmethod
def from_crawler(cls, crawler):
return cls(crawler.settings.get("TUNNEL_PROXY_URL"))
def process_request(self, request, spider):
request.meta["proxy"] = self.proxy_url
settings.py中配置:
TUNNEL_PROXY_URL = "http://用户名:密码@tps.zdaye.com:8080"
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.TunnelProxyMiddleware": 543,
}
但要注意 :如果只做"每个Worker都走同一个隧道入口、每个请求自动换IP",这仍然是一种"各自为战"的变体------所有Worker共享同一个隧道出口池,全局频率依然不可控。更稳妥的做法是把隧道代理的出口IP状态 也纳入Redis代理池的"借-还"模型:Worker从Redis借到一个"隧道出口标识",用完还回结果,由中心调度决定哪个Worker在什么时间使用哪个出口。如果项目规模不需要这么精细,至少应该为不同Worker配置不同的隧道入口或不同的period轮换周期,避免所有Worker在同一时刻共享同一批出口IP。
5.4 高鲁棒性代理重试中间件
Scrapy默认的RetryMiddleware有两个致命缺陷:403/429不会被重试(它只重试500/502/503/504等状态码),Twisted底层的TunnelError、ConnectionLost等异常也可能被漏掉。在分布式高并发下,这意味着大量请求被静默丢弃。
企业级做法是把代理注入、状态码校验、异常捕获和重试收拢到一个自定义中间件中:
class RobustProxyRetryMiddleware:
def process_response(self, request, response, spider):
if response.status in (403, 429):
reason = f"proxy_blocked_{response.status}"
retry_req = request.copy()
retry_req.dont_filter = True
retry_req.meta["retry_times"] = request.meta.get("retry_times", 0) + 1
if retry_req.meta["retry_times"] <= 3:
return retry_req # 重新入队,隧道代理自动换IP
return response
return response
配合隧道代理的自动轮换,重试本身就意味着换了一个出口IP,而不是在同一个死IP上反复撞墙。
六、性能优化与监控
6.1 Redis内存:从Set到BloomFilter
千万级URL的去重集合是Redis内存增长的头号来源。普通Set存储完整指纹,百万级就可能占用GB级内存;BloomFilter用多个哈希函数把URL映射到位数组,千万级URL的内存占用可以压缩到GB级甚至更低,代价是存在极低的误判率(少量未爬URL被误判为已存在)。
生产环境常用的组合是:BloomFilter负责实时查重(快、省内存),Redis HyperLogLog负责全局唯一计数统计,定期将BloomFilter状态持久化到文件,防止重启丢失。
6.2 限流与反压
分布式环境下,"反压"是一个容易被忽视的问题:如果Item管道(如数据库写入)跟不上爬取速度,Item会在内存中堆积,最终导致爬虫停滞。解决方案包括:使用Redis Pipeline批量写入、将Item先推入Redis队列由独立的后处理进程消费、监控队列长度并动态降低采集并发。
6.3 监控指标
没有监控的分布式爬虫等于"盲人摸象"。关键指标包括:
| 指标 | 来源 | 告警阈值示例 |
|---|---|---|
| 待爬队列长度 | LLEN spider:requests |
持续为0说明任务耗尽或Worker空闲 |
| 去重集合大小 | SCARD spider:dupefilter |
增长异常快说明重复URL过多 |
| 各节点请求量 | Prometheus | 节点间差异过大说明负载不均 |
| 403/429比例 | Scrapy stats | 超过10%说明IP被限流 |
| 平均响应时间 | Prometheus | 突增说明代理质量下降 |
推荐的技术栈是Scrapy + scrapy-prometheus暴露指标 → Prometheus拉取存储 → Grafana看板可视化 → 钉钉/企业微信告警。每10分钟检查一次队列长度、去重集合大小和失败URL数量,超阈值即推送告警。
七、常见问题与避坑指南
配了SCHEDULER_PERSIST=True重启后还是从头爬。 这是最高频的坑。检查三件事:SCHEDULER是否拼写正确、Redis是否开启了AOF/RDB持久化、redis-cli keys "spider:requests"是否有数据。三者缺一不可。
多个Worker重复抓同一个URL。 检查是否在start_requests()中直接yield Request------这样绕过了Redis调度器。初始URL必须通过LPUSH注入Redis,或者重写start_requests从Redis读取。
代理IP被目标站"集体拉黑"。 检查是否每个Worker都在本地维护代理列表。分布式环境下必须把代理状态中心化到Redis,用"借-还"模型控制全局频率,否则N个Worker同时用一个IP,频率是单机的N倍。
Redis内存持续增长。 去重集合是主要来源。评估切换到BloomFilter,或为去重指纹设置合理的TTL(如果允许少量重复)。同时检查是否有大量重复URL在反复入队------这通常说明去重没有生效或爬虫逻辑有问题。
Worker"饿死"或负载不均。 检查队列类型。基于List的FifoQueue在高并发下可能出现某个节点连续抢不到任务的情况;可以改用PriorityQueue(ZSet),并确保优先级分数带时间戳,避免同分并列导致顺序不确定。
法律与合规提醒。 分布式提升的是吞吐量,但单IP的请求频率仍应控制在合理范围。请遵守目标网站的robots.txt和用户协议,不绕过访问控制,仅将技术用于学习和研究目的。
八、总结
从单机Scrapy到企业级分布式系统,核心变化不在"写更多代码",而在把状态从本地搬到中心:
| 维度 | 单机 | 企业级分布式 |
|---|---|---|
| 请求队列 | 本地内存 | Redis共享队列,所有节点竞争消费 |
| 去重 | 内存Set | Redis全局去重 / BloomFilter |
| 持久化 | JOBDIR(单机) | Redis AOF+RDB,断点续爬 |
| 代理调度 | 本地列表 | Redis"借-还"池,全局频率控制 |
| 高可用 | 单点 | Redis主从+哨兵,Worker无状态 |
| 监控 | 日志 | Prometheus+Grafana+告警 |
Scrapy-Redis解决的是"队列和去重共享"的问题,但代理调度、限流、监控、高可用 这些企业级能力,需要在它之上额外设计。站大爷隧道代理在分布式代理层中的角色,是提供稳定、可轮换的出口资源,但它不能替代"中心化的代理调度逻辑"------代理状态必须放在Redis里,由全局规则决定谁在什么时候用哪个出口,而不是让每个Worker各自持有一份代理列表。
把队列、去重、代理、监控四件事都做到"中心化、可观测、可恢复",分布式爬虫才真正具备企业级的底色。
最后需要提醒的是:分布式架构提升的是工程能力,不是合规豁免。请控制采集频率,遵守目标网站的规则和相关法律法规,仅将技术用于学习和研究目的。