分布式爬虫实战:Scrapy-Redis与布隆过滤器改造指南
1. 为什么需要分布式爬虫单机瓶颈与核心挑战1.1 单机爬虫到底在哪个环节卡脖子先聊一个最基础的问题一个用 Scrapy 写好的爬虫跑在单台服务器上数据量上来了到底哪里先扛不住我见过不少开发者对分布式的第一反应是加机器让每台机器各跑各的。真这么干过的人就知道问题远没有这么简单。单机 Scrapy 的瓶颈有三个层面调度层、去重层、存储层。这三个环节全部依赖进程内内存一旦爬虫重启队列和去重集合就全部清零了。拿一个我之前模拟项目 X 的经验举例。某个爬虫任务要从一个公开数据源抓取大约 800 万个页面单机跑大概需要 9 天才能跑完。在这 9 天里只要进程一崩、重启之前已经抓过的 URL 又会重新进入待抓队列。内存里的去重集合一丢重复请求量瞬间爆炸。你打开终端看到日志里一屏一屏的 duplicate就知道这个方案行不通了。分布式爬虫要解决的核心问题不是多台机器同时跑而是多台机器如何像一个整体那样协作。也就是说你需要一个所有节点都能访问的、非内存态的调度器把哪个 URL 接下来由谁抓这件事放到同一个地方去管理同时需要一个跨节点的去重机制保证同一个 URL 不会被两台机器各自抓一遍最后还需要统一的数据出口让每个节点的抓取结果都汇入同一个存储系统。1.2 分布式改造的关键问题清单改造之前先把需求列清楚后面写代码才不会被细节带着跑。一个可用的分布式爬虫至少要满足以下五个条件任务队列可共享所有爬虫节点从同一个队列里取待抓 URL。任何节点取走一个任务其他节点就不会再取到它。请求去重可共享某个 URL 只要被任何一个节点处理过其他节点就不需要再处理。状态可持久化进程重启、服务器断电队列和去重集合都不应该丢。这是跟单机 Scrapy 最本质的区别也是很多人踩坑最深的地方。横向可扩展数据量涨了加一台机器、开一个进程就能分担压力不需要改代码逻辑。数据入库存量可控多节点同时写入数据库必须考虑批量写入、连接池和写入速率限制否则数据库会变成新的瓶颈。在我做过的几个模拟项目里真正难的不是第一点和第二点——这些都有现成组件可以搞——而是第五点。有些团队把精力全花在调度和去重上结果上线之后发现 MySQL 的写入连接被两台机器的 16 个进程打满CPU 100% 就烧在一个 INSERT 语句上。1.3 方案选型选对组件少走弯路Scrapy 本身没有分布式能力所有方案本质上都是在做一件事把 Scrapy 内部基于内存的队列和去重器替换成基于外部存储的共享实现。目前主流的方案有三个方向我分别说一下适用场景方案核心原理适用场景难点Scrapy-Redis 经典版Redis 的 List 做任务队列Set 做去重集合中小规模数据量千万级 URL 以内去重内存消耗大旧版与 Python 3.10 异步兼容性差Scrapy-Redis BloomFilter用布隆过滤器改造去重层大幅降低 Redis 内存海量 URL 去重上亿级需要自行实现或引入第三方扩展自定义调度器直接用 Redis 原语或引入 MQ/任务队列对调度策略有个性化需求开发成本高维护负担重我自己在实际项目中比较推荐的是第二种。核心原因是分布式爬虫的 Redis 内存开销绝大多数时候不是花在队列上而是花在去重集合上。比如抓 1 亿个 URL如果用 Set 去重每个 URL 指纹按 40 字节算要占大约 4GB 内存如果用布隆过滤器按 1% 误判率配置几十个 bit 就能搞定一个 URL内存直接下降一个数量级。如果已经用上了 Python 3.10经典版的 Scrapy-Redis 装完需要做一点小改动才能跑起来我后面会专门把兼容性问题拎出来讲。2. 分布式改造原理调度器、去重器与共享状态2.1 Scrapy 默认的单机调度流程要理解分布式改造先从单机 Scrapy 的流程说起。一个请求从发起到最后被处理的完整路径是这样的爬虫的start_requests()生成初始请求交给引擎。引擎把请求交给调度器调度器在内存中维护一个队列。调度器出队一个请求交给下载器去请求网络。响应回来之后由爬虫中间件和爬虫解析出新的 URL。新生成的 Requests 会先去去重器做一次指纹检查——Scrapy 默认用一个基于 SHA1 的内存集合来判断这个请求是否已经处理过。没重复的请求重新进入调度器队列重复的直接丢弃计入dupefilter统计。这个设计在单机上非常优雅但一旦进程退出或者部署到多台机器问题就暴露了队列是内存里的去重集合也是内存里的天然无法跨进程共享。你在一台机器上抓过的 URL另一台机器完全不知道。2.2 用 Redis 替换内存队列的改造思路Scrapy-Redis 的思路说白了就是不动引擎的逻辑只把调度器和去重器的实现换成基于 Redis 的版本。队列替换逻辑如下原始实现queue.put(request)/queue.get(request)数据存在进程内。分布式实现redis.lpush(key, request_bytes)/redis.brpop(key)数据存在 Redis 的 List 数据结构里。用 Redis List 的好处是操作是原子的。多个节点同时brpop同一个 key 时Redis 底层保证同一时刻只有一个客户端能取走一个元素天然避免了任务重复分配的问题。至于请求序列化Scrapy 的 Request 对象本身有to_bytes/from_bytes的序列化接口Scrapy-Redis 就是用它把请求对象转成二进制再塞进 Redis。这也是为什么存储进队列的每个元素是一个完整请求而不是一个裸 URL——这样连请求头、Cookies、特定参数都能跟着任务走。下面是调度器核心逻辑的简化示意# 伪代码说明核心机制 class RedisScheduler: def __init__(self, server, queue_key): self.server server self.queue_key queue_key def enqueue_request(self, request): if not self._filter_request(request): # 把请求序列化后压入 Redis List self.server.lpush(self.queue_key, request_to_bytes(request)) return True return False def next_request(self): # 阻塞式取出队首元素支持多节点竞争消费 data self.server.brpop(self.queue_key, timeout30) if data: return bytes_to_request(data[1]) return None到这里各个节点之间的协作问题就解决了一大半。剩下另一半是去重。2.3 指纹去重从 Set 到 BloomFilter 的演进去重器的原理很简单给每个请求算一个唯一的指纹然后判断这个指纹是不是已经存在。Scrapy 默认的指纹算法是取请求的 URL、请求方法、请求主体的规范化信息然后算 SHA1 得到一个 40 个字符的十六进制字符串。在单机版里去重集合就是一个set()。在 Scrapy-Redis 里去重集合被换成了 Redis 的 Set。每次请求过来执行sadd(fingerprint)如果返回 1 说明是新指纹如果返回 0说明已经有了直接跳过。这个方案的问题在于Set 的内存开销跟 URL 数量线性增长。跑个几百万 URL 没什么感觉到上亿级内存直接吃紧。我在一个模拟抓取 2 亿个链接的项目里试过Set 占用了大约 6.8GB 的 Redis 内存而且随着数据增加还在不停上涨。后来换成了 BloomFilter同样数据量内存占用压到了 800MB 出头可接受度完全不一样。布隆过滤器的原理用一句话说就是用多个哈希函数给一个元素打出多位标记。当某个请求的所有哈希位都已经置 1就认为它可能存在只要有一位是 0就确定它不存在。这里有个关键点也是很多初学者容易纠结的问题布隆过滤器存在误判会把从未见过的 URL 判成已存在。但误判率是可以配置的通常控制在 1% 以内对爬虫去重完全没有实际影响——损失的那 1% 数据量远小于 Redis 内存爆炸带来的运维成本。我在实际项目里用的参数配置是期望插入 1 亿条数据误判率0.0001需要的位数为大约 1.4GB 内存哈希函数个数取 14 个。如果你用现成的scrapy-redis-bloomfilter这类扩展包直接在settings.py里改两行配置就能启用代码层完全无感知。3. 写一个可运行的分布式爬虫项目3.1 项目结构与基础依赖理论聊完了直接进入实操。我接下来用一个模拟项目 X 来演示抓取某个公开数据网站的列表页和详情页抽取关键字段最终统一入库到 MySQL。整个项目的目录结构如下distributed_crawler/ ├── scrapy.cfg ├── requirements.txt └── distributed_crawler/ ├── __init__.py ├── settings.py ├── items.py ├── pipelines.py ├── middlewares.py └── spiders/ ├── __init__.py └── public_data_spider.py依赖方面我建议按以下版本组合安装这是我在多台机器上验证过稳定能跑的pip install scrapy2.8.0 pip install redis4.3.4 pip install Scrapy-Redis0.6.3 pip install pymysql1.0.2 pip install scrapy-redis-bloomfilter其中scrapy-redis-bloomfilter这个包是对 Scrapy-Redis 的去重器做 BloomFilter 替换的第三方扩展装完之后还带了一个 MySQL 管道示例后面我们会改成自己的实现。3.2 settings.py 核心配置逐行解析配置是分布式爬虫的灵魂。很多人以为把SCHEDULER换一下就行实际上至少有 7 组参数需要同时调整。下面是我验证过的完整配置带注释说明每一行为什么这么设# settings.py # 分布式核心将调度器替换为基于 Redis 的调度器 SCHEDULER scrapy_redis.scheduler.Scheduler # 分布式核心将去重器替换为布隆过滤器版本 DUPEFILTER_CLASS scrapy_redis_bloomfilter.bloomfilter.BloomFilterDupeFilter # 布隆过滤器参数期望去重的 URL 数量 BLOOMFILTER_HASH_NUMBER 6 BLOOMFILTER_BIT 30 # 爬虫结束后不清空 Redis 中的队列和去重集合。 # 好处断点续爬坏处如果任务已经彻底结束需要手动清理 key。 SCHEDULER_PERSIST True # 同一个爬虫任务在多个节点间分摊工作时必须指定相同的 key # 否则不同节点会各自维护独立的队列。 SCHEDULER_QUEUE_KEY spider:public_data:requests SCHEDULER_DUPEFILTER_KEY spider:public_data:dupefilter # Redis 连接配置 REDIS_HOST 127.0.0.1 REDIS_PORT 6379 REDIS_PASSWORD REDIS_PARAMS {socket_timeout: 30} REDIS_ENCODING utf-8 # 并发级别每个节点开 16 并发 CONCURRENT_REQUESTS 16 # 下载超时与重试 DOWNLOAD_TIMEOUT 20 RETRY_ENABLED True RETRY_TIMES 3 RETRY_HTTP_CODES [500, 502, 503, 504, 408] # 关闭内置的限速方案由我们自己控制 QPS AUTOTHROTTLE_ENABLED False # 遵守目标站点的合理请求间隔 DOWNLOAD_DELAY 0.5 # 开启日志到文件方便多节点追踪问题 LOG_LEVEL INFO LOG_FILE crawler.log有几个配置值得专门解释BLOOMFILTER_BIT 30代表布隆过滤器使用 2 的 30 次方个 bit也就是大约 1.26 亿个 bit约 160MB 内存。这个容量对于千万级的 URL 去重已经足够宽裕。如果抓的是上亿规模需要调到 33 甚至更大。SCHEDULER_PERSIST True在开发调试期间有个坑你跑完一遍测试重启爬虫它会把旧队列里没消费完的数据继续消费。如果你发现改了爬虫代码之后老是抓到旧任务把 Redis 里对应的 key 删掉再跑。我一般测试时会手动执行redis-cli del spider:public_data:*。DOWNLOAD_DELAY在多节点场景下需要特别注意单机 0.5 秒的间隔10 台节点叠加在一起就是每 0.05 秒一个请求。如果目标站点对这种访问频率敏感你要么把这个值调大要么在中间件里加一个基于 Redis 计数器的全局限速逻辑。后面我会详细说。3.3 爬虫代码从单机改造为分布式这里有一个很多人都会问的问题爬虫代码本身要不要改答案是需要改动的地方极少但有一处必须改——不能再用start_requests()或start_urls直接提交起始请求而要改成从 Redis 队列里读取起始 URL。Scrapy-Redis 的做法是让你写一个start_requests()方法从 Redis 里读出一个个 URL 字符串包装成 Request。# spiders/public_data_spider.py import scrapy from scrapy_redis.spiders import RedisSpider from distributed_crawler.items import PublicDataItem class PublicDataSpider(RedisSpider): name public_data_spider # 在 settings.py 中设置的队列 key redis_key spider:public_data:start_urls def parse(self, response): 列表页解析提取详情页链接 detail_links response.xpath(//a[classdetail-link]/href).getall() for link in detail_links: yield scrapy.Request(urlresponse.urljoin(link), callbackself.parse_detail) # 处理下一页 next_page response.xpath(//a[classnext-page]/href).get() if next_page: yield scrapy.Request(urlresponse.urljoin(next_page), callbackself.parse) def parse_detail(self, response): 详情页解析抽取字段 item PublicDataItem() item[title] response.xpath(//h1/text()).get() item[content] response.xpath(//div[classarticle-content]//text()).getall() item[url] response.url item[crawl_time] int(time.time()) yield item启动方式跟普通 Scrapy 爬虫有一点区别普通爬虫使用scrapy crawl spider_name后会自动从start_urls开始抓分布式爬虫启动后会在 Redis 里轮询等待不会主动开始。你需要手动将起始 URL 压入 Redis 队列redis-cli lpush spider:public_data:start_urls https://example-public-data-source.com/page/1压入之后所有正在运行的节点都会立刻感知到并从共享队列开始消费。如果抓取中途断掉了重新启动爬虫节点它会继续从队列的未消费位置开始抓。3.4 数据管道多节点写入 MySQL 的工程实践数据管道是分布式方案里最容易因为并发写而翻车的地方。多台机器的多个进程同时往一张表里插数据如果每个 item 都单独提交一次事务数据库端的压力会非常大。我的做法是做一个批量攒批 手动提交的 MySQL Pipeline。核心逻辑是先在一个子线程里维护一个数据列表收集一定数量的 item 后一次性执行多行 INSERT同时加上时间阈值兜底100 条或 5 秒到了就刷新一次。# pipelines.py import pymysql import threading import time class BatchMySQLPipeline: 批量写入MySQL攒够 batch_size 条或超过 flush_interval 秒即写入一次 def __init__(self, host, user, password, database, port): self.conn pymysql.connect( hosthost, useruser, passwordpassword, databasedatabase, portport, charsetutf8mb4, ) self.buffer [] self.batch_size 100 self.flush_interval 5 self.last_flush_time time.time() classmethod def from_crawler(cls, crawler): mysql_config crawler.settings.get(MYSQL_CONFIG) return cls( hostmysql_config[host], usermysql_config[user], passwordmysql_config[password], databasemysql_config[database], portmysql_config[port], ) def process_item(self, item, spider): self.buffer.append( ( item[title], item[content], item[url], item[crawl_time], ) ) if len(self.buffer) self.batch_size or (time.time() - self.last_flush_time) self.flush_interval: self._flush() return item def _flush(self): if not self.buffer: return sql INSERT INTO public_data_article(title, content, url, crawl_time) VALUES (%s, %s, %s, %s) try: with self.conn.cursor() as cursor: cursor.executemany(sql, self.buffer) self.conn.commit() except Exception as e: self.conn.rollback() self.logger.error(f写入MySQL失败: {e}) finally: self.buffer.clear() self.last_flush_time time.time() def close_spider(self, spider): self._flush() self.conn.close()几个实践中的心得executemany批量插入比逐条execute快非常多10 万条数据的量级下两种方式耗时差距在 5 倍以上。注意buffer是进程内共享的多线程场景需要加锁。如果单节点内没有开启多个线程跑同一个 pipeline一般没问题最好还是用threading.RLock包一层。close_spider方法里必须做最后一次 flush否则爬虫停止时最后一批不足batch_size的数据会直接丢掉。这个 bug 很隐蔽我是跑完对数据量才发现少了一截。4. 多节点部署与运行时调优4.1 Redis 在爬虫场景下的部署要点Redis 是整个分布式爬虫的大脑它挂了所有节点都会跟着卡住。在部署上我给几个实际经验内存一定要给够。别小看布隆过滤器去重的内存加上请求队列的缓冲最坏情况下也要按你预估 URL 总量加 30% 的余量来规划内存。我习惯在redis.conf里设置maxmemory防止失控膨胀吃满系统内存maxmemory 4gb maxmemory-policy noevictionnoeviction策略意味着内存满了直接报错不会静默淘汰队列里的任务。宁可让爬虫报错停止也不能让它丢掉排队中的请求。开启 AOF 持久化。队列和去重集合都是关键状态如果不开持久化Redis 重启后之前去重的记录全部丢失分布式爬虫做再好的断点续爬也白搭。我在配置里用的是appendonly yes appendfsync everyseceverysec的性能损耗很小对爬虫场景够用。多个节点访问同一个 Redis 实例时要注意 REPL 模式下连接数限制。Scrapy 默认每个节点开多个 Redis 连接4 个节点 × 16 并发就在 Redis 上可能产生几十个连接记得把redis.conf里的maxclients调高一点。4.2 多节点部署与进程管理项目跑起来后部署的思路是这样准备 2~4 台服务器都安装 Python 环境、项目依赖和Scrapy框架只有一台服务器安装 Redis其他节点配置REDIS_HOST指向这台机器在所有节点上分别启动爬虫进程。启动命令跟单机完全一样只需要在每台机器上执行scrapy crawl public_data_spider不过生产环境不建议直接这样前台跑我习惯用supervisor管理每个节点上的进程; /etc/supervisor/conf.d/crawler.conf [program:crawler] command/path/to/venv/bin/scrapy crawl public_data_spider directory/path/to/distributed_crawler userwww-data autorestarttrue stderr_logfile/var/log/crawler.err.log stdout_logfile/var/log/crawler.out.log几个部署经验所有节点的settings.py中SCHEDULER_QUEUE_KEY和SCHEDULER_DUPEFILTER_KEY必须一致否则它们各干各的分布式就失效了。节点数并不是越多越好。目标站点带宽、Redis 吞吐、数据库写入能力三者中哪个先到瓶颈再扩容哪个。我这边实测过一个 500ms 延迟的公开站点2 个节点各 16 并发已经能把带宽吃得很满再往上加节点反而容易被目标站点限流。如果希望某些节点只负责抓取、不参与调度分配可以单独调整该节点上的CONCURRENT_REQUESTS来控制负载。4.3 让分布式爬虫跑得更稳的几组参数跑一段时间后有几个参数需要根据实际数据来调并发数CONCURRENT_REQUESTS单节点并发从 8 调到 32吞吐量确实线性上升但目标站点的响应时间也会恶化。我建议从 8 起步递增观察找到响应成功率开始下滑的临界点再退回一档。超时重试DOWNLOAD_TIMEOUT / RETRY_TIMES准时失败的请求往往比慢请求更消耗资源。DOWNLOAD_TIMEOUT默认是 180 秒太长了。我一般压到 20 秒配合RETRY_HTTP_CODES里包含 500 系列。Redis 阻塞读取等待不直接暴露为配置但影响明显next_request使用brpop时如果队列长时间没有新任务每个节点都会阻塞等待。频率调低一点可以大幅降低无谓的 Redis 轮询。这部分可以在 Redis 客户端侧设置合适的 socket 超时。调度器队列类型Scrapy-Redis 默认是 FIFOSpiderQueue如果改成熟先级模式SpiderPriorityQueue队列底层从 List 换成 Zset支持按请求优先级排序。深爬场景里这个功能很少用到但如果你有先抓重要页面的需求可以直接改SCHEDULER_QUEUE_CLASS来切换。5. 高频故障与排查实录5.1 问题速查表分布式爬虫遇到的问题有相当大一部分是集中在 Redis 和数据库上的。我整理了一份速查表覆盖了我自己遇到和帮别人排查过的典型情况现象可能原因快速排查与解决爬虫启动后一直不抓数据Redis 的 start_urls 队列是空的redis-cli llen spider:public_data:start_urls查看长度为空就手动 lpush 起始 URL多个节点抓到重复数据SCHEDULER_DUPEFILTER_KEY在各节点配置不一致检查每个节点的 settings.py确保去重 key 一致Redis 内存占用持续飙升Set 去重未使用 BloomFilter或队列积压太多确认DUPEFILTER_CLASS是布隆过滤器的类清理积压队列重启爬虫后队列任务丢失Redis 没开持久化或SCHEDULER_PERSIST是 False设置appendonly yes生产环境不要关掉SCHEDULER_PERSIST数据库报连接数超限多节点共用同一个 MySQL 账号且连接没有复用Pipeline 使用连接池或限制每个节点的最大并发写入抓取速度远低于预期下载超时太长慢请求拖累了并发调低DOWNLOAD_TIMEOUT调高RETRY_TIMES并检查目标站点的响应耗时某节点宕机后其它节点正常但吞吐下降宕机节点上存在大量未提交的任务Redis 队列为空但该节点持有锁或正在处理检查 Redis 是否有异常的 key 残留清理后重启节点页面解析字段大量为空可能是触发了目标站点的异常页面、验证页或改版抽样检查原始响应确认页面结构是否和 xpath 匹配5.2 典型故障完整复盘挑一个分布式场景下最容易踩的坑详细说一下爬虫节点崩溃后出现大量重复抓取。事情是这样的某个模拟项目抓取流程跑到了第三阶段数据量在 300 万条左右。某一天凌晨一台节点服务器触发 OOM内核把进程杀掉了。第二天早上看数据发现有将近 2000 条记录完全一致都是同一批详情页。排查过程如下首先检查是否去重失效了。查看 Redis 里dupefilterkey 的SCARD成员数量发现还在增长说明去重集合本身是正常的。查看日志发现崩溃节点在重启之后从 Redis 队列里取出来的请求处理完之后重新把 URL 放回了队列。为什么因为它重启后处理的那个 item 是在进程崩溃前已经解析完、但还没来得及提交给 Redis 去重器的请求。崩溃期间这个请求一直停留在队列里重启后被重新消费了一次。这个问题的根本原因不是去重器有问题而是**处理完和标记去重不是原子操作**。抓取流程中Scrapy 先将请求从队列取出处理完响应后再把新请求写入队列。正常情况下一个请求被取走并处理时其他节点不会再见它。但如果进程在取走→处理→重新入队这个时间窗口内挂了请求就会被打回队列。这是分布式任务系统的老问题至少一次语义at-least-once。应对方法有两个维度接受至少一次语义在数据入库层面做幂等。给 MySQL 的目标表加一个url的唯一索引重复插入时用ON DUPLICATE KEY UPDATE或直接忽略。这是最省事的兜底也是我推荐的做法。ALTER TABLE public_data_article ADD UNIQUE KEY uk_url (url);把去重标记提前到取走时而不是处理完时。也就是调度器在brpop之后立即sadd指纹。这样即使进程崩溃这个 URL 也不会再被其他节点取走。代价是如果处理失败了这个 URL 也不会自动重试需要额外的重试队列。这个方案适合对数据完整性要求高的场景。我个人的默认方案是前者。幂等入库能解决绝大多数由至少一次语义带来的问题而且额外开销几乎为零。6. 分布式爬虫的合规边界与稳定性经验6.1 合规底线不能碰聊技术的同时有些边界问题必须明确。分布式爬虫的并发和分散性天然会给目标站点带来比单机爬虫更大的压力。我在做模拟项目时给自己定的几条纪律也建议所有做爬虫的人参考遵守目标站点的 robots.txt 声明。虽然 robots.txt 不构成法律强制力但它代表了站方对机器访问的明确意愿。尊重它是基本的行业操守。控制访问速率不能把目标站点的服务打挂。分布式爬虫的 QPS 是各节点之和很多人单机配置很保守一上分布式就忘掉了这一点。最好在中间件层通过 Redis 做一个全局限流每次请求前检查当前全局第几秒超出阈值就等。不采集未公开的、需要特定登录权限才能访问的数据。这是合规红线也是很多纠纷的源头。不做验证码识别、不做接口参数逆向。一方面是合规风险另一方面这类工程投入回报比极低不如把精力花在合法的公开数据源上。我见过有的团队为了抢数据把并发开到 64多台机器一起上最后目标站点直接拒绝服务、对外发公告。这种全局性事故既害了自己也败坏了整个行业的名声。6.2 反爬应对UA 池与代理池的工程实现分布式爬虫必然面临一个问题同一批 IP 高频访问很容易被识别成机器行为。常规应对方式有两层第一层随机 User-Agent。用一组常见浏览器的 UA 做成中间件每次请求随机挑选。这个方式能挡住很基础的反爬探测。# middlewares.py import random USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/119.0, ] class RandomUserAgentMiddleware: def process_request(self, request, spider): request.headers[User-Agent] random.choice(USER_AGENTS)第二层代理池。当同一个 IP 单位时间内的请求数超过阈值光换 UA 已经不够必须换 IP。自建代理池的基本思路是定期从免费代理源或自购代理里采集代理用任务线程去验证代理可用性存到 Redis 的setDownloader Middleware 在每次请求前从 Redis 随机取一个代理绑定上去发现请求失败立即把该代理从 Redis 中移除。这个方案的具体代码篇幅比较长核心中间件模块大概是下面这样# middlewares.py (代理池部分) class RandomProxyMiddleware: def process_request(self, request, spider): proxy self.get_random_proxy() if proxy: request.meta[proxy] proxy def get_random_proxy(self): # 从 Redis 代理池随机取一个可用代理 return self.server.srandmember(proxy_pool) def process_exception(self, request, exception, spider): # 代理失效时移除并返回一个全新的请求 proxy request.meta.get(proxy) if proxy: self.server.srem(proxy_pool, proxy) return request这套东西在公开数据站点的抓取上已经足够用了。需要留意的是代理池同样要控制全局频率如果代理换得比目标站点的访问还频繁一样容易被识别。最后再分享一个我在实际操作中很有体会的点分布式架构能不能跑好表面上拼的是代码能力深层次拼的是对数据量和运行成本的预估。你选择的 BloomFilter 参数、Redis 持久化策略、MySQL 批量大小、节点数和并发数全部要基于一个明确的量级预期来定。起步先按最小可用配置跑通再去逐步加节点、调并发比一上来就堆机器靠谱得多。如果这篇文章里的思路能在你下一个爬虫项目里少走一点弯路那就够了。