分布式爬虫架构:Redis+Scrapy打造企业级爬虫系统

引言

单机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就能断点续爬",结果重启后还是从头开始。真正生效需要三个条件同时满足:

  1. **SCHEDULER正确指向scrapy_redis.scheduler.Scheduler**:拼写错误或漏配,持久化开关不会生效。

  2. **SCHEDULER_PERSIST = True**:告诉Scrapy-Redis在爬虫关闭时不清空Redis队列。

  3. 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各自持有一份代理列表。

把队列、去重、代理、监控四件事都做到"中心化、可观测、可恢复",分布式爬虫才真正具备企业级的底色。

最后需要提醒的是:分布式架构提升的是工程能力,不是合规豁免。请控制采集频率,遵守目标网站的规则和相关法律法规,仅将技术用于学习和研究目的。

相关推荐
FastCAE20221 小时前
FastCAE AI应用助手重构(一)架构与桥接详解
人工智能·重构·架构
开源推荐官2 小时前
多门店 O2O 商城选型复盘:从开发视角对比主流开源系统
java·架构·开源·php
FPGA小迷弟2 小时前
FPGA 与卫星:从星载硬件到在轨智能的完整技术解析
fpga开发·架构·fpga·1024程序员节·无线电
静以修身zzz2 小时前
OpenStack对象存储-Swift、OpenStack编排管理-heat、OpenStack网络管理-neutron
linux·运维·服务器·架构·openstack·swift
Gauss松鼠会3 小时前
GaussDB 整体架构解析
运维·服务器·网络·数据库·sql·架构·gaussdb
超级架构师3 小时前
让 Fortran 模型自己调用 Agent:原生 Fortran 2008 实现深度剖析
人工智能·python·架构·机器人·开源·fortran
暴走约伯3 小时前
【VFX工具链源码拆解 01】总体架构:大型项目特效流程的程序化落地
开发语言·经验分享·3d·unity·架构·游戏引擎
starzy19904 小时前
Kafka请求进门口:Processor是怎么把请求读上来的
分布式·kafka
Decay__10 小时前
05_MySQL 常用 SQL 大全 —— 诊断、管理、优化辅助 SQL 与三大系统库速查
运维·数据库·sql·mysql·架构