ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Scrapy-Redis的全国历史天气分布式爬虫实战

基于Scrapy-Redis的全国历史天气分布式爬虫实战

简介:这份资源是面向Python爬虫学习者与毕业设计选题学生的Scrapy分布式爬虫实战项目,聚焦全国历史天气数据的大规模抓取与存储。项目基于Twisted异步网络库,涵盖Spiders、Item、Item Pipeline、Downloader Middleware等核心组件,并借助Scrapy Cluster或ScrapyRT等扩展将任务分散到多台机器,提升抓取速度与稳定性,同时降低对目标站点的访问压力。爬取内容涉及日期、温度、湿度、风向风力等字段的解析与提取,数据经Pipeline清洗、验证、格式化后存入MySQL、MongoDB或CSV、JSON文件。压缩包共9个文件,以7个py源码为主,辅以1个md说明文档和1个gitignore配置,整体约8KB,已在Windows 10/11环境测试通过,并附有示例图片与部署教程。目前已有232人学习,适合希望掌握分布式爬虫架构、数据存储设计及真实环境部署的开发者参考借鉴。

1. 全国历史天气数据怎么拿:从单机 scrapy 到分布式爬虫的落地判断

做气象数据分析、农业模型训练或者保险精算的朋友,多半绕不开一个需求:拿到全国几百个站点、过去十几年的逐日历史天气。单机 scrapy 跑一遍不是不行,但站点多、年份长、请求量大,单进程串行抓取往往要跑上十几个小时,中途还可能因为网络抖动或反爬策略中断。这时候“分布式爬虫”就成了一个很自然的选择——把请求队列抽出来放到 Redis,让多台机器或多个进程共享同一个调度器,谁空闲谁领任务,整体吞吐能翻好几倍。

这个方案适合两类人:一是已经写过基础 scrapy 爬虫、想把它改造成多机协作的工程师;二是手里有历史天气数据需求、愿意花半天搭一套可复用采集管道的开发者。它解决的核心问题不是“能不能抓到”,而是“怎么在可控时间内稳定抓完、断点续跑、不重复不遗漏”。下面我会按选型、改造、调度、避坑、验证的顺序,把这条链路拆开讲清楚。

2. 分布式 scrapy 的骨架:Redis 队列、去重指纹与调度器改造

2.1 为什么选 scrapy-redis 而不是自己写队列

scrapy 原生的调度器是内存级的,爬虫一停,队列就没了。要做分布式,核心是把两个东西外置:请求队列(queue)和去重集合(dupefilter)。常见做法是用 scrapy-redis 这个组件,它把调度器和去重器都换成 Redis 实现,多个爬虫实例连同一个 Redis,就自动共享任务。

选它的理由很直接:改动量小,只需要在 settings 里换几行配置,再让 Spider 继承 RedisSpider 而不是 scrapy.Spider。自己用 kombu 或 rabbitmq 写一套调度器当然也行,但你要处理序列化、去重一致性、断线重连,工作量不成比例。对于历史天气这种“URL 可枚举、结构规整”的场景,scrapy-redis 足够。

需要提前想清楚的是:Redis 里的队列是持久化的,但如果你用spop取任务,进程崩溃时正在处理的那条会丢。scrapy-redis 默认用的是zpopmin配合一个正在处理的集合,相对安全,但也不是严格意义上的 ACK 机制。对天气数据这种允许少量重试的场景够用,对金融级去重就不合适了。

2.2 改造一个单机天气爬虫的最小步骤

假设你原来有一个单机爬虫,从某个天气网站按“城市+月份”拼 URL 抓取。改造成分布式,第一步是安装依赖并配置 Redis 连接。

pip install scrapy scrapy-redis redis

然后在settings.py里替换调度器和去重类:

# settings.py # 启用 scrapy-redis 调度器,请求队列存到 Redis SCHEDULER = "scrapy_redis.scheduler.Scheduler" # 去重指纹也存 Redis,多个实例共享同一份指纹集合 DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter" # 允许暂停/恢复,爬虫重启后队列不丢 SCHEDULER_PERSIST = True # Redis 地址,多机部署时改成内网 IP REDIS_URL = "redis://127.0.0.1:6379/0" # 可选:给队列和指纹加前缀,避免多个项目互相污染 REDIS_KEY = "weather:history"

参数说明:SCHEDULER_PERSIST=True是关键,它决定爬虫停止后 Redis 里的队列是否保留。调试阶段可以设 False,方便清空重来;生产环境一定设 True,否则断点续跑无从谈起。REDIS_KEY建议按项目命名,我见过有人多个爬虫共用一个 db0,结果指纹串了,A 项目的 URL 被 B 项目判重,排查了半天。

接着改 Spider,让它从 Redis 读起始 URL:

from scrapy_redis.spiders import RedisSpider class WeatherSpider(RedisSpider): name = "weather" # 不再写 start_urls,改为从 Redis 的 weather:start_urls 读取 redis_key = "weather:start_urls" def parse(self, response): # 解析逻辑保持原样 for row in response.css("table.history tr"): yield { "city": response.meta["city"], "date": row.css("td:nth-child(1)::text").get(), "temp_high": row.css("td:nth-child(2)::text").get(), "temp_low": row.css("td:nth-child(3)::text").get(), }

逻辑说明:redis_key是 scrapy-redis 约定的起始队列名。你不需要在代码里写start_requests,爬虫启动后会阻塞等待这个 key 有数据。往 Redis 里推 URL 用lpush:

redis-cli lpush weather:start_urls "https://example.com/history/beijing/202301"

每推一条,任意一个空闲的爬虫实例就会领走。多台机器同时启动同一个 Spider,它们会自动分摊队列里的任务,这就是分布式的核心机制。

2.3 请求指纹与增量抓取:别让重复请求吃掉你的带宽

历史天气数据有个特点:同一个城市不同年份的 URL 结构高度相似,如果你在生成 URL 时不小心把月份参数拼错,很容易产生大量重复请求。scrapy-redis 的指纹默认基于请求方法、URL、body 做 sha1,重复的 URL 会被直接过滤掉,不会进入队列。

但要注意:指纹是在请求入队时计算的,如果你先往 Redis 里推了 10 万条 URL,其中 3 万条重复,这 3 万条在入队时就已经被去重了,不会占用队列空间。真正需要担心的是“逻辑重复”——比如北京 2023 年 1 月的数据,你从两个不同的入口页面都能点到,URL 却不一样。这种情况指纹拦不住,得在解析层做二次判断,比如把(city, year, month)作为唯一键存到 Redis 的 Set 里,解析前先查一下。

我一般会在 pipeline 里加一个轻量去重:

import redis class DedupPipeline: def __init__(self): self.r = redis.Redis(host="127.0.0.1", port=6379, db=1) def process_item(self, item, spider): key = f"seen:{item['city']}:{item['date']}" # setnx 返回 1 表示新数据,返回 0 表示已存在 if self.r.setnx(key, 1): return item else: raise DropItem(f"duplicate: {key}")

参数说明:这里用了独立的 db1,和调度队列的 db0 分开,避免keys *时互相干扰。setnx是原子操作,多实例并发也不会误判。缺点是 Redis 内存会随数据量增长,全国站点十几年的逐日数据大概几百万条,每条 key 几十字节,内存占用在几百 MB 级别,可以接受。如果数据量再大一个数量级,就得考虑用布隆过滤器或者定期清理。

3. 全国站点与年份的 URL 编排:把任务拆成可并行的最小单元

3.1 按“城市-年份”切片,而不是按“城市”整包

很多人第一次做分布式,习惯按城市切任务:一个城市一个 URL,爬虫进去翻页。这种切法的问题在于任务粒度太粗,一个城市十几年的数据可能几百页,某个实例领到这个任务后要跑很久,其他实例干等着,负载不均。

更合理的做法是按“城市-年份”甚至“城市-月份”切片。全国地级市约 300 个,每个城市 10 年,就是 3000 个任务单元;如果按月份切,就是 36000 个。任务单元越多,分布式调度的优势越明显,单个任务失败的影响面也越小。

生成 URL 列表的脚本可以这样写:

import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0) cities = ["beijing", "shanghai", "guangzhou", "shenzhen"] # 实际替换为完整城市列表 years = range(2014, 2024) count = 0 for city in cities: for year in years: for month in range(1, 13): url = f"https://example.com/history/{city}/{year}{month:02d}" r.lpush("weather:start_urls", url) count += 1 print(f"pushed {count} urls")

逻辑说明:lpush是往队列左侧推,scrapy-redis 默认从右侧取(rpop),所以先推的会先被消费,顺序可控。如果你希望打乱顺序避免对同一站点集中请求,可以用rpush或者推之前 shuffle 一下城市列表。参数上,years的范围根据你的数据需求调整,注意目标网站可能只保留最近 N 年的数据,推之前最好先手动验证一两个 URL 是否有效。

3.2 起始 URL 的验证与失败重推

往 Redis 推了几万条 URL 之后,最怕的是里面混了一批 404 或者结构错误的链接。这些链接被爬虫领走后,要么返回空数据,要么触发重试,白白消耗调度资源。

我的习惯是推之前先抽样验证。写一个小脚本,从生成的 URL 里随机抽 20 条,用 requests 快速请求一遍,看状态码和页面结构:

import random import requests urls = [f"https://example.com/history/beijing/{y}{m:02d}" for y in range(2014, 2024) for m in range(1, 13)] sample = random.sample(urls, 20) for url in sample: try: resp = requests.get(url, timeout=10) # 检查状态码和关键元素是否存在 ok = resp.status_code == 200 and "历史天气" in resp.text print(f"{url} -> {resp.status_code} {'OK' if ok else 'CHECK'}") except Exception as e: print(f"{url} -> ERROR {e}")

参数说明:timeout=10是必须的,否则一个慢响应会卡住整个验证脚本。"历史天气" in resp.text这种检查比较粗糙,更稳妥的是用 parsel 或 lxml 解析后判断目标表格是否存在。如果抽样里有超过 10% 的 URL 有问题,说明你的 URL 模板需要调整,别急着全量推。

对于已经推入 Redis 但发现是坏链接的情况,scrapy-redis 没有提供“按 URL 删除”的便捷方法,因为队列是 list 结构。常见做法是停掉爬虫,用lrange导出全部 URL,过滤后再重新lpush。所以推之前验证这一步,省的是后面的麻烦。

3.3 并发数与下载延迟的平衡

分布式环境下,每个实例的CONCURRENT_REQUESTS和DOWNLOAD_DELAY需要重新算。单机时你可能设CONCURRENT_REQUESTS=16、DOWNLOAD_DELAY=1,现在 5 台机器同时跑,对目标站点的总并发就是 80,延迟等效降到 0.2 秒。很多天气网站是中小型站点,扛不住这个压力,轻则限流,重则封 IP。

我的经验是:先确认目标站点的承受能力。如果对方有 CDN 或者明确允许批量访问,总并发控制在 20 以内比较安全;如果是小站,总并发别超过 5。换算到每个实例,就是CONCURRENT_REQUESTS除以实例数。同时开启AUTOTHROTTLE,让 scrapy 根据响应时间自动调整:

# settings.py CONCURRENT_REQUESTS = 4 # 单实例并发,5 台机器总计 20 DOWNLOAD_DELAY = 0.5 # 基础延迟 AUTOTHROTTLE_ENABLED = True AUTOTHROTTLE_START_DELAY = 0.5 AUTOTHROTTLE_MAX_DELAY = 10 AUTOTHROTTLE_TARGET_CONCURRENCY = 2.0

参数说明:AUTOTHROTTLE_TARGET_CONCURRENCY是每个实例希望维持的并发数,设成 2.0 意味着 scrapy 会动态调整延迟,让实际并发趋近这个值。AUTOTHROTTLE_MAX_DELAY=10是上限,防止某个慢响应把延迟拉到不可接受的程度。这套配置在 5 台机器上跑,对目标站点的压力大约相当于单机 10 并发,比较温和。

4. 分布式爬虫避坑:从 Redis 连接池到数据落库的 5 个翻车现场

4.1 现象:爬虫启动后一直卡在等待,Redis 里明明有 URL

原因:最常见的是redis_key写错,或者推 URL 时用的 key 和 Spider 里声明的不一致。scrapy-redis 启动后会blpop这个 key,如果 key 不存在,它会一直阻塞等待,日志里只显示 “Waiting for start urls”。另一个可能是 Redis 的 db 选错了,推到了 db0,爬虫连的是 db1。

解决:先用redis-cli keys "*start_urls*"确认 key 存在,再用llen看队列长度。然后检查 Spider 里的redis_key和 settings 里的REDIS_URL是否匹配。如果都正确,看 Redis 是否设置了密码而 scrapy 没配REDIS_PARAMS。

4.2 现象:多个实例同时抓同一个 URL,数据重复入库

原因:去重指纹默认基于请求的 URL 和方法,但如果你的 Spider 在start_requests里动态生成了带随机参数的 URL,或者用了 POST 请求且 body 里有时间戳,指纹就会不同,导致重复请求。另一个可能是多个实例用了不同的DUPEFILTER_CLASS配置,有的走本地去重,有的走 Redis。

解决:统一所有实例的 settings,确保DUPEFILTER_CLASS都是RFPDupeFilter。检查 URL 生成逻辑,去掉无意义的随机参数。如果确实需要带时间戳,把时间戳精度降到天或者小时,并在 pipeline 里做业务层去重兜底。

4.3 现象:爬虫跑着跑着 Redis 内存暴涨,最后 OOM

原因:去重指纹集合和业务去重集合都在 Redis 里,数据量大了之后内存持续增长。尤其是RFPDupeFilter的 key 默认没有过期时间,跑完也不清理。另外如果用了SCHEDULER_PERSIST=True,队列里的请求也会一直占内存。

解决:给指纹 key 设置过期时间,比如 7 天,通过REDIS_PARAMS传expire参数不太方便,更直接的是在爬虫结束后手动expire或者用定时任务清理。业务去重集合如果不需要长期保留,也可以设 TTL。如果数据量确实大,考虑用布隆过滤器替代 Set,内存占用能降一个数量级。

4.4 现象:部分实例报 “Response never received” 或连接超时

原因:分布式环境下网络抖动更频繁,尤其是多台机器跨机房时。scrapy 默认的重试策略可能不够用,或者DOWNLOAD_TIMEOUT设得太短。

解决:调大DOWNLOAD_TIMEOUT到 30 秒,开启RETRY_ENABLED并设置RETRY_TIMES=3。如果目标站点有 IP 限流,考虑在中间加一层代理池,但要注意代理的稳定性,不稳定的代理反而会拉低整体效率。另外检查 Redis 连接是否稳定,REDIS_PARAMS里可以设socket_timeout和socket_connect_timeout。

4.5 现象:数据抓完了,但入库时发现字段错位或编码乱码

原因:历史天气页面通常是表格结构,不同年份的页面模板可能有细微差异,比如列的顺序变了,或者表头多了“空气质量”一列。如果你的解析逻辑写死了td:nth-child(2),遇到模板变化就会取错字段。编码问题则多半是响应头里的 charset 和实际内容不一致。

解决:解析时不要依赖列的位置,而是先解析表头,建立“列名→索引”的映射,再按列名取值。编码方面,在 Spider 里显式指定response.encoding = 'utf-8',或者用response.text让 scrapy 自动推断。入库前加一层校验,比如温度字段必须是数字,日期字段必须能解析成日期,不符合的记录下来人工排查。

5. 验证分布式抓取是否真的生效:三个可量化的检查点

5.1 用 Redis 监控命令看队列消费速率

爬虫跑起来后,最直观的验证方式是看 Redis 里队列长度的变化。开一个终端,每隔几秒执行一次:

watch -n 5 "redis-cli llen weather:start_urls && redis-cli scard weather:dupefilter"

llen是剩余待抓 URL 数,scard是已去重指纹数。如果llen在稳定下降,说明消费正常;如果长时间不变,说明爬虫卡住了或者队列空了。scard的增长速率可以反推抓取速度,比如 5 秒涨了 50,就是每秒 10 个请求。多实例环境下,这个速率应该是单实例的数倍,如果没差别,说明分布式没生效,可能所有实例连的是不同的 Redis,或者去重类没配对。

5.2 检查各实例的日志分布

每个实例的日志里会记录它抓了哪些 URL。把几个实例的日志拉到一起,按 URL 去重后统计总数,和 Redis 里的指纹数对比。如果日志总数远大于指纹数,说明有重复抓取;如果远小于,说明有实例没在干活。

我一般会在 Spider 里加一行日志,记录实例 ID 和当前抓取的 URL:

import os import logging class WeatherSpider(RedisSpider): name = "weather" redis_key = "weather:start_urls" def parse(self, response): instance_id = os.environ.get("HOSTNAME", "unknown") self.logger.info(f"[{instance_id}] parsing {response.url}") # ... 解析逻辑

参数说明:HOSTNAME在 Docker 容器里就是容器 ID,在物理机上可以用socket.gethostname()。这样日志里能清楚看到哪个实例在处理哪个 URL,排查负载不均时很有用。

5.3 数据完整性校验:抽样比对源站

抓完之后,别急着关 Redis。随机抽 10 个城市-月份组合,手动打开源站页面,和你抓到的数据逐条比对。重点看:日期是否连续、温度值是否在合理范围(比如 -50 到 50 摄氏度)、有没有整月缺失。

如果发现某个城市的数据大面积缺失,先查这个城市的 URL 是否都推入了队列,再查解析逻辑是否对该城市的页面模板不兼容。历史天气网站经常改版,不同年份的页面结构可能不同,这是最容易翻车的地方。我的习惯是每抓完一批,就用 pandas 做个简单的完整性报告:

import pandas as pd df = pd.read_csv("weather_history.csv") # 按城市和月份统计记录数,正常应该是 28-31 条 report = df.groupby(["city", "year", "month"]).size().reset_index(name="days") abnormal = report[report["days"] < 28] print(f"异常月份数: {len(abnormal)}") print(abnormal.head(20))

这个检查能快速定位到哪些月份的数据不完整,然后针对性重抓。分布式爬虫的优势在这里也体现出来:重抓只需要把对应的 URL 重新推入 Redis,不用从头跑一遍。

6. 把历史天气数据变成可复用资产:增量更新与调度习惯

抓到数据只是第一步,真正省时间的是建立一套增量更新机制。全国历史天气数据不是抓一次就完了,每天都有新数据产生,如果你每隔几个月要重新全量抓一遍,那分布式搭得再好也是浪费。

我的做法是把“历史全量”和“每日增量”分开。历史数据用分布式爬虫一次性抓完,存到数据库或者按城市分区的 Parquet 文件里。每日增量用一个轻量脚本,只抓昨天和今天的数据,追加到对应分区。增量脚本不需要分布式,单机 scrapy 或者 requests 就够了,关键是稳定和定时。

调度上,我用 crontab 每天凌晨跑增量,每周跑一次完整性校验,发现缺失就自动把对应 URL 推入 Redis 队列,让分布式爬虫补抓。这样历史数据只会越补越全,不会因为某次网络故障留下永久空洞。

# 每天凌晨 2 点抓增量 0 2 * * * /usr/bin/python3 /opt/weather/incremental.py >> /var/log/weather_incr.log 2>&1 # 每周一凌晨 3 点校验完整性,缺失的推入 Redis 0 3 * * 1 /usr/bin/python3 /opt/weather/verify_and_repush.py >> /var/log/weather_verify.log 2>&1

这套机制跑了一年多,中间经历过目标网站改版、Redis 实例迁移、服务器重启,但因为队列和指纹都在 Redis 里持久化,每次恢复后都能接着跑,没有丢过数据。唯一一次翻车是 Redis 没设密码,被内网另一台机器上的测试脚本flushall清空了队列,好在数据已经落库,只是重新推了一遍 URL。从那以后我给所有生产 Redis 都加了密码,并且把flushall命令 rename 掉了。

如果你也在做类似的历史数据采集,我的建议是:先把单机流程跑通,确认解析逻辑没问题,再改分布式。分布式解决的是吞吐和稳定性,不解决解析正确性。解析错了,分布式只会让你更快地得到一堆错数据。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进