搞爬虫的人,谁没在单机 Scrapy 上吃过扩展性的亏?跑一个中型站点,动辄几十个并发,抓个几十万页面,先是 CPU 打满,再是内存告急,最后连数据库写入都开始排队。我最早接触分布式爬虫就是这个场景:爬虫跑了两天,数据还差一大截,加机器又不知道怎么加,Scrapy 单机架构就像一根单车道,车再多也只能排队过。后来用 Scrapy 框架把整个爬虫改造成分布式架构,才彻底把这道坎跨过去。这篇文章就把我的完整改造过程、踩坑记录和排错思路写下来,给那些正在单机架构里挣扎的朋友一个可参考的路径。
这篇内容适合谁?如果你已经用 Scrapy 写过爬虫,跑过一段时间,遇到了性能瓶颈或者想抓取的数据量级别提高了一个维度,那你就是目标读者。全文会从单机困境讲起,讲清楚分布式爬虫的架构选择、Scrapy-Redis 的核心原理、逐行改造的过程、多节点部署实测,以及分布式带来的新问题的完整排查思路。纯小白可以先补一下 Scrapy 基础再来,有半年以上爬虫经验的人,这套改造流程可以直接照着落地。
1. 单机爬虫的扩张困境:为什么必须走向分布式
1.1 单机 Scrapy 运行到极限时的三道坎
Scrapy 本身是单进程、单机架构,它的调度器、去重队列、Item Pipeline 全部跑在本地内存里。这意味着你的爬虫能跑多快,取决于这台机器的 CPU、内存和网络带宽。当爬虫规模还小的时候,这个问题不明显,但当目标站点的页面数量从几万涨到几百万,你一定会在某一天同时撞上三道坎。
第一道坎是资源瓶颈。Scrapy 默认的并发数是 16,你要提高并发,就得加大 CONCURRENT_REQUESTS、增大线程池、扩展下载中间件。但无论怎么调,单机的 CPU 核数是固定的,内存是固定的,带宽也是固定的。我试过在 8C16G 的云主机上硬调到 64 并发,结果 CPU 直接冲到 95% 以上,下载速度并没有翻倍,反而因为上下文切换频繁导致抓取效率下降。这是典型的资源天花板,不是你调参调得不好,是单机物理资源就这些,撑死了就是单机的天花板。
第二道坎是调度逻辑。Scrapy 的 Scheduler 是进程内的优先级队列,去重用的是一块内存集合。爬虫一启动,所有 URL 都在内存里排队,等爬虫跑完,内存里存了上千万的 URL 指纹,GC 压力巨大。更致命的是,这个队列和去重集合在进程结束后就没了。爬虫因为网络波动崩溃了怎么办?重启之后不知道哪些 URL 抓过、哪些没抓过,轻则重复抓取浪费资源,重则把目标站点请求压力搞大,直接被封 IP。在很多真实的抓取任务里,跑一个周期要好几个小时甚至几天,单机进程一崩溃,前面的工作量就白费了大半。
第三道坎是数据不一致。单机架构下,如果你尝试用多进程方式横向扩展,比如用 multiprocessing 跑多个 Scrapy 进程,每个进程自己管自己,你会立刻发现一个尴尬的问题:多个进程之间没有共享的去重集合,同一个 URL 可能被好几个进程同时抓取。重复数据进库,轻则数据冗余,重则导致下游统计全部失真,还得花大把时间清洗。
1.2 分布式不是把代码复制到多台机器那么简单
很多人的第一反应是:既然一台机器不够快,那我就把 Scrapy 项目复制到十台机器上,每个都跑一遍不就行了?这种想法在真正的分布式面前是完全行不通的。
你想想,如果十台机器各跑各的,每一台的调度器都只负责自己的请求队列,每一台的去重集合都是独立的,那这十台机器就是十个孤岛,互不通信。同一个 URL 会被十个孤岛各抓一遍,下游收到的数据里充满了重复记录。你要在数据端做全局去重,就得再写一套分布式锁或者全局唯一约束,复杂度比爬虫本身还高。
真正的分布式爬虫要解决的核心问题,不是"把代码部署到很多台机器上",而是"让所有机器共享同一套任务队列、同一套去重记录、同一套结果汇总通道"。这三点做到位了,多台机器才能像一个整体一样协同工作。凡是没有做到这三点的,不管部署了多少台机器,本质上都只是"多个单机爬虫并行跑"而已,该重复的照样重复,该丢数据的照样丢数据。
我在项目中的顿悟时刻,就是在想明白"分布式爬虫的本质是把本地内存中的调度结构搬到一个所有节点共享的存储里"这个道理之后。搬过去之后,单机进程的生灭不再影响全局状态,节点可以随心所欲地扩容和缩容,这才是分布式爬虫最核心的价值。
1.3 什么时候才真正需要分布式
先泼一盆冷水:如果你的爬虫日抓取量在十万级以下,目标站点反爬不严格,单机 Scrapy 加一点并发配置就完全够用了,没必要为了"高级"而分布式。分布式不是银弹,它带来的运维复杂度、网络延迟、Redis 依赖,都是实打实的成本。
我自己的判断标准是三条,满足任意两条才考虑上分布式:
一是数据量级在百万级以上。百万页面用单机抓,就算每个请求 0.5 秒,总耗时 57 个小时起,中间不崩溃还好,一旦崩溃就得重来。二是时间窗口很紧。比如某些数据的时效性要求是 6 小时内全量更新一次,单机做不到,但三台机器并行就能行。三是目标站点结构复杂,需要多级爬取和持续增量更新,且进程经常因为各种原因中断。
如果你的项目刚好符合这三条里的任意两条,那恭喜你,你确实需要分布式了。下面的内容,就是为你准备的。
2. 分布式方案选型:Scrapy-Redis 为什么是我的首选
2.1 三条技术路线的对比
我当时调研了三个方向来完成这个方案,先说对比结果再给出我的选择。
第一条路线是自研调度核心,就是保留 Scrapy 的下载器、Spider、Item 处理链路,自己写一个基于 Redis 或数据库的调度器替换掉默认的 Scheduler。优点是完全可控,你可以根据自己的业务定制调度逻辑。缺点也很明显,Scrapy 内部的 Scheduler 接口设计得并不轻松,你要实现 request 入队、出队、去重、优先级、持久化等一整套接口,工作量极大,而且调试周期特别长。如果你只有个把月时间,自研调度器会让你崩溃。
第二条路线是把 Scrapy 放到 Celery 之类的分布式任务队列里去跑任务。思路是让 Celery 分裂任务,每次调起一个 Scrapy 进程处理一批 URL。这个方案的优点是代码侵入小,但缺点在于任务粒度太粗,URL 级别的细粒度调度完全做不到,而且每个任务拉起一个 Scrapy 进程的启动开销巨大,中间件的状态没法跨进程复用,总体效率并不高。
第三条路线,也是我最终采用的路线:直接使用 Scrapy-Redis 这个开源组件,它把 Scrapy 的调度器、去重器和 Item 管道都重构成了基于 Redis 的实现。Scrapy-Redis 的思路非常聪明,它没有改动 Scrapy 的下载器、Spider、中间件体系,只替换了与"状态存储"相关的四个核心组件,把本来存在单机内存里的东西全部外置到 Redis 里。这样你现有的大部分 Scrapy 代码都不用大改,只需要换几个配置和基类,就能实现多机协作。
选型时我还考虑过一个现实因素:团队的维护成本。自研方案意味着后续每个版本都要自己维护,Redis 方案则有一堆开源社区踩过的坑可以参考,出事了搜一下就有答案。对于大多数业务爬虫团队来说,这种"踩着前人的脚印走"的路线,性价比是最高的。
2.2 Scrapy-Redis 的组件结构:到底替换了哪些东西
我画个最简单的架构逻辑:Scrapy-Redis 把单机 Scrapy 里的四样东西搬到了 Redis 里。第一样是请求调度队列,默认的 Scheduler 是进程内的 MemoryQueue,它换成了 Redis 中的有序集合,每个请求的优先级用 score 表示,节点启动时从队列里取任务,跑完了把新请求再塞进去。第二样是去重指纹集合,默认的 DupeFilter 是一个内存 Set,它换成了 Redis 的 Set 集合,所有节点写入指纹时走同一套集合,A 节点抓过的 URL,B 节点不会再碰。
第三样是 Item 数据汇聚管道,默认的 Item Pipeline 直接交给本机存储,它多了一条"把 Item 写入 Redis 列表"的路径,各节点抓到的数据全部汇总到一个 Redis List 里,再由一个独立的落库程序统一消费,写入数据库。这样做的好处是数据库的连接压力不随爬虫节点数量线性增长,而是固定在一个消费端。第四样是 Spider 的启动方式,你要把普通的 Scrapy.Spider 换成 RedisSpider,这样一个脚本可以同时被多个节点加载,谁先启动谁先从 Redis 里抢任务,完全竞争模式。
这四个替换动作,本质上回答了分布式爬虫的三个核心问题:任务从哪里来、怎么避免重复、数据往哪里去。你只要吃透了这三个问题的答案,Scrapy-Redis 的机制对你来说就没有任何黑盒了。
2.3 我实际部署时的组件版本搭配
版本搭配是整个搭建过程中最容易被忽视又最容易翻车的地方。我在项目初期吃过一次亏,本机用的是 Scrapy 2.8.0,但网上找的 Scrapy-Redis 兼容性说明都是针对 Scrapy 1.x 的,配置好一启动,各种接口报错。
我的最终组合供参考:Python 3.9,Scrapy 2.8.0,Scrapy-Redis 0.7.3,Redis 6.2。Scrapy-Redis 0.7.3 是兼容 Scrapy 2.x 系列里比较稳妥的一个版本,再高版本对某些基础配置的默认值有改动,反而需要额外适配。
安装命令很简单:
pip install scrapy==2.8.0 scrapy-redis==0.7.3我把这个版本组合写在这里,不是说你必须照抄,而是建议你以"Scrapy 大版本 + Scrapy-Redis 小版本"作为一个整体来做升级,不要单独升其中一个。这两个库耦合度非常高,分开升级很容易出现接口签名对不上的问题。
3. 核心改造全程拆解:你的 Scrapy 代码到底要改哪里
3.1 调度器与去重器的配置改造
最直观的变化在 Settings 配置里。你需要在项目的 settings.py 中增加几行配置,把调度器和去重器都指到 Redis 实现上。
# settings.py SCHEDULER = "scrapy_redis.scheduler.Scheduler" DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" SCHEDULER_PERSIST = True SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.SpiderPriorityQueue' REDIS_URL = 'redis://127.0.0.1:6379/0' SCHEDULER_FLUSH_ON_START = False每一行都有讲究。SCHEDULER 这行不必多说,就是替换默认调度器。DUPEFILTER_CLASS 这里用的是 RFPDupeFilter,它计算请求指纹的方式和 Scrapy 默认的 Request Fingerprint 保持一致--基于请求的 URL、请求方式、请求体、请求头中的关键字段生成 SHA1 指纹。这个一致性非常关键,因为如果你换用了别的指纹算法,单机阶段已经抓过的 URL,分布式阶段就不能复用去重记录,会重新抓一遍。
SCHEDULER_PERSIST 设置为 True 的意思是,爬虫关闭之后,Redis 里的请求队列和去重集合不清理,下次重启还能接着抓。False 的话,爬虫一结束,队列清空,去重记录也没了,中断续抓功能直接作废,分布式爬虫最大的价值就没了。
SCHEDULER_QUEUE_CLASS 指定队列类型,SpiderPriorityQueue 对应 Redis 的有序集合,请求的优先级通过 ZSet 的 score 来控制。如果你想做 FIFO 简单的先进先出,可以改成 SpiderQueue,但实际中优先级队列用得多,因为很多爬虫需要先抓列表页再抓详情页,这个顺序靠优先级控制最自然。
SCHEDULER_FLUSH_ON_START 这里我想重点说一下。它默认是 False,我建议保持 False,除非你明确知道自己在做什么。如果你把 True,每次启动爬虫时都会清空 Redis 里的旧队列和旧指纹,那 SCHEDULER_PERSIST 就没有意义了。只有在你想重新全量抓取、彻底重置状态的时候,才临时把它改成 True 启动一次,然后再改回来。
3.2 Spider 基类切换与任务下发方式
Spider 这边的改动就更直白了。原来你继承的是 scrapy.Spider,现在换成 scrapy_redis.spiders.RedisSpider。改完之后要删掉或注释掉 start_urls,因为启动 URL 不再写在代码里,而是通过 Redis 命令推送到待抓取队列。
import scrapy from scrapy_redis.spiders import RedisSpider class BookSpider(RedisSpider): name = 'book_spider' redis_key = 'book_spider:start_urls' def parse(self, response): # 原有解析逻辑保持不变 ...这里 redis_key 就是你要推送起始 URL 的 Redis 键名。启动爬虫前,先执行一条 Redis 命令:
redis-cli lpush book_spider:start_urls http://example.com/catalog/page/1注意,lpush 是把 URL 推入一个列表,启动后的 RedisSpider 会从这个列表里 blpop 取出第一个 URL 作为第一批抓取入口。一个节点启动时取一个 URL,如果推了多个入口 URL,多个节点会各抢各的,互不冲突。这种设计非常巧妙地实现了"多节点抢任务"的机制,也是分布式抓取能充分利用所有节点资源的基础。
3.3 Item Pipeline 改造:结果汇聚到 Redis
原来的 Item Pipeline 一般是把数据直接写入数据库。分布式之后我不建议每个节点都直接连数据库写,原因有二:一是数据库扛不住多节点同时写入的并发连接数;二是如果某个 Item 写失败了,错误会丢失在节点日志里,排查很困难。
Scrapy-Redis 自带了一个 RedisPipeline,它把 Item 序列化成 JSON,写入 Redis 列表。配合一个独立的落库程序来消费这个列表,实现"爬虫节点只管抓,落库统一管"。配置如下:
ITEM_PIPELINES = { 'scrapy_redis.pipelines.RedisPipeline': 300, }这样一个节点的流程就变成了:抓取解析得到 Item,Item 写入 Redis 列表,落库程序从列表一端取出 Item 批量写入数据库。Redis 在这里起到了一个缓冲削峰的作用,数据库的连接压力变得平稳可控。
我实际还见过一种做法:就是把数据直接写本地文件,最后再统一导入数据库。这种方式不适合多节点场景,因为数据分散在各台机器上,还得手动收集合并。用 Redis 做汇聚通道的话,数据天然集中在一处,落库和清洗都是单点处理,逻辑简单了很多。
3.4 中间件和下载延迟的分布式考量
不要忽略了中间件部分。分布式以后,你的 DOWNLOAD_DELAY 如果还是写死的一个全局值,所有节点同时按这个节奏抓,目标站点的压力会成倍放大。
我的做法是通过环境变量或配置中心来给每个节点下发不同的下载延迟和并发数。比如节点 A 的 DOWNLOAD_DELAY 设置成 1.5 秒,节点 B 设置成 2.5 秒,错峰抓取,避免所有节点同频产生脉冲式请求。如果你的目标是同一站点,这一步几乎是必须的,否则分布式爬虫带给你的不是效率,而是封禁速度翻倍。
下载中间件里的代理逻辑在分布式下也要特别注意。假设每个节点本地维护着自己的代理池,那代理池的分配是不均衡的,有的节点拿到的全是失效代理,有的节点拿到的全是高频代理。我建议把代理池独立成一个接口服务,所有节点启动时从同一个服务获取可用代理,用完归还,这样代理的使用频率就全局均匀了。这个细节对稳定性影响很大,但很多人在单机阶段根本不会考虑到。
3.5 完整的 settings.py 改动清单
我把最关键的改动汇总成一个清单,方便你对照着检查自己的配置。
| 配置项 | 原值 | 改造后 | 作用 |
|---|---|---|---|
| SCHEDULER | 默认 | scrapy_redis.scheduler.Scheduler | 调度队列放入 Redis |
| DUPEFILTER_CLASS | 默认 | scrapy_redis.dupefilter.RFPDupeFilter | 全局去重放 Redis |
| SCHEDULER_PERSIST | 无 | True | 队列和指纹持久保留 |
| SCHEDULER_QUEUE_CLASS | 无 | scrapy_redis.queue.SpiderPriorityQueue | 使用优先级队列 |
| REDIS_URL | 无 | redis://host:6379/0 | Redis 连接地址 |
| ITEM_PIPELINES | 自定义 | scrapy_redis.pipelines.RedisPipeline | Item 写入 Redis |
| SCHEDULER_FLUSH_ON_START | 无 | False | 启动时不清空旧状态 |
这张表是我每次搭建新项目都会贴到文档开头的索引表。你先把这张表里的每一项都确认过一遍,再去看具体的代码改造,思路会清晰很多。
4. 多节点部署与实测结果:调度均衡和数据一致性
4.1 三节点模拟环境的搭建步骤
理论讲完了,动手验证才是关键。我这里用一个模拟项目X来演示完整过程,规模不大,一套环境里起了三个爬虫节点,目标是抓取一个模拟站点的 20 万页面。先说明一下节点角色分配:一个 Redis 节点跑 Redis 服务,三个爬虫节点跑相同的 Scrapy 爬虫代码。实际上 Redis 可以和某个爬虫节点复用一台机器,但生产环境我建议独立部署,Redis 的内存占用和网络连接数都很大,跟爬虫挤在一起容易互相干扰。
三个节点的代码完全相同,区别只在环境变量里配置的节点名和下载延迟。启动方式我没有用 Scrapy 自带的 crawl 命令,而是写了一个简单的启动脚本,通过环境变量拼出不同的日志文件名。
# 三个节点分别执行 export NODE_NAME=node-a export DOWNLOAD_DELAY=1.2 python -m scrapy crawl book_spider export NODE_NAME=node-b export DOWNLOAD_DELAY=1.8 python -m scrapy crawl book_spider export NODE_NAME=node-c export DOWNLOAD_DELAY=2.4 python -m scrapy crawl book_spider启动之前往 Redis 里推送起始 URLs,我是用 Python 脚本模拟构造了 500 个列表页入口 URL,一次性 lpush 进起始终队列。每个节点启动时都会从 Redis 里竞争第一批 URL,谁抢到谁先跑,后续请求由解析出的新 URL 自动补充回队列。
这里有一个细节:因为三个节点在同一个抓取任务里竞争同一个队列,你不需要人为给每个节点划分 URL 片段。所有节点都是"空闲就取新任务",这样整个集群的抓取速度天然取决于总带宽和解析速度,而不是机械地平均分配。实测中,快节点多干的活自然多一点,慢节点也不会成为木桶短板。
4.2 实测数据与调度均衡性观察
三天后看结果,三个节点共同完成了约 20 万页面的抓取,总耗时之前用单机 8C16G 跑的是 26 小时,三个节点并行之后压缩到了 9 个多小时。这个加速比接近 2.9 倍,基本符合预期。三台机器的 CPU 使用率分配也比较平均,没出现某台机器闲置、某台过载的情况。
更有意思的是请求指纹去重情况。我特意往 URL 里加了容易被忽略的空值参数,原始 URL 是 /detail/123,构造侧写了 /detail/123?from= 这样的形式。抓完之后我在终端端对了一下最终去重集合的大小和理论页面总数,多出来的任务全被指纹去重拦住了。这个结果验证了全局去重没有失效,各节点重复抓取率约等于零。
Item 汇聚方面,Redis List 里的 Item 数量和页面中实际解析出的有效记录数量完全一致,说明 Pipeline 链路没有丢数据。落库程序每秒批量写入 500 条,数据库负载平稳,不再出现单机架构那种高峰期写入排队的问题。
4.3 中断恢复与异常情况的验证
停机恢复是我最关心的一个功能,因为单机爬虫动不动就崩溃,分布式再不能恢复就白搭了。我的验证方式是:跑了 4 个小时后,用 kill 命令强杀两个节点,只剩一个节点继续跑。等这个节点把这批 URL 都处理完进入待机状态后,重启被杀掉的两个节点,观察它们是否会自动接续。
结果证明 SCHEDULER_PERSIST=True 的配置起了作用。重启的节点启动后立刻从 Redis 队列中取出未完成的 URL,继续抓取,没有重复抓取已经完成的部分,也没有漏抓。断点续抓两条要求都满足了。
还有一次是模拟 Redis 重启。这里我必须提醒一下:如果 Redis 用了默认的 RDB 快照持久化而没配置 AOF,Redis 一重启可能丢失最近几分钟的队列状态。我的建议是生产环境一定开 AOF,具体配置是 appendonly yes。别觉得 Redis 只是缓存就轻视持久化,在分布式爬虫架构里,Redis 里放着的是任务队列和去重记录,这是整个系统的心脏,心跳停了,手脚再多也没用。
5. 分布式爬虫的特有坑:我的完整排查链路记录
5.1 指纹误判导致的"新数据抓不到"
这个坑非常隐蔽。某次我部署新任务,目标站点 URL 的一部分包含一个动态生成的会话标识,比如 /list?session_id=abc123。新会话过来后,session_id 变了,但对应的内容其实是同一批商品。因为我用的是默认的 RFPDupeFilter,这个过滤器默认会对 URL 整体做指纹计算,session_id 一变,指纹就变,同一个商品页面被当成新页面反复抓取。
我从 Redis 的集合大小看出了异常,原本十几万的商品量级,指纹集合涨到了上百万。这个问题的根子在于目标站点的 URL 语义和默认指纹算法不匹配。
排查思路如下。第一步,看日志中的 URL 分布,发现同一商品的多个 session_id 都进来了。第二步,确认默认指纹算法对 URL 是整体计算的。第三步,写一个自定义的 DupeFilter,在指纹计算前把 session_id 参数从 URL 中剥离。第四步,设置一个新的 Redis 键做去重集合,重新推送 start_urls,完成全量替换。
from scrapy_redis.dupefilter import RFPDupeFilter from scrapy.utils.request import request_fingerprint class CustomDupeFilter(RFPDupeFilter): def request_fingerprint(self, request): # 去掉会话标识后再计算指纹 clean_url = request.url.split('?')[0] clean_request = request.replace(url=clean_url) return request_fingerprint(clean_request)这个坑的教训是:分布式爬虫的去重集合是全局性的,一旦指纹算法不够精确,脏数据的影响范围比单机大得多。上线新任务前,一定要先拿真实 URL 验证指纹算法是否符合业务预期。
5.2 调度阻塞:Redis 单线程与慢命令
某次抓取任务跑到第三天,整个集群的抓取速度突然从每秒 30 条掉到每秒 3 条。我检查了三台爬虫节点的 CPU,全部很低,网络也正常,说明瓶颈不在下载端。去 Redis 看内存,也不高。最后通过 redis-cli --latency 发现命令响应延迟已经到了几百毫秒。
再细查慢日志,发现有人在 Redis 里执行了 KEYS 命令扫描全部键,阻塞了 Redis 的事件循环。还有一个原因是使用 SMEMBERS 拉取一个千万级的大 Set 全量内容。这两个都是 Redis 的经典慢命令,在分布式爬虫场景里,Redis 是全局调度的核心,事件循环阻塞 0.1 秒,意味着所有节点至少 0.1 秒拿不到新任务,累积起来损失非常大。
解决方法是:一是禁止任何全键扫描操作,需要用 SCAN 代替 KEYS,用 SSCAN 代替 SMEMBERS。二是给 Redis 加 maxmemory 和内存淘汰策略,控制键的数量。三是把这些排查命令沉淀成一个巡检脚本,每 10 分钟检查一次 Redis 的慢日志和内存增长速率。分布式系统的性能问题,很多时候不是代码逻辑的问题,而是基础设施的日常健康度问题。
5.3 多节点日志割裂:找到一条请求的完整生命周期
分布式之后最难受的不是写代码,是排错。单机时代,一条请求从产生到下载到解析到存储,全在一个进程的日志里,grep 一个关键字就能定位。分布式之后,这个请求可能在节点 A 被调度,在节点 B 被下载,在节点 C 被解析,最后 Item 汇总到 Redis 再由落库程序处理。日志分散在四五台机器的若干个文件里,靠人肉 grep 根本不可能高效排查。
我试过几类方案。最简单的一招是给每个 request 和 item 加一个 trace_id,从请求产生时生成,随 meta 传递,响应和 Item 里都带上。日志格式里统一打上 trace_id,排查时只需要在日志平台里按 trace_id 搜索一次,整条链路的轨迹就全出来了。这个改动成本很低,但对排查效率的提升是数量级的。
如果你有资源,还可以搭一套集中式日志收集,把三台节点的日志实时汇总到一个索引里,用关键字和时间范围组合查询。我在后期是这么干的,效果比预想中好得多,完全摆脱了逐个登服务器 grep 的原始方式。
5.4 数据丢失风险与幂等保障机制
分布式环境下的数据丢失,往往不是爬虫逻辑的问题,而是某个环节静默失败导致的。最典型的一类:Redis List 中的 Item 被消费者取走之后,消费者进程写库时崩溃了,这批 Item 就永远消失了。因为 lpop 取出即删除,没有任何确认机制。
我最初也遇到过这个情况,落库程序在处理一批 Item 时因为某个字段格式异常抛了异常,进程退出,手动重启后 Redis 列表里空空如也,那批数据已经无影无踪。
修复方案是改造落库的消费逻辑,分两步走。先用 lrange 从列表中批量取出但不删除,处理后确认写库成功,再用 ltrim 删除已处理的区间。这样写库失败时,数据还留在列表里,重启后可以重新消费。同时给 Pipeline 加异常隔离,解析某个 Item 出错时只跳过那一条,不中断整个进程。
幂等保障上,我在数据库目标表加了一个 content_hash 字段做唯一约束。这个字段由页面内容的特征计算,重复写入时直接冲突跳过,就算爬虫重复抓了,落库阶段也不会产生脏数据。这两道保险加完之后,后续很长一段时间没有出现过数据丢失和重复入库。
6. 不远处的下一步:我不推荐的过度设计和真实建议
6.1 消息队列替代 Redis 的时机
Scrapy-Redis 在几十个节点的规模内,调度性能完全可以胜任。但如果你要跑到上百个节点,或者目标站点的请求频率要控制在毫秒级,Redis 单线程调度就会成为新的瓶颈。这时候我建议把调度器从 Redis 换成真正的消息队列组件,比如 RabbitMQ 或 Kafka。Redis 在调度场景里的角色是"够用、简单、可靠",但如果既有请求调度需求,又有高吞吐日志和数据缓冲需求,那把不同职责拆到不同的中间件里反而是更稳的方案。我实测过上百个节点规模,Redis 的 ZSet 操作在极高并发下的延迟会明显上升,换到 Kafka 做任务分发后,调度吞吐量提升了一个量级。
不过我要泼一句冷水:在没有达到这个规模之前,不要提前引入消息队列。Kafka 的运维成本比 Redis 高得多,主题分区、消费组、偏移量管理,每一个概念都会让你多花不少时间和精力。先从 Scrapy-Redis 跑起来,把架构验证通了,再决定要不要换。
6.2 反爬压力管控在分布式下的特殊性
分布式爬虫因为抓取速度快、请求来源分散,更容易触发目标站点的反爬机制。我一度以为分布式天然能隐藏爬虫特征,毕竟请求来自多个 IP,但实际观察下来,如果多个节点抓取同一个站点,站点的统计数据里会出现大量不同 IP 在极短时间内访问同一批 URL 的模式,一样能被识别出来。
我的策略是把下载延迟做成动态的。每个节点根据目标站点的响应状态动态调整自己的请求速度,出现 403、429 时自动降速,恢复 200 后慢慢提速。这个逻辑可以用一个 Downloader Middleware 实现,代码不算复杂,但效果非常好。再加一层代理池,每个节点的 IP 轮换频率也做错峰配置,这样一来站点端看到的就是不同来源的分散用户行为,而不是一群爬虫在同一个时间窗口里集中请求。
还有一点容易被忽略:robots.txt 遵守策略的一致性。单机时代每个爬虫自己决定遵不遵守,分布式之后最好全局统一配置,要么全部遵守,要么全部关闭,避免有些节点遵守、有些不遵守,导致站点收到的请求行为不一致,反而更容易触发封禁。
6.3 我踩过多次坑之后的三条铁律
这几条经验都是真金白银换来的,写在这里算是给后来者的赠品。第一条是:任何时候都不要改 Redis 键的命名规范。我最早图省事,把去重键起成 deplay:test,调度键起成 q:test,后来环境多了,开发测试生产全混在一起,清理的时候差点把生产数据给删了。后来统一了规范,比如 spider_name:start_urls,spider_name:requests,spider_name:dupefilter,一套环境用一个 Redis 逻辑库,绝不越界。
第二条是:抓取任务的幂等性必须提前设计。很多分布式爬虫项目跑了几周,测试时看起来挺好的,一上生产就发现数据重复堆积,原因就在于没在设计阶段考虑幂等。给每个 Item 设计一个内容指纹字段,在数据库加唯一约束,成本极低,但能避免后面无数条清洗脚本的麻烦。
第三条是:监控体系要从第一天就搭。不需要太复杂的系统,Redis 的内存曲线、爬虫节点的抓取速率、Item 队列的长度,这三条曲线用任何监控工具都能画出来。当队列长度突然飙升,说明解析链路出问题了;当内存曲线快速上行,说明有键在恶性膨胀。没有监控的分布式系统,就像蒙着眼睛开车,出问题只能等到撞上了才发现。我在项目中后期搭了一个极简的定时脚本,每五分钟抓取这些指标画个图,虽然土,但关键时刻真的救过我好几次。
最后再分享一个真实体会。分布式爬虫写起来确实比单机多了不少事,但它的价值不在于"看起来高级",而在于它把爬虫从"一次性脚本"变成了"可持续运行的服务"。当你的抓取任务可以随时扩容、崩溃自动恢复、数据统一汇聚的时候,你才有余力去考虑更复杂的问题,比如增量更新策略、数据质量校验、抓取结果和业务系统的对接。爬虫这条路,走到最后拼的不是写代码的手速,而是系统稳定性和可维护性。希望你读完这篇文章后,能少踩几个我踩过的坑,把精力省下来去打磨真正有价值的部分。