ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Scrapy+Redis分布式爬虫架构落地:从单机瓶颈到多机协同实战

Scrapy+Redis分布式爬虫架构落地:从单机瓶颈到多机协同实战 做爬虫这行单机Scrapy跑久了都会遇到同一个尴尬代码逻辑没毛病但吞吐量就是上不去。我之前有个项目要抓全站商品数据单机8核16G的机器跑了一晚上才抓了不到40万条Redis队列里积压的请求还动不动上百万。排查后发现CPU和带宽都远没跑满瓶颈全卡在网络握手、DNS解析和单队列读写上。后来把架构改成Scrapy Redis的分布式方案三台机器的采集速度直接翻了接近一倍积压队列也能稳定消化。这篇文章就把这套方案的完整落地过程讲清楚。适合已经会写基础Scrapy爬虫、但对分布式方案还停留在听说要用Redis阶段的读者也适合已经在用Scrapy-Redis但经常踩坑、想深入理解原理的人。我会从架构选型讲到具体配置再把动态页面的处理、Extensions扩展机制、以及我踩过的那些坑一并整理出来。1. 整体架构设计与分布式方案选型1.1 Scrapy单机架构的瓶颈在哪Scrapy底层基于Twisted异步框架单机的并发能力其实不弱。默认的CONCURRENT_REQUESTS调到32甚至64每个请求占着线程的事件循环网络IO不阻塞CPU。那瓶颈到底在哪第一个瓶颈是网络握手。Web服务器对同一IP的并发连接是有限制的单机出口IP就一个爬取同一站点时TCP连接数一多就会被限流甚至封禁。第二是DNS解析。就算系统有缓存大量不同域名的请求同时触发DNS查询解析延迟非常可观。第三是去重和调度。Scrapy自带的去重是基于内存的RFPDupeFilter单机运行的Spider实例只在进程内共享指纹想要多机协同去重这套机制完全帮不上忙。换句话说单机Scrapy的瓶颈不是框架本身而是一个进程只能在一个网络出口上干活这个物理限制。分布式要解决的就是两件事多台机器协同调度以及全局去重。核心思路是把调度队列和请求指纹过滤从各个爬虫进程内部抽出来放到一个所有机器都能访问的中央组件里。社区里最常用的中央组件就是Redis配合Scrapy-Redis这个插件来替代Scrapy原生的调度器和去重器。1.2 社区主流分布式方案怎么选先说结论如果不是千万级以上的数据量不用自己造分布式调度器。社区里现成的方案已经比较成熟主要有三个方案实现原理适用场景优缺点Scrapy-Redis用Redis列表作为请求队列用Redis Set做全局去重Spider共享同一个队列中小规模分布式采集团队快速落地简单好上手生态成熟但不支持请求优先级队列内存占用较高Scrapy-Redis-Bloomfilter把去重逻辑从普通Set换成Bloom Filter结构数据量大、URL去重集超过千万级别的场景大幅降低Redis内存开销但存在一定误判率自研调度器自己写Scrapy Scheduler对接RabbitMQ或Kafka大规模任务编排、请求优先级、动态限速等复杂需求扩展性强、可定制化程度高但开发和维护成本很高我做项目时选的是Scrapy-Redis-Bloomfilter。原因很直接目标站点的URL数量级在千万以上如果用普通Set做去重每个URL指纹算25字节一千万条就要占250MB内存Redis虽然撑得住但没必要浪费。Bloomfilter方案的问题在于有误判率但对URL去重来说偶尔丢弃一两个重复URL完全不影响数据完整性。1.3 分布式架构的数据流设计整体数据流是这样的每台机器上的Spider从Redis队列里取一个请求发送HTTP请求解析出新的URL再push回Redis队列。Scheduler被替换成Scrapy-Redis自带的Scheduler每次请求传递时都要查一次Redis里的去重Set。Item Pipeline照常落在各台机器本地也可以统一写入同一个MySQL或MongoDB。这个架构里有几个细节值得注意Redis必须开持久化。AOF和RDB至少开一个否则队列崩了所有待抓取请求全部丢失。我跑了一晚上数据发现队列空了排查后才发现是因为Redis重启过一次AOF没开内存队列直接清空。Redis的阻塞读取要配timeout。Scrapy-Redis里Spider默认通过blpop阻塞读取队列阻塞时间默认是REDIS_START_URLS_AS_SET相关的参数。实际操作用默认就好但连接池要设置合理否则机器多了Redis连接数会爆。每台机器要错峰启动。如果五六台机器同时启动Redis瞬间会涌入大量请求下游站点响应超时的概率会明显增加还容易触发反爬。2. 环境搭建与核心配置实操2.1 Redis部署和Python环境准备既然选了Scrapy-Redis-Bloomfilter这套方案先把环境准备清楚。Redis就按标准装法来Linux服务器上直接apt install redis-server或yum install redis就够用。需要注意的配置是maxmemory和maxmemory-policy。如果Redis内存被其他业务占满去重Set和请求队列全被淘汰后果很严重。我通常设成maxmemory 4gb淘汰策略设成noeviction——宁可不写入也不能把已有队列和指纹清掉。Python侧建议用虚拟环境管理依赖python3 -m venv venv source venv/bin/activate pip install scrapy scrapy-redis scrapy-redis-bloomfilter playwright playwright install chromium这里有个坑提醒一下scrapy-redis和scrapy-redis-bloomfilter要是直接用最新版可能会和Scrapy 2.x以上版本有兼容性警告。目前社区比较稳妥的搭配是Scrapy 2.5到2.9之间别盲目追新。2.2 Scrapy项目改造为分布式模式改造一个现有项目很简单核心就改三个文件。第一是settings.py把调度器和去重器替换掉# 使用Scrapy-Redis的调度器 SCHEDULER scrapy_redis.scheduler.Scheduler # 使用Bloomfilter去重 DUPEFILTER_CLASS scrapy_redis_bloomfilter.dupefilter.RFPDupeFilter BLOOMFILTER_HASH_NUMBER 6 BLOOMFILTER_BIT 30 # Redis连接配置 REDIS_URL redis://:yourpassword10.0.0.11:6379/0 # 队列调度方式优先级队列 SCHEDULER_QUEUE_CLASS scrapy_redis.queue.PriorityQueue SCHEDULER_PERSIST TrueSCHEDULER_PERSIST True表示爬虫结束后Redis队列不清空下次启动可以继续接着跑。但有个现象要提前知道这个配置下Spider不会因为队列空了就自动退出因为Redis那边永远可能还有新的请求进来。想让它抓完自动停要配合自定义的Idle扩展或者直接在Spider里判断请求数量和当前URL数量一致后抛CloseSpider异常。第二是Spider的基类替换。原来继承scrapy.Spider分布式模式下改成继承RedisSpiderfrom scrapy_redis.spiders import RedisSpider class ProductSpider(RedisSpider): name product_spider # 指定从哪个Redis key里取第一批起始URL redis_key product_spider:start_urls启动前先把种子URL塞进Redisredis-cli lpush product_spider:start_urls https://example.com/sitemap.xml第三是Pipeline需要加一个Item去重逻辑吗不需要。URL去重已经在调度器层面做掉了Pipeline只需要处理入库逻辑。真要防重复数据在数据库表里加唯一索引更靠谱。2.3 多机协同启动与状态验证配置完成后在两台机器上各自启动爬虫scrapy crawl product_spider正常情况下两台机器的日志都会显示Scheduler: processing request from redis同时Redis的llen值会动态变化。我验证架构是否生效的方式是直接去Redis里看队列和去重Set的变化redis-cli llen product_spider:requests redis-cli hlen product_spider:dupefilterllen在爬虫跑起来后能维持在一个相对稳定的值说明消费和生产做到了平衡。如果只增不减说明解析出来的URL数量远大于实际抓取速度要么调大并发要么检查解析规则是不是产生了大量无意义URL。3. 动态页面、Extensions与扩展机制解密3.1 用Playwright补齐动态渲染和iframe的短板套上分布式架构后有一个躲不开的问题Scrapy是纯HTTP请求框架默认拿不到浏览器渲染后的DOM。而现在的目标站点随便一个详情页都可能是Vue或React渲染数据全部动态加载甚至还有藏在iframe里的表格和图表。用纯response.xpath()抓这种页面结果就是空列表还容易让人误以为是解析规则写错。处理动态页面我推荐scrapy-playwright插件。它把Playwright的浏览器控制能力接进了Scrapy的Downloader中间件里这样不仅能用Scrapy的并发模型管理请求也能享受Playwright对浏览器执行环境的支持。配置也简单# settings.py DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, } PLAYWRIGHT_LAUNCH_OPTIONS { headless: True, args: [--no-sandbox, --disable-dev-shm-usage] } PLAYWRIGHT_BROWSER_TYPE chromium需要渲染的请求在爬虫里这样声明yield scrapy.Request( urlurl, callbackself.parse_detail, meta{ playwright: True, playwright_include_page: True, } )在parse_detail里通过meta取回页面实例可以对iframe做深度操作async def parse_detail(self, response): page response.meta[playwright_page] # 等待内部iframe出现 await page.wait_for_selector(iframe[data-iddetail-frame]) frame page.frame_locator(iframe[data-iddetail-frame]) # 等到iframe内某个关键节点渲染完成 await frame.locator(.product-info).inner_text(timeout10000) # 直接提取iframe内部的文本内容 iframe_text await frame.locator(.product-info).inner_text() yield { title: iframe_text, }这是scrapy-playwright目前处理iframe最顺手的写法。需要注意playwright_include_page开启后页面对象用完要手动关闭否则浏览器标签页越来越多最后内存耗尽。正确做法是在回调结束前执行await page.close()或者在使用完页面后把meta里的page实例主动释放。3.2 这些Scrapy Extensions到底是什么很多人在项目的settings里见过EXTENSIONS这个配置项但不太明白Extensions到底承担什么角色。简单说Extensions就是Scrapy框架层面的全局钩子它不像Downloader Middleware那样作用于单个请求而是监听全局事件比如Spider启动、Spider idle、请求被调度、Item被生成这些生命周期信号。Scrapy内置的Extensions有很多Stats Collection负责采集统计数据Telnet Console老版本负责远程调试Log Stats负责定期打印抓取进度。如果你在settings里看到EXTENSIONS { scrapy.extensions.telnet.TelnetConsole: None, scrapy.extensions.logstats.LogStats: 50, }None表示禁用内置扩展后面的数字代表扩展的加载优先级数字越小越先加载。自定义Extension其实非常容易本质是一个用来接收signal的类from scrapy import signals class RedisQueueMonitor: def __init__(self, stats, redis_conn): self.stats stats self.redis_conn redis_conn classmethod def from_crawler(cls, crawler): ext cls(crawler.stats, redis.Redis.from_url(crawler.settings[REDIS_URL])) crawler.signals.connect(ext.spider_idle, signalsignals.spider_idle) return ext def spider_idle(self, spider): # 当Scheduler认为没有待处理的请求时触发 pending self.redis_conn.llen(f{spider.name}:requests) self.stats.set_value(redis/pending_requests, pending)我当时写这个Extension的初衷很简单分布式环境下单机日志里看到Spider idle不代表整体任务结束了可能别的机器还在往队列里丢请求。在spider_idle里主动检查Redis队列长度如果大于0说明还有其他机器在生产URL当前这台就不能被认为是抓完了。Extensions的价值在于它能让你在Scrapy框架最关键的节点插入自己的逻辑又不污染Spider代码。监控、告警、优雅退出、自动扩容脚本全都可以用这套机制实现。3.3 分布式状态与队列监控的落地细节一旦上了分布式原来一眼就能看懂的日志里的结束标志就失效了。我现在会在每台机器上额外跑一个小进程通过Redis的监控数据来判断集群整体状态。主要看三个指标指标Redis Key含义待处理请求数{spider_name}:requests队列中还没分配的URL数去重指纹数{spider_name}:dupefilter最近去重Set里累计的指纹数量当前正在爬取的请求数{spider_name}:requests的瞬时变化率消费速率是否波动这里有个很实用的监控技巧不要把Redis的实时队列长度当成准确的剩余任务量因为一个请求发出去之后要等Spider回调执行完、item到Pipeline落地、子Request全部生成完毕后这个请求才算真正处理完。监控吞吐量时更重要的是看增量——比如从llen里每分钟取样一次算出差值才能得到真实的消费速率。4. 常见问题与排查技巧实录4.1 部署后发现爬虫不动了队列积压却很大这个现象是分布式爬虫里最容易遇到的表现形式为Redis队列明明有几万条URL但所有机器的Spider日志全部停在Receiving response没有新请求产生。排查顺序很重要先看每台机器的CONCURRENT_REQUESTS和DOWNLOAD_DELAY配置分布式环境下这两项决定了全集群对目标站的请求频率。如果原先单机设置的DOWNLOAD_DELAY是3秒三台机器并发后平均每秒钟发出的请求是1次/台对目标站的压力会明显增大容易被限流。看日志里有没有Retrying或Ignoring字样。如果大量请求被重试且返回了403或429说明目标站已经在反爬干预了不是爬虫代码本身的问题。检查每台机器的时间是否同步。分布式去重用的是请求指纹如果系统时间差了太多某些Http头生成规则会不稳定导致不同机器对同一个URL算出的指纹不一致反而绕过了全局去重。最有效的一招是先把CONCURRENT_REQUESTS降到每台16再逐步往上加加到哪台机器开始出现大量429就说明到了这台机器的极限。这个极限不是固定的取决于目标站的限流策略和你用的代理池质量。4.2 去重失效同一个URL被反复抓取分布式环境下去重失效的排查方向跟单机完全不同。如果Redis里llen不变但日志里不断输出同一批URL先确认DUPEFILTER_CLASS是否替换成功。很多人改完settings后忘了重启进程或者把settings.py的配置写到了默认的settings结构里导致实际没生效。另一个常见原因是Request的URL构造不一致。例如有的地方把URL写成了https://example.com/foo?page1另一个地方生成了https://example.com/foo?page1#comment。Scrapy的Request指纹计算默认包括URL和method等参数但Query参数的顺序也会影响指纹。两个URL看起来一样实际fingerprint完全不同全局去重形同虚设。解决办法是在Spider里做统一的URL规范化from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode def normalize_url(url): parsed urlparse(url) query sorted(parse_qsl(parsed.query)) normalized_query urlencode(query) return urlunparse(parsed._replace(querynormalized_query))所有新增的Request都经过这个方法再返回给调度器基本上能杜绝这类伪重复。4.3 iframe页面内容抓不全、超时严重动态页面加iframe的场景最容易遇到的问题是白屏或超时。发布HTML里的init逻辑错误、资源加载慢、网络阻塞都会让页面在指定时间内等不到最终内容。我建议分三层来处理等待策略升级。wait_for_selector尽量选择iframe内部真正出现的内容而不是iframe框架本身的标签。等外壳是等不到数据的反而会因为超时错误误判页面结构变了。合理配置超时。Playwright默认的timeout是30秒但Scrapy里DOWNLOAD_TIMEOUT默认才180秒。如果页面里嵌入的大图很多瓶颈就在图片加载上。但抓数据不一定要等图片完全渲染用await page.locator(selector).inner_text()只拿文本内容比完整等待load事件快很多。无头浏览器伪装别裸奔。直接用默认Chromium实例去抓稍微严一点的站点大概率被识别。常见做法是固定UA、隐藏WebDriver特征PLAYWRIGHT_LAUNCH_OPTIONS { headless: True, args: [ --no-sandbox, --disable-blink-featuresAutomationControlled, ] }在启动浏览器时注入page.add_init_script(Object.defineProperty(navigator, webdriver, {get: () undefined}))能有效规避一部分基础的WebDriver检测。4.4 Redis连接数爆满、内存异常增长Scrapy-Redis的每个Spider进程都会建立一条Redis连接。如果一台机器同时跑多个Spider再加上每台机器一堆Worker进程Redis的连接数很容易突破默认上限。这个问题的根源常常出在REDIS_CONNECTION_POOL没设置成共享连接。可以在settings里强制使用统一连接池REDIS_CONNECTION_POOL { max_connections: 20, socket_timeout: 10, socket_connect_timeout: 10, }内存异常增长这件事除了Bloomfilter的位数组偏大还有可能是SCHEDULER_QUEUE_CLASS选择不当。PriorityQueue会维持一个zset请求多的时候Redis的写入和排序压力都很大。如果项目对抓取顺序不敏感直接用FIFO队列就行SCHEDULER_QUEUE_CLASS scrapy_redis.queue.FifoQueue在千万级请求的场景里FifoQueue比PriorityQueue省几十GB的主内存都有可能。5. 性能调优与爬虫稳定性的经验汇总分布式爬虫上线阶段最怕的不是抓得慢而是抓得太猛把目标站反爬触发导致后面一整天都在处理被封IP的问题。下面这几条经验是从实际踩坑里总结出来的强烈建议提前设计好。第一件要做的事是压测并量化单机的极限。每台机器在一个目标站上的合理并发并不是拍脑袋定的。我习惯的做法是写一个小脚本只请求一个列表页逐步把并发从8加到64记录响应时间、429出现的频率和内存占用。取响应稳定且无429的最高并发值作为这台机器该站点的上限。多台机器根据各自资源调整不要所有机器都用一个数。第二件是配置全局限速。Scrapy-Redis本身不提供分布式的速率控制每台机器的DOWNLOAD_DELAY是各自的。最简单的方案是让所有机器使用统一的DOWNLOAD_DELAY和CONCURRENT_REQUESTS但资源不同的机器分配相同的值会浪费性能。更好的做法是给每台机器设置不同的DOWNLOAD_DELAY让它们形成互补的请求频率整体逼近但不超过目标站的承受范围。第三件是代理和Cookie策略不能落下。分布式架构下出口IP分散了但还是可能多台机器共享同一个代理IP池导致同一IP的并发请求数很高。我的建议是代理池按机器分组不同机器用不同批次的代理IP并且给每个代理IP设置独立的最大并发数。至于Cookie登录态的Cookie最好不要在Redis里共享因为很多站点的会话信息绑定IP或设备指纹A机器登录的Cookie给B机器用反而容易触发风控。第四件是部署层的稳定性。爬虫进程在生产环境要能被守护进程托管推荐用supervisor或systemd每台机器上都配好进程挂了自动拉起。这个看起来是基本功但分布式环境下有个细节需要特别处理进程重启后Scrapy-Redis的队列和去重Set都还在Redis里Spider起来后会继续消费队列。如果业务允许把SCHEDULER_PERSIST设为False进程重启时清掉队列重新开始反而是最安全的行为避免旧任务干扰新任务。最后想单独说一个跟性能和体验都直接相关的问题日志。分布式爬虫的日志必须和队列、吞吐量、错误码关联起来方便定位是哪台机器出了问题。我给每台机器配置了不同的日志文件名比如crawler_10_0_0_11.log和crawler_10_0_0_12.log同时在每条日志里主动写入node_id 10.0.0.11这样的标识。这样一旦出现拒绝访问或超时可以通过日志快速定位是哪台机器的问题而不用一台一台登录进去查。还有一个最终回头验证架构是否合理的办法在没有代理池的前提下如果目标站本身不太严格单机并发调到64左右能达到每秒20到30条response的吞吐三台机器就是每秒60到90条。如果你跑完发现每台机器只能到每秒5条还不是网络或目标站原因那大概率是解析环节里串行了阻塞操作比如在parse里直接印数据库或调第三方API。记住Scrapy的单线程异步模型parse回调里的任何同步阻塞都会卡住整台机器的吞吐这个细节在分布式场景里比单机被放大得更明显。把这些点都理顺分布式爬虫才能真正变成稳定、可控、可维护的一套基础设施而不只是一堆Worker进程在Redis队列上跑的临时脚本。
返回列表