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, timeout=30) 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 scrapy==2.8.0 pip install redis==4.3.4 pip install Scrapy-Redis==0.6.3 pip install pymysql==1.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[@class='detail-link']/@href").getall() for link in detail_links: yield scrapy.Request(url=response.urljoin(link), callback=self.parse_detail) # 处理下一页 next_page = response.xpath("//a[@class='next-page']/@href").get() if next_page: yield scrapy.Request(url=response.urljoin(next_page), callback=self.parse) def parse_detail(self, response): """ 详情页解析:抽取字段 """ item = PublicDataItem() item["title"] = response.xpath("//h1/text()").get() item["content"] = response.xpath("//div[@class='article-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( host=host, user=user, password=password, database=database, port=port, charset="utf8mb4", ) 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( host=mysql_config["host"], user=mysql_config["user"], password=mysql_config["password"], database=mysql_config["database"], port=mysql_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 user=www-data autorestart=true 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 默认是 FIFO(SpiderQueue),如果改成熟先级模式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 的
set; - Downloader 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 批量大小、节点数和并发数,全部要基于一个明确的量级预期来定。起步先按最小可用配置跑通,再去逐步加节点、调并发,比一上来就堆机器靠谱得多。
如果这篇文章里的思路能在你下一个爬虫项目里少走一点弯路,那就够了。