接手这个项目时,我第一反应不是“又来个清洗+统计的活”,而是觉得天气数据叠加大数据框架这件事,放在西南这种地形复杂的区域特别有意思。西南地区既有高原又有盆地,海拔从几百米跨度到几千米,天气现象的空间差异非常大。传统的气象分析工具处理小批量数据还行,一旦面对几十GB甚至上百GB的历史站点观测数据和再分析资料,单机方案根本跑不动,Spark就成了最务实的选择。
这篇文章我会把整个项目从数据接入、清洗、存储设计、分析计算,到最后的可视化展示和模型应用的完整链路拆开讲。内容包括我踩过的坑、调参的教训、以及一些能直接“抄作业”的代码片段。无论你是刚开始接触Spark的数据分析师,还是准备做类似区域气象研究的学生,都能从中找到可以直接落地的思路。
1. 项目定位与整体设计思路
1.1 为什么用Spark而不是Pandas
很多人问过我一个问题:天气数据能有多大,至于上Spark吗?说实话,如果只是分析某一天或某一个站点的数据,Pandas确实够用。但这个项目要处理的是西南地区多年份、多站点、多要素的历史数据加再分析数据,累计体量能达到几百GB,而且里面还涉及站点观测和格点数据的融合。Pandas在这个量级下,光加载数据就能把内存吃光,更别说做复杂的聚合和关联计算了。
Spark的优势在于它把数据切碎后分布到多个节点上并行处理,内存不够就落磁盘,单机搞不定的量级它可以扛住。更关键的是,Spark SQL提供的DataFrame API让写分析逻辑的体验非常接近Pandas,学习曲线没那么陡峭。我用的是PySpark,主要原因是我们团队Python生态相对成熟,后续接机器学习和可视化都方便。
这个项目本质上做的是“降维”:把分散的、格式混乱的原始天气数据,加工成结构化、干净、可直接分析的数据表,再基于这些表去回答具体的气象问题。Spark在这里承担的不只是计算引擎,更是一个统一的数据加工厂。
1.2 整体技术架构选型
整个项目的技术栈并不复杂,但每一层的选型都是经过反复权衡的:
- 存储层:数据落在HDFS上,文件格式统一用Parquet。列式存储对这类“按列聚合”的气象分析非常友好,查询时只需要读取涉及的列,IO开销能省一大半。
- 计算层:Spark SQL配合DataFrame API,偶尔用RDD处理一些特别不规则的数据源。
- 调度层:用的Yarn做资源调度,队列资源不够的时候任务会排队,这个后面会讲到。
- 结果层:分析结果导出为Parquet和CSV两份,Parquet供后续数据服务使用,CSV给业务方做报表。
至于为什么不选Hive做全部计算、为什么不用Flink,核心原因就是Spark的批处理能力在这个场景下性价比最高。天气数据的分析大多是离线批任务,按天或按小时跑一轮,对秒级延迟没有要求。Flink更擅长流式场景,Hive的交互体验又不如Spark SQL方便,所以最终敲定Spark作为主计算引擎。
1.3 西南地区天气数据的特殊性
西南地区横跨多个气候带,数据本身就有很强的空间异质性。比如同一时间下,高原站点和盆地站点的气温可能相差十几度,这种差异不是简单的均值能代表得了的。做分析时如果笼统地算全国或全区平均,很多有价值的信息会被掩盖掉。
所以我在项目里特别强调“按站点+海拔+地形区域”的维度去做切分。地形数据叠加气象数据后,很多规律才真正显现出来——比如迎风坡和背风坡的降水量差异、山谷地带的夜间辐射降温效应等。这些特征如果不结合地理信息,单纯看数字是看不出来的。
另外一个麻烦是数据质量问题。西南有些站点位于偏远山区,设备故障率高,数据中断、跳变比平原地区严重得多。这意味着清洗环节不能只做简单的去空值,还需要结合气象物理常识做合理性检查。
2. 数据链路与预处理方案
2.1 数据源盘点与接入方式
这个项目涉及三类数据源,每一类的接入方式都不一样:
- 气象站点观测数据:从某气象数据服务商那里获取的历史站点逐小时观测资料,包含气温、降水、风速、风向、气压等要素。单站点单要素全年大约8760个记录,西南地区有数百个站点,几年累计下来数据量并不小。
- 再分析格点数据:类似ERA5这类全球再分析数据集的西南区域切片,分辨率是0.25度格点。这种数据不是“测出来”的,而是通过数值模式同化后的产物,适合用来补地面站点覆盖不到的地方。
- 地形辅助数据:SRTM高程数据,用来提取每个站点的海拔和地形参数。这部分数据不需要频繁更新,一次处理完入库即可。
接入方式上,站点观测数据是csv文件按站点分目录存储的,我直接用Spark批量读取。再分析数据是netCDF格式,PySpark不能直接读,得先通过Python的科学计算库转为Parquet中间格式。这里有个小技巧:netCDF转Parquet时,最好一次性把经纬度和时间维度全部展开成列,避免后面对行索引做笛卡尔积式的计算。
2.2 数据清洗的三个关键坑
天气数据的清洗是全项目中最花时间的环节,远比后面的分析要耗神。我总结下来有三个坑必须提前做好准备。
第一是缺失值处理。站点设备故障、通信中断都会造成数据缺失。空值太多的时候不能直接删行,否则按小时汇总时会出现“某个小时没有数据”的假象。我的做法是:如果一个站点某天缺测超过6小时,那当天不参与日统计;超过总记录5%的站点直接标记为低质量站点,单独拉出来做敏感性分析。
第二是异常值识别。天气数据有物理极限,比如夏季气温不可能超过50度,小时降水量不可能超过200毫米(台风暴雨除外,西南地区相对少见)。我写了一个UDF,按要素设定上下限范围,超出范围的记录标记为可疑值,再结合前后时刻的资料做线性插值替换或者剔除。
第三是时间对齐。不同来源的数据,时间基准可能不一样,有的是世界时,有的是北京时间,相差8个小时。不统一时区会导致日最高气温和日累计降水全部错位。我先统一转成北京时间,再按小时打齐时间戳,这一步做完后整个数据体系才真正能串起来。
2.3 数据分区与存储设计
数据表怎么组织,直接影响查询性能和开发效率。刚开始我图省事,把所有数据都放一张大表里,查询时用where条件过滤。结果是每次扫描都要读取全表,跑一个简单的多年平均都要几分钟。
后来我改成“按年和月双级分区”的物理存储结构,即:
/data/weather_data/year=2024/month=07/每个分区里存的是当月的站点逐小时观测数据,Parquet文件按站点做了排序。这样跑年度统计时只需要读取对应年份的分区,跑月度分析时甚至可以只扫一个分区,扫描量大幅下降。
这里插一句小文件问题。天气数据的特点是“站点多、单文件小”,如果每个站点每小时落一个文件,HDFS上会堆出大量小于128MB的小文件,NameNode压力很大,Spark任务也会因为文件数太多而启动大量低效task。我的做法是每天做一次合并,通过repartition(文件数, 按站点列)将当天的数据均匀分布到几个大文件中,再写入Parquet分区目录。
3. 核心分析场景与实现细节
3.1 气温时空分布分析
气温是最基础也是应用面最广的气象要素。我做了三块分析:区域平均气温的长期趋势、月均温的空间分布、以及极端气温事件的识别。
区域平均这块,计算逻辑比较简单,核心代码如下:
from pyspark.sql import functions as F df_daily = df_hourly.groupBy("station_id", "date").agg( F.avg("temperature").alias("avg_temp"), F.max("temperature").alias("max_temp"), F.min("temperature").alias("min_temp") ) df_region_month = df_daily.groupBy("year", "month").agg( F.avg("avg_temp").alias("monthly_avg_temp") ).orderBy("year", "month")但这里有个统计口径问题:求区域平均时,站点分布是不均匀的,盆地站点多、高原站点少,直接算术平均会偏向盆地。更合理的做法是先按经纬度做网格化(比如0.5度网格),先求每个网格内站点的平均,再对网格做面积加权平均。我最终用了简化版:先按站点聚类到地形区域,再在各区域内算平均,最后加权合并。这个处理让区域平均气温的趋势分析更可靠。
极端气温事件识别这块,我采用阈值法加持续天数判断。比如连续三天日最高气温超过35度,称为一次高温过程;连续两天最低气温低于0度,算霜冻过程。用Spark做这类“按站点分组、按日期排序”的连续过程识别时,窗口函数是最顺手的工具:
from pyspark.sql.window import Window w = Window.partitionBy("station_id").orderBy("date") df_temp = df_daily.withColumn( "hot_day", (F.col("max_temp") > 35).cast("int") ) # 利用lag判断连续日期的起始点 df_event = df_temp.withColumn( "event_start", F.when(F.col("hot_day") == 1, F.when(F.lag("hot_day", 1).over(w) == 0, 1).otherwise(0) ).otherwise(0) )这个识别结果后来用在了农业服务端,作为高温热害风险提示的底表。
3.2 降水特征与雨季识别
降水分析里最有价值的产出是“雨季起止日期自动识别”。西南地区降水季节性强,雨季的起止直接影响农业生产安排。我用的方法是滑动平均法:逐日降水量序列做5日滑动平均,当年滑动平均序列第一次稳定大于某个基准阈值、且后20天没有明显回落的日期,定义为雨季开始日。
基准阈值不能全区域统一,因为不同区域年降水量差异很大。我按站点历史平均年降水量分档:年降水800毫米以下的站点阈值取3毫米/日,800-1200毫米的取4毫米/日,超过1200毫米的取5毫米/日。原理很简单:阈值太低会把零星降水误判为雨季,太高又会漏掉早期降水过程。
区间累计降水、最大日降水这些指标,用Spark的groupBy加agg就能完成。有一个容易被忽视的坑是降水的“微量记录”。气象资料里降水小于0.1毫米会记为0.0,但对于降雨特征分析,究竟是“完全无降水”还是“出现过微量降水”,意义不一样。我建了一个辅助列区分“降水量是否为0”和“是否有降水现象”,后续做降雨日数统计时全部基于这个辅助列,避免口径混淆。
3.3 风速风向与地形关系
风速风向分析算不上这个项目的核心,但做出来之后反而成了业务方最喜欢的内容。西南地区地形起伏大,山谷风效应特别明显。我统计了站点平均风速的日变化曲线,发现不少河谷站点凌晨风速小、午后风速迅速增大,典型的山谷风日变化特征。
为了把“地形+风”的关系量化,我在站点表里关联了海拔和地形粗糙度等级,再按不同海拔区间统计风速均值。结果呈现出一个大致规律:海拔越高、地形越开阔的区域,平均风速越大;盆地腹地站点风速明显偏小。这个结果在风电和电力设施规划场景里可以使用。
风向处理也有讲究。不能用普通数值直接平均,否则350度和10度平均出来是180度,实际风向应该是0度左右。我自己实现了一个把风向转成单位向量的方法,先求sin和cos的均值,再通过atan2还原出平均风向角,这样得到的才是气象学意义上的平均风向。
4. 从分析到应用:落地路径与效果
4.1 可视化:让数据自己“说话”
分析结果如果只躺在表里,价值很有限。我做了两层可视化:第一层是常规统计图表,用Python的Matplotlib和Seaborn库生成气温曲线、降水柱状图、风向玫瑰图;第二层是空间分布图,把Spark算出来的站点聚合结果导出成标准表格,然后用GIS工具做插值和面状渲染,最后输出成专题图。
在空间分布图上,站点稀疏问题会在插值时放大。比如高原西部站点少,插值出来一个大面积的“无数据区”,很容易误导人。我的处理是把插值范围限制在站点密度可支撑的区域,同时把地形阴影叠加上去,让读者直观理解“没数据的地方是因为站点覆盖不到”,而不是真的没有气象现象。
统一可视化之前的出口格式也很关键。我建议所有空间结果都带station_id、longitude、latitude、analysis_value四列,就是标准的“点要素表”,所有GIS工具都能直接读。不要在结果里混入多余描述列,否则后面还要花时间做清洗。
4.2 用MLlib构建气温预报模型
分析完历史数据后,我尝试用Spark MLlib建了一个简单的站点温度预报模型。这个模型的定位不是跟数值天气预报抢饭碗,而是做“基于历史相似场景的快速预测”,比如电网负荷预测需要下周每个小时的温度参考值,这种场景下用一个轻量级模型随叫随用,很方便。
特征工程方面,我选了海拔、月份、当日历史平均温度、昨日同时刻温度、三日滑动平均温度等几个变量。其中昨日同时刻温度是最重要的特征,因为温度有很强的自相关性;海拔用来捕捉不同站点的差异。标签列就是目标时刻的实际温度。
模型选用了随机森林回归器,Spark MLlib里的实现是RandomForestRegressor。不用线性回归的原因是温度与海拔、月份之间并非严格线性关系,随机森林可以自动学到非线性结构。
from pyspark.ml.regression import RandomForestRegressor from pyspark.ml.feature import VectorAssembler feature_cols = ["altitude", "month", "temp_lag1", "temp_lag24", "temp_ma3"] assembler = VectorAssembler( inputCols=feature_cols, outputCol="features" ) train_data = assembler.transform(df_train) rf = RandomForestRegressor( featuresCol="features", labelCol="temperature", numTrees=100, maxDepth=10 ) model = rf.fit(train_data)最终模型在验证集上的RMSE大约2.3度,在山区站点上误差会大一些,高原站RMSE接近3.5度,盆地站可以到1.8度。这个精度对业务参考来说足够用了。要注意的一点是:用历史站点温度训练模型时,训练集和测试集必须按时间切分,不能随机切分。因为温度序列存在强自相关,随机切分会让模型“偷看”未来数据,评估结果虚高。
4.3 应用场景拓展思路
分析结果落地时,我结合业务需求做了几个延伸场景:
- 农业气象服务:利用积温指标对农作物适宜生长区做气候区划。项目输出的月均温、极端低温等结果,结合GIS叠加作物种植边界,可以识别出温度条件不匹配的区域,给出调整建议。
- 旅游舒适度评估:用温湿指数和风效指数对重点旅游区域做舒适度月份分布,生成的月度舒适度地图可以直接用于旅游线路规划参考。
- 电力负荷预测:气温与用电负荷有强相关性。项目输出的逐时温度序列作为外部特征,接入某跨平台系统的电力负荷预测模型后,预测误差降低了约12%。
这些应用本质上都在做同一件事:把气象数据变成可决策的信息。Spark在整个链条里负责提供“算得快、算得准”的基础数据产品。
5. 常见问题与排查技巧实录
5.1 数据倾斜的排查与应对
我在跑“按站点聚合日降水”这个任务时,发现整个作业特别慢,观察Yarn日志后发现某个executor几乎承载了80%的数据量。原因是有几个中心站点的数据量比普通站点多一个量级,按站点做groupBy时,这几个热点键对应的数据全压在一个分区上。
解决办法有两个层面。第一层是逻辑优化,如果倾斜只影响少数键,可以先做两阶段聚合:先按站点加随机前缀做局部聚合,去掉前缀再聚合一次。第二层是参数调整,比如把spark.sql.shuffle.partitions调大,让shuffle后的分区粒度更细,同时spark.default.parallelism也相应调大。
这种方式能缓解但未必根治,根源在于数据本身的键分布不均衡。数据入库时如果能按站点做更均匀的哈希分桶,后续的聚合倾斜会少很多。我在后续项目里把站点表改为分桶表(按station_id分成64个桶),聚合性能提升了近两倍。
5.2 Executor OOM的解决过程
有一次跑宽表关联任务时频繁报错ExecutorLostFailure,原因是数据广播策略选择错误。一个大维度表(几十万行)我用默认的SortMergeJoin去和事实表关联,导致每个executor都要维护一个较大的hash表,内存直接被打爆。
优化手段是改用广播变量,把小维度表广播到每个节点:
from pyspark.sql import functions as F df_result = df_fact.join( F.broadcast(df_dim), "dim_key", "left" )Spark在内存中直接查找广播表,不再需要shuffle。直播上做维度表关联时,只要维度表小于Spark可用内存的阈值,都建议优先用广播方式。另外把spark.sql.autoBroadcastJoinThreshold从默认的10MB调大到50MB,能自动触发更多小表的广播优化。
5.3 时间与坐标口径的统一问题
时间口径问题在数据量小的时候不太明显,一旦做跨年汇总就很容易出错。我第一次算“全年累计降水量”时,发现结果比气象部门发布的参考值少了约百分之八。排查到最后发现是有三个站点的源数据用了UTC时间,按日划分时记录落错了日期,导致边界天的降水被漏掉。
解决办法是清洗阶段就统一做一次to_utc_timestamp或from_utc_timestamp的转换,并且对每天的数据做完整性校验:检查每站点每天的记录条数是否等于24小时。不满足的站点标出来单独复查,这个校验逻辑让后续所有分析的准确性有了基本保障。
坐标方面同样有坑。部分格点数据用的是经纬度浮点表示,但不同来源数据的浮点精度不同,直接做空间匹配时对不上的概率很大。我先统一转成LonLat坐标,然后把经纬度字段格式化成固定小数点位数,再建一个“网格ID”字段,后续匹配全部基于网格ID做等值关联,又快又准。
5.4 小文件与NameNode压力
还有一个容易被忽略的问题,就是数据生产路径里Parquet小文件数量失控。有一段时间天气数据服务端每15分钟生成一个增量文件,当天合并前HDFS上会有上百个碎片文件。虽然Spark能读,但每次列取任务光打开文件列表就要花几十秒。
解决方案是建立一个每日合并的定时任务:每天凌晨读取前一天的全部分区数据,通过repartition按站点重分区,目标大小控制在128MB左右,然后覆盖写入。跑完后再执行OPTIMIZE做文件合并和气层统计更新,确保后续查询使用最新的文件布局。
写在最后的实操体会
做天气数据分析项目,最大体会是“数据加工流程比算法模型更影响成败”。很多人一上来就急着调模型参数,结果发现数据里有大量脏点和口径不一致的问题,后面再怎么调都是白费。扎实的清洗与存储设计,是Spark这类项目真正能出活的前提。
一个小技巧分享给大家:在做任何正式统计分析之前,先跑一遍全量数据质量报告,分站点输出缺失率、异常值占比、时间连续性等指标。这个报告本身只要几十行Spark代码就能生成,但它能帮你提前发现数据链路里的隐患,省下后面反复排查的几天时间。另外,涉及到空间分析时,多花点时间把坐标系和网格ID的口径定清楚,后面做插值和匹配会事半功倍。
这个项目后续可以考虑的方向是接入实时数据流,配合Spark Structured Streaming做逐小时的快速更新,让分析结果从“离线报表”进化成“准实时服务”。到那个阶段,基于天气数据能做的事情就更灵活了。