Scrapy+Redis分布式爬虫:架构设计、核心实现与监控运维实战
Scrapy写单机爬虫很简单但一旦数据量上来、目标站点多了单机瓶颈就会立刻暴露出来。爬得慢了老板催爬得快了IP被封好不容易跑起来的爬虫半夜挂了也没人知道。我做了几年爬虫相关的工作2018年第一次把爬虫从单机改造成分布式踩了不少坑也积累了一套相对成熟的做法。今天把这些经验整理出来把一套基于Scrapy Redis的分布式爬虫从架构设计、核心实现到监控运维完整走一遍。这套方案解决的核心问题是多台机器如何协同工作、URL如何统一去重、请求队列如何共享、出了问题如何快速发现。它适合已经熟悉Scrapy基础、想把爬虫能力横向扩展的开发者也适合公司里需要搭建一套相对规范的爬虫采集体系的团队参考。1. 为什么需要分布式单机爬虫的瓶颈到底在哪先聊聊单机爬虫的瓶颈不然很多人上来就搭分布式最后发现架构复杂度上去了收益却不大。单机Scrapy爬虫的吞吐量上限主要卡在三个地方。第一是CPU和内存Scrapy的引擎、下载器、Item Pipeline都在一个进程里跑解析逻辑再复杂一点比如XPath解析、字段清洗、入库前的格式化单进程的GIL限制就会让多线程变摆设。第二是网络带宽和延迟如果目标站点在国外或者响应很慢一次请求可能就要2到3秒单机即使开满并发实际每秒能抓的页面数也是有限的。第三是IP封禁单机出口IP就一个抓得稍微猛一点就容易触发反爬策略403、429接踵而来。分布式要解决的核心问题很简单让多台机器共享同一个请求队列每个节点从队列里取任务去执行执行完再把新发现的URL放回队列。同时去重操作不能在每台机器上单独做必须由所有节点共享一套去重逻辑否则同一个URL会被多个节点重复抓取既浪费资源又可能被站点拉黑。有人会问那直接把几千个URL按机器数量均分一下各爬各的是不是也算分布式严格来说不算。这种静态切分方式一旦某台机器挂了或者某个节点爬得特别快任务就歪了而且URL之间的依赖关系比如先从列表页提取详情页链接再去抓详情页根本无法处理。真正的分布式爬虫任务调度必须是动态的、有状态的节点之间通过一个统一的中间件通信谁空闲谁拿任务谁挂了其他节点自动接替。用Redis做这个中间件是当前最成熟、性价比最高的方案。Redis本身就是基于内存的键值数据库读写速度快天然支持列表List、集合Set、有序集合ZSet等数据结构正好覆盖爬虫场景的三个核心需求请求队列用ListURL去重用Set请求的优先级调度用ZSet。比起引入RabbitMQ或者Kafka这样的重型消息队列Redis足够轻部署简单又能满足大部分爬虫场景对吞吐量的要求。2. 整体架构设计与技术选型思路分布式爬虫的架构设计核心是拆清楚哪些东西必须共享、哪些东西可以各干各的。必须共享的只有两类数据请求队列和URL指纹的去重集合。请求队列是任务分发的核心所有节点的爬虫引擎都从同一个队列里取Request对象去重集合保证同一个URL在整个集群范围内只会被抓取一次。除此之外爬虫的下载器、解析器、Item Pipeline、代理池客户端都可以在各自的节点上独立运行。架构就这么简单但落地时有一个关键选择用现成的scrapy-redis库还是自己实现一套调度逻辑我个人的建议是第一次做直接用scrapy-redis快速跑通全链路等真正理解了它的原理再根据业务需要定制。scrapy-redis的核心思想就是替换掉Scrapy默认的Scheduler和DupeFilter让这两个组件通过Redis来协同多节点的工作。它做的事情很纯粹Scheduler不再把Request存在内存里而是push到Redis的List或ZSet中DupeFilter不再用内存Set保存已抓取的URL指纹而是通过Redis的Set进行全局去重。但scrapy-redis也有明显的短板它本质上是一个多年前的开源项目更新的节奏比较慢而且它对Scrapy版本的适配、对动态请求的支持都需要自己动手。所以2026年再做分布式爬虫我更推荐在scrapy-redis的基础上自己扩展调度器和去重逻辑把动态网页渲染比如Playwright集成进来做成一套真正贴合公司业务的基础设施。动态页面处理是这个时代绕不开的问题。现在大量站点是Javascript渲染的页面里的内容是前端异步加载的有些关键数据还藏在iframe里面。传统的Scrapy直接发HTTP请求拿到的HTML根本不含这些数据。所以架构设计里需要单独加一层渲染服务要么用Selenium要么用Playwright。前两年我还在用Selenium后来整体切换到Playwright了因为它的启动速度更快、API更简洁而且与Python生态整合得更自然。整体架构可以这样描述由Redis实例提供请求队列和指纹去重两个共享服务多个Scrapy节点通过自定义Scheduler连接同一个Redis节点内使用Scrapy引擎负责下载和解析遇到动态页面或iframe内容时调用Playwright渲染服务取出真实数据再交给Item Pipeline处理。运维侧所有节点的运行状态和抓取指标统一写入Redis或直接暴露HTTP接口由Prometheus采集。这个架构无论是两台机器还是二十台机器扩展起来区别不大只是Redis压力会变大可能需要考虑Cluster模式。3. 从0到1搭建分布式核心3.1 环境准备与基础组件安装先准备三样东西一个Redis实例测试环境单机就行生产环境务必开主从或集群若干台可以运行Scrapy的机器实测两台以上才有意义以及每台机器上统一的Python环境。Redis安装网上教程很多macOS上直接brew install redisUbuntu上用apt install redis-serverWindows上可以用官方提供的MSI包也可以用Docker跑。测试时先不设置密码把bind 127.0.0.1改成0.0.0.0让其他节点能连上来。生产环境必须开密码认证并且配置requirepass和rename-command防止危险命令被利用。Python环境建议用conda或venv每台机器保持一致避免出现“我这跑得好好的你那ImportError”这种尴尬。核心依赖就四个pip install scrapy scrapy-redis redis playwright版本不用刻意追求最新以我目前的经验Scrapy 2.x、scrapy-redis 0.7、redis-py 4.x、Playwright 1.4x 这套组合是比较稳的。确认一下版本避免后面遇到莫名其妙的兼容性问题。3.2 理解scrapy-redis的四个核心组件如果只是照着别人的配置抄一遍遇到问题根本无从下手。先把scrapy-redis的四个核心组件搞清楚后面调试会轻松很多。第一个是Scheduler。它替代了Scrapy默认的Scheduler职责是把Request对象序列化后推入Redis队列以及从Redis队列中取出Request交给Scrapy引擎。默认使用List实现FIFO队列也可以改用ZSet实现优先级队列。它运行的逻辑和单机版Scrapy的调度器是对等的只不过存储介质从内存变成了Redis。第二个是DupeFilter。它替代了默认的去重过滤器把Request的指纹写入Redis的Set。指纹的生成方式是sha1(request_url request_method request_body)本质上就是请求的唯一标识。所有节点共同读写同一个Set自然就实现了集群级去重。第三个是Queue。scrapy-redis提供了SpiderQueue、SpiderPriorityQueue和SpiderStack三种队列实现分别对应FIFO、优先级和LIFO。日常够用但如果需要更复杂的调度策略比如基于domain的队列隔离就得自己重写。第四个是Pipeline。它会自动把爬取到的Item推送到Redis的List中供其他服务消费。这在爬虫和数据处理解耦时很有用但大多数情况下我们更希望自己控制数据的落库流程所以这个Pipeline我一般不用而是自己在Item Pipeline里直接写数据库中间不加Redis这层中转。3.3 核心配置详解在Scrapy项目的settings.py里改这几个配置就完成了分布式的接入# 使用scrapy-redis的调度器 SCHEDULER scrapy_redis.scheduler.Scheduler # 开启调度器持久化 SCHEDULER_PERSIST True # 使用scrapy-redis的去重过滤器 DUPEFILTER_CLASS scrapy_redis.dupefilter.RFPDupeFilter # 指定Redis连接参数 REDIS_HOST your-redis-server REDIS_PORT 6379 REDIS_PARAMS { password: yourpassword, db: 0, } # 队列键名前缀 SCHEDULER_QUEUE_KEY %(spider)s:requests这里有个细节值得注意SCHEDULER_PERSIST True表示爬虫结束后Redis中的队列和去重集合不清理。这意味着重启爬虫后它会接着之前的进度继续抓而不是重新从start_urls开始。这在增量抓取场景下非常有用。但如果你的爬虫是一次性的、希望每次任务都是全新开始就得在启动前手动清理Redis里的key。去重这块我强烈建议先理解RFPDupeFilter的指纹机制不要盲用。它默认对Request的URL、Method、Body取指纹也就是说两个请求只要这三者一致就会被判定为重复。但实际开发中会遇到一个问题有些URL携带了trace参数或随机参数每次访问都会变默认指纹反而无法去重。这时候就需要重写指纹生成方法把无关参数剔除后再生成指纹。3.4 编写一个分布式Spider实例代码层面分布式Spider和单机Scrapy Spider的区别很小最大的变化是不再定义start_urls而是通过start_requests从Redis中读取起始URL。import scrapy from scrapy_redis.spiders import RedisSpider class ProductSpider(RedisSpider): name product_spider redis_key product_spider:start_urls def parse(self, response): # 解析列表页提取商品详情页链接 for href in response.css(a.product-link::attr(href)).getall(): yield response.follow(href, callbackself.parse_detail) # 翻页 next_page response.css(a.next::attr(href)).get() if next_page: yield response.follow(next_page, callbackself.parse) def parse_detail(self, response): yield { name: response.css(h1.product-name::text).get(), price: response.css(span.price::text).get(), url: response.url, }spider写好后先在Redis里往product_spider:start_urls这个key中LPUSH一个URL然后启动爬虫。redis-cli LPUSH product_spider:start_urls https://example.com/products?page1 scrapy crawl product_spider多开几个终端跑同样的命令每个终端就是一个爬虫节点所有节点会从同一个队列里取任务自动实现负载均衡。这就是分布式最直观的体验不需要改任何业务代码加机器就是加节点。3.5 进阶自定义队列和去重策略scrapy-redis默认的FIFO队列在绝大多数场景够用但如果你希望某些类型的URL优先处理就要自己实现一个基于ZSet的优先级队列。我的做法是重写spider_redis.scheduler.Scheduler里的enqueue_request方法根据URL的特征比如列表页URL优先级高、详情页优先级低决定ZSet的score值score越小越优先出队。也可以更暴力一点为不同类型的URL配置不同的队列keyspider内部根据URL类型分发到不同的队列。第一种方案更优雅第二种方案更便于排查。实际项目里我是先用第二种方案跑通的因为出了问题可以清楚看到哪个队列堆积了。去重策略也需要定制。默认指纹把整个URL都算进去了但很多站点URL里会拼接来源参数、渠道参数、时间戳这在去重视角下是噪音应该剔除。下面这个示例在指纹生成前把query中的trace_id参数移除from scrapy_redis.dupefilter import RFPDupeFilter from w3lib.url import url_query_cleaner class CustomDupeFilter(RFPDupeFilter): def request_fingerprint(self, request): cleaned_url url_query_cleaner( request.url, parameterlist[trace_id, utm_source, utm_campaign], removeTrue, ) new_request request.replace(urlcleaned_url) return super().request_fingerprint(new_request)这样从源头控制了去重粒度。实际运行中去重集合会越来越大需要注意Redis的内存水位适时清理长期不用的key或者对去重集合设置合适的过期策略。4. 动态页面与iframe内容的采集处理现在再回到那个让无数爬虫工程师头疼的问题目标站点是动态渲染的或者核心数据藏在iframe里。用Scrapy直接抓会拿到一个个空壳HTML根本解析不出数据。我在2024年之前一直用Selenium但Selenium太重了每次启动浏览器、加载驱动都很耗资源多开几个实例内存就报警。后来全面切换到了Playwright配合Scrapy使用后效果提升很明显。4.1 将Playwright渲染能力集成到Scrapy中集成方式并不神秘核心思路是把Playwright的渲染能力封装成Scrapy的Downloader Middleware当请求被标记为需要渲染时中间件拦截请求用Playwright去加载页面渲染完成后再把完整的HTML响应返回给Spider。先写一个简单的Downloader Middlewareimport asyncio from playwright.async_api import async_playwright class PlaywrightMiddleware: def __init__(self): self.browser None classmethod def from_crawler(cls, crawler): middleware cls() crawler.signals.connect(middleware.spider_opened, scrapy.signals.spider_opened) crawler.signals.connect(middleware.spider_closed, scrapy.signals.spider_closed) return middleware async def process_request(self, request, spider): if request.meta.get(render) is not True: return None async with async_playwright() as p: self.browser await p.chromium.launch(headlessTrue) page await self.browser.new_page() await page.goto(request.url, wait_untilnetworkidle) html await page.content() await self.browser.close() from scrapy.http import HtmlResponse return HtmlResponse( urlrequest.url, bodyhtml, encodingutf-8, requestrequest, )当然这只是一种简化的同步写法实际生产环境里更推荐用Playwright的异步API配合Scrapy的异步引擎并且通过连接池复用浏览器实例否则每个请求都启动一个浏览器性能会非常差。4.2 处理iframe中的异步数据iframe嵌套是反爬界和前端界“联手”搞出来的另一个麻烦。很多页面的关键数据并不直接出现在主文档中而是通过iframe加载的。如果要用Playwright处理思路是先定位iframe元素再切换到iframe的frame对象中去获取内容。async def extract_from_iframe(page): frame_element await page.query_selector(iframe#content-frame) if frame_element: frame await frame_element.content_frame() content await frame.inner_text(div.product-info) return content return None在使用Playwright时有一个容易踩的坑wait_untilnetworkidle在部分站点永远不会触发因为页面一直在发心跳请求或者埋点请求。实测下来改成wait_untildomcontentloaded再配合特定的元素等待条件比死等networkidle要稳定得多。在封装中间件时request.meta里可以透传额外的等待配置比如指定某个选择器出现后再返回响应这样比单纯用计时器可靠。4.3 动态渲染场景下的去重与队列设计加了动态渲染之后队列的设计也要跟着调整。动态渲染请求比普通HTTP请求慢得多如果直接把所有请求混在一个队列里动态请求会堵住后面的大量普通请求。我的做法是拆两个队列一个普通请求队列一个渲染请求队列。Spider发现一个URL需要动态渲染时不是直接yield Request而是把它放到渲染队列里由专门的渲染节点消费。普通请求和渲染请求走不同的Scheduler流程互不干扰。这一层的实现需要写一个自定义Scheduler根据Request的meta信息决定推入哪个队列。同理去重策略也要跟上。同一个URL静态请求和渲染请求抓到的内容可能是不同的比如静态页面有部分数据、渲染后才有全量数据所以去重指纹应该把“是否需要渲染”这个维度也考虑进去。我自己的实现是在指纹字符串中拼接一个前缀static:{url}和render:{url}分别去重互不影响。5. 爬虫监控与可视化运维实战分布式爬虫的监控运维是这个项目的重头戏。爬虫跑起来以后最怕的就是出了问题你不知道等发现时已经丢了几万条数据或者被目标站点封了IP导致整体断裂。我早期吃过这个亏后来逐步搭建了一套基于Prometheus Grafana的监控体系再加上一些定时巡检的脚本才算真正睡了个安稳觉。5.1 采集爬虫实时运行指标Scrapy框架自带了一个StatsCollector会统计爬虫运行时的大量关键指标比如downloader/response_count已下载的响应数downloader/response_status_count/200按状态码分类的响应数item_scraped_count已抓取的Item数log_count/ERROR错误日志数dupefilter/filtered被去重过滤的请求数默认情况下这些统计信息存在内存里爬虫结束时打印到日志里。但在分布式场景下每个节点的stats都是独立的我们需要把它们统一收集起来。我常用的方案是写一个自定义扩展Extension每隔一定时间把当前节点的stats推送到Redis然后由一个独立的Exporter从Redis中读取这些指标暴露给Prometheus。这里的代码不打算展开贴全核心思路是监听spider_idle和定时器事件将stats序列化后写入Redis的Hash结构中例如key为crawler_stats:{spider_name}:{node_id}。Prometheus的exporter再将这些实时数据转成指标格式。5.2 Prometheus监控指标设计监控指标的设置归根结底要回答三个问题爬虫还活着吗爬得快吗出错了吗围绕这三个问题我定义了以下核心指标指标名类型说明crawler_request_totalCounter累计请求数按spider、节点、状态码维度crawler_item_totalCounter累计抓取Item数crawler_error_totalCounter累计错误数按错误类型crawler_progressGauge当前已完成URL数占队列总任务数的比例crawler_queue_depthGaugeRedis中待处理请求队列的深度crawler_node_aliveGauge节点存活状态1为存活这些指标里最值得关注的是crawler_queue_depth和crawler_progress。crawler_queue_depth能反映队列积压的情况如果深度持续大于0且不下降说明爬虫处理速度跟不上URL发现速度或者目标站点响应极慢需要增加节点。crawler_progress则用来判断一个批次的任务是否接近完成。5.3 日志收集与异常告警日志是最朴素的排障手段。但分布式环境下日志分散在各节点上排查问题时要一台台登录服务器看日志非常痛苦。建议搭一套集中式日志所有节点的日志统一输出到JSON格式通过Filebeat写进Elasticsearch再用Kibana做可视化查询。如果嫌这套ELK太重也可以用Loki Grafana的组合部署成本更低。告警规则我建议至少覆盖以下几个场景节点心跳丢失超过5分钟属于节点宕机5分钟内错误日志数量超过阈值可能是反爬触发或解析逻辑异常队列深度持续超过某个值30分钟不下降说明任务堆积单个节点抓取速率低于正常水平告警方式用Alertmanager对接企业微信、钉钉或者邮件实操时根据团队协作习惯选择。5.4 容器化部署与弹性扩容企业级实战容器化部署几乎是标配。分布式爬虫的节点天然适合Docker部署Dockerfile写好后通过docker-compose或者Kubernetes就能一键拉起一批节点。这里分享一个docker-compose的简易示例version: 3 services: redis: image: redis:7.0 ports: - 6379:6379 command: redis-server --requirepass yourpassword volumes: - redis_data:/data crawler-worker: build: . depends_on: - redis environment: - REDIS_HOSTredis - REDIS_PORT6379 command: scrapy crawl product_spider deploy: replicas: 3需要扩容时直接改replicas数量再docker-compose up -d新节点自动加入分布式队列开始工作。缩容时注意不要突然kill掉正在处理请求的节点尽量等当前Request处理完再退出。我们可以在Dockerfile的启动命令里加一个优雅退出的逻辑#!/bin/bash trap kill -TERM $PID TERM INT scrapy crawl product_spider PID$! wait $PID用这种方式确保容器收到停止信号后等爬虫处理完正在执行的请求再退出避免丢失任务。6. 常见问题与排查技巧实录搭建和运维分布式爬虫的这几年踩过的坑能写满一个小本子。我挑几个典型的按“现象-原因-解决方案”的方式整理一下这些都是网上很少能查到的实战经验。问题一节点启动后不消费Redis队列里的任务现象多个节点通过scrapy crawl product_spider启动但Redis队列里的请求数不减少日志也没有任何下载操作。原因Spider的name与Redis key不匹配。scrapy-redis的Scheduler会按照spider.name拼接队列key如果你的spider名称写错Scheduler要么找不到队列要么连接到错误的key上。解决用redis-cli keys *查看实际生成的key确认队列名字是否正确。最常见的低级错误是spider名称为product但redis_key定义为product_spider:start_urlsScheduler创建的队列却是product:requests。问题二两个节点同时抓到了同一个URL现象数据库里出现重复数据而且是同一时间点由不同节点抓到的。原因去重和执行之间有一个时间窗口。节点A从Redis队列中取出Request后先去查去重集合发现没有然后开始下载在它把指纹写入去重集合之前节点B又取了同一个Request再次查去重集合还是发现没有就会导致重复下载。这个问题在队列消费速度较快时尤其容易发生。解决处理办法是把“写入去重集合”这个操作提前到请求出队时就执行而不是等Request执行完再写。scrapy-redis默认是取Request时不写dupefilter而是执行完成后才写这就有漏洞。自定义Scheduler时在dequeue_request阶段先执行一个add指纹的操作用Redis的原子性确保指纹只有一个节点能写入成功。写入失败的节点说明该URL已被其他节点抢走就不用执行了。问题三Redis连接池耗尽爬虫大面积超时现象爬虫运行一段时间后日志大量报Redis连接超时系统整体吞吐量骤降。原因每个Scrapy节点默认创建的Redis连接池比较小并发高了之后连接不够用。加上key越来越多Redis本身也在变慢。解决调大Redis客户端连接池配置。redis-py的redis.Redis默认连接池是50个连接我把max_connections改成200后情况有所缓解。同时把Redis的timeout和tcp-keepalive参数调一下避免长时间空闲的连接被误判为死连接。问题四Playwright异步任务在Scrapy的Twisted事件循环中报错现象在Scrapy中集成Playwright后运行时不断出现RuntimeError: There is no current event loop in thread MainThread。原因Scrapy基于Twisted使用自己的事件循环Playwright的异步调用需要与之兼容。直接混用asyncio.run()会导致事件循环冲突。解决用grequests那样的方式在独立线程中运行Playwright通过线程间通信把结果传回Twisted或者在Scrapy的twisted reactor中直接集成asyncio事件循环但这对框架理解要求比较高。我的建议是走独立线程方案简单可靠不容易引入框架级兼容问题。问题五爬虫节点挂了Redis队列里的任务会丢吗现象一台节点宕机后原本由它负责处理的请求不见了队列深度跳崖式下降。原因如果节点的调度器启用了持久化Request已经从Redis队列取出但在内存中尚未执行完节点挂了以后这部分请求就丢了。解决队列中已经poll出去的Request在节点宕机时确实会丢失无法主动找回。缓解方案是启用Scrapy的retry中间件同时把RETRY_HTTP_CODES配置覆盖到超时和连接错误另外在应用层做一层兜底把抓取成功的URL写入索引库下次增量爬取时重新补种缺失的URL。这些坑看起来很多但踩过一次之后解决方案基本都能沉淀成规范化的配置和代码。分布式爬虫的运维重点不在于“出了问题能修”而在于“问题还没暴露就被监控发现并且有预案”。最后的经验之谈如果你刚接触分布式爬虫我建议不要一开始就追求复杂架构。先在两台机器上把一个最简单的scrapy-redis跑通亲手验证同一个爬虫任务能分到不同节点执行。然后再逐步加入动态渲染支持、自定义队列、监控告警。我见过很多团队一上来就上Kubernetes 一堆中间件最后发现连基础的队列消费都理不清反而不如小步快跑稳。另外分布式爬虫本质上不是“爬虫Redis”这么简单它更接近一个分布式任务调度系统只是任务类型恰好是网页采集。把调度、去重、监控、可靠性的思路吃透哪怕以后不做爬虫了这套方法论迁移到其他分布式系统开发中依然适用。最后分享一个实用的小习惯给每个节点打上唯一标识在日志和监控指标中都带上节点ID。这样排查问题时你能立刻定位是哪个节点出了问题而不是在几十台机器里大海捞针。这个看似不起眼的细节在报警频繁的生产环境里能省下大量时间。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →