尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Scrapy+Redis分布式爬虫改造实战:从单机到多节点部署

Scrapy+Redis分布式爬虫改造实战:从单机到多节点部署 做了几年爬虫大部分时间都在跟 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 还是 parseritem 还是 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_password10.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/electronicsSpider 侧代码会长这样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_size2**32, hash_count7): 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或者mmh3bitarray组合我这里展示的是原理。关键点是布隆过滤器的位数组大小和哈希函数数量直接影响误判率和内存占用千万别随手填一个 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 分布式爬虫入门”的问题核心思路其实就是这几个共享队列、共享去重、共享状态。把这三点吃透了后面的优化都是往这个框架里填东西而已。
返回列表