做气象数据分析的同行都知道,西南地区的天气数据先天就带着一股“不驯服”的气质:盆地站点夏季闷热潮湿,高原站点一年四季昼夜温差能拉开二十多度,河谷地带还时不时来一场焚风。我手上的西南天气数据分析项目,一开始其实很朴素——把各站点过去十几年的观测记录汇总,算算气温、降水、湿度的分布规律。但数据一旦真正铺开,单机脚本的路就走到头了。
那段时间我试过用Pandas处理全量数据,结果不是内存爆掉就是跑一晚上没结果;换成逐站分块处理,几个表的关联又绕得头疼。后来我下定决心把整套分析迁移到Spark上,从数据清洗、气候统计到机器学习建模全部在分布式环境里跑通,整体处理效率提升了量级,分析深度也上来了。这篇博文就把我在这个项目里的完整思路、核心代码和踩坑经验整理出来,想用Spark处理气象、环境这类时序数据的读者,应该能从里面找到不少可以直接抄作业的东西。
1. 决定换Spark之前,我到底经历了什么
1.1 单机方案的三次崩溃
先说数据规模。看起来西南地区的国家级观测站点数量有限,但算上区域自动站、农业气象站和梯度观测站,有效站点能有上千个。观测频率从小时级到分钟级不等,一个站点一天最多产生1440条分钟记录,累积十几年就是几十亿行。我第一次用Pandas直接加载全量日值数据时,8G内存的机器直接卡死,这是第一次崩溃。
第二次崩溃发生在做多站点关联的时候。我要把气象要素、站点元数据和地形数据进行join,Pandas在单机里做大批量join本来就吃力,一旦键的分布不均匀,内存和CPU双重拉满,跑了大半个小时直接OOM。第三次崩溃更憋屈:我用Python写了个循环,逐站读取计算,结果一跑就是十几个小时,中途某个文件格式出了点问题,整批任务报废。
三次崩溃之后我彻底想明白一件事:问题不在于我写的脚本效率低,而是这套分析从架构上就不应该走单机路线。数据量大、维度多、还涉及多表关联和后续的模型训练,天然适合分布式计算框架来处理。
1.2 为什么在众多大数据框架中选了Spark
决定上大数据框架之后,我也纠结过选型。Flink在实时处理方面很强,但我的场景是批量分析和离线建模,用Flink有点用牛刀杀鸡,还得自己维护一堆流处理状态。Hadoop MapReduce倒是能处理大规模数据,但开发效率一言难尽,一个简单的分组聚合要写一大堆Mapper和Reducer,迭代成本实在太高。
最后选Spark,主要看重三点。第一,RDD和DataFrame的抽象足够简洁,写统计分析像写普通SQL一样自然;第二,Spark SQL、MLlib这些组件是完整的生态闭环,从清洗到建模不用跨多个框架;第三,内存计算的特性在处理迭代式算法时优势明显,跑机器学习算法比MapReduce快很多。另外,Spark对Python的支持度很好,我这种习惯用Python做分析的开发者,迁移成本几乎可以忽略。
2. 数据接入和清洗:气象数据远没有想象中干净
2.1 多源数据如何拉齐格式
气象观测数据通常来自三个渠道:国家级观测站提供小时级和日值数据,格式相对规范;区域自动站是分钟级记录,质量参差不齐;还有一些网格化气象数据,比如卫星反演产品,格式更特殊。这三个渠道我都需要接入,第一步就是统一成Spark能高效读取的存储格式。
我最终的方案是全部转换为Parquet列式存储。Parquet在Spark里读取效率高,压缩率好,还能配合分区裁剪快速定位数据。数据落地时我按year和month做分区,查询某个时段的数据时,Spark能直接跳过无关分区,效果显著。
统一后的核心表结构大概是这样的:
| 字段名 | 类型 | 说明 |
|---|---|---|
| station_id | string | 站点编号 |
| lon | double | 经度 |
| lat | double | 纬度 |
| elevation | double | 海拔高度 |
| timestamp | timestamp | 观测时间 |
| temperature | double | 气温,单位摄氏度 |
| humidity | double | 相对湿度,单位% |
| pressure | double | 气压,单位hPa |
| precipitation | double | 降水量,单位mm |
| wind_speed | double | 风速,单位m/s |
| wind_direction | double | 风向,单位度 |
站点元数据单独存一张小表,包括经纬度、海拔和站点类型,分析时再join进去。这样做的好处是,观测数据表不会被频繁更新的元数据污染,后续做空间分析时灵活性也更强。
2.2 清洗规则:这些脏数据必须处理
气象数据是出了名的脏,传感器故障、通讯中断、极端天气干扰都会产生异常记录。我整理了一套实用的清洗规则,全是这个项目里实际验证过的:
| 问题类型 | 处理规则 |
|---|---|
| 缺失值 | 单次缺失不超过2小时,用线性插值补齐;长时间缺失直接丢弃该站该时段 |
| 温度异常 | 超出该站点历史极值范围,且超出物理阈值(-40到45摄氏度),判定为传感器故障 |
| 降水负值 | 直接置为0 |
| 风速为0但风向有值 | 视为无效记录去除 |
| 重复时间戳 | 保留最后一次观测值 |
温度异常这块我特别说明一下。单纯用物理阈值会漏掉传感器“漂移”的情况,比如某个夏天传感器坏了,读数持续偏高但还在物理范围内。所以我加了站点历史极值做辅助判定,结合该站点过去五年同期的统计分布,超过3个标准差的记录标记为可疑数据,人工抽检后确认是否剔除。
2.3 Spark清洗代码示例
清洗的核心逻辑我用PySpark实现,这里给出一段关键代码:
from pyspark.sql import SparkSession from pyspark.sql import functions as F spark = SparkSession.builder.appName("weather-clean").enableHiveSupport().getOrCreate() df = spark.read.parquet("/data/weather/raw/year=2024") # 1. 去重:相同station_id + timestamp只保留最后一条 df = df.dropDuplicates(["station_id", "timestamp"]) # 2. 降水和风向风速逻辑校验 df = df.filter( (F.col("precipitation") >= 0) & ~((F.col("wind_speed") == 0) & (F.col("wind_direction") != 0)) ) # 3. 温度异常值过滤:物理阈值 + 站点历史极值范围 df = df.filter( (F.col("temperature") >= -40) & (F.col("temperature") <= 45) ) # 4. 对短时缺失做线性插值(按站点窗口内处理) from pyspark.sql.window import Window w = Window.partitionBy("station_id").orderBy("timestamp") df = df.withColumn("prev_temp", F.lag("temperature", 1).over(w)) df = df.withColumn("next_temp", F.lead("temperature", 1).over(w)) df = df.withColumn( "temperature_filled", F.when( F.col("temperature").isNull() & F.col("prev_temp").isNotNull() & F.col("next_temp").isNotNull(), (F.col("prev_temp") + F.col("next_temp")) / 2 ).otherwise(F.col("temperature")) )这里用窗口函数取上一条和下一条记录,缺失在两个有效观测值之间就取均值插补。注意窗口函数在数据量很大的时候shuffle开销不小,所以我建议先按站点做预排序,再配合合理分区数使用,能有效降低性能损耗。
3. 气候统计分析:把十几年的气象记录真正吃透
3.1 站点气候画像:均值、极值、变异系数
清洗完成后,第一步做的是站点气候画像。这是最基础也最有价值的产出,能直观看出西南不同地形下气候的极大差异。我用Spark SQL做分组聚合,按站点、月份、年份三个维度计算主要气候指标:
SELECT station_id, year, month, AVG(temperature) AS avg_temp, MAX(temperature) AS max_temp, MIN(temperature) AS min_temp, SUM(precipitation) AS total_precip, AVG(humidity) AS avg_humidity FROM weather_clean GROUP BY station_id, year, month ORDER BY station_id, year, month这个查询在几千万条记录上,Spark跑几十秒就能完成,换作Pandas单机处理不可想象。基于聚合结果,我把站点分成了几类典型气候画像:
- 盆地站点:夏季平均最高温超过32摄氏度,湿度常年偏高,高温日数和闷热天数明显多于其他区域。
- 高原站点:年均温偏低,但昼夜温差极大,极端日温差可达24摄氏度以上,年较差反而较小。
- 河谷站点:降水量明显偏少,干热特征突出,焚风现象带来的瞬时升温经常触发温度异常告警。
- 山地站点:降水随海拔变化显著,垂直气候带特征清晰,不同海拔高度站点之间的气温差异比水平距离差异更明显。
这些画像不是凭感觉写的,而是来自多个气候指标的交叉验证。比如我会同时看平均气温、极端气温频率和降水集中度三个指标,避免单一指标带来的误判。
3.2 极端天气事件的提取逻辑
除了常规统计,项目里还做了极端天气事件提取。暴雨、高温、持续性干旱这些事件,对农业和城市运行影响最大。提取逻辑需要识别“连续条件满足”的时间段,这里用窗口函数最方便。
以暴雨过程为例,我先定义单日暴雨阈值,日降水量≥50毫米记为暴雨日,然后识别连续3天及以上满足条件的过程事件:
WITH daily_precip AS ( SELECT station_id, DATE(timestamp) AS obs_date, SUM(precipitation) AS daily_precip FROM weather_clean GROUP BY station_id, DATE(timestamp) ), flagged AS ( SELECT station_id, obs_date, daily_precip, CASE WHEN daily_precip >= 50 THEN 1 ELSE 0 END AS storm_flag FROM daily_precip ), numbered AS ( SELECT station_id, obs_date, storm_flag, ROW_NUMBER() OVER (PARTITION BY station_id ORDER BY obs_date) AS rn FROM flagged ) SELECT station_id, obs_date, storm_flag FROM numbered ORDER BY station_id, obs_date这个查询的核心思路是,用行号减去连续日期的差值来分组,连续满足条件的日期会落在同一组里。识别出来的暴雨过程,再和实际灾害记录做对照,准确率还不错。这套逻辑完全跑在Spark上,不需要引入额外工具。
3.3 空间视角:按气候相似性给站点分组
统计完站点画像,我把各站的年均温、年降水量、湿度变率等特征汇总成一个特征矩阵,然后用MLlib里的KMeans做无监督聚类,想看看机器是否能自动划分出气候区。
我把站点分成4类,跑完发现结果和经验气候分区高度吻合:一类集中在低海拔湿热区域,一类对应高原干冷区域,一类对应河谷干热区域,还有一类是山地过渡区域。聚类结果直接生成了图表,作为后续分析的分层依据。比如做农业气象服务时,同一个物候模型直接套用到不同聚类区,参数需要分开标定。
这一步的价值在于,分布式聚类算法处理数百个站点、多个特征的数据几乎瞬间完成,而且聚类逻辑完全可复现,别人拿到代码和数据也能跑出同样结果。
4. 用MLlib做降雨预测和趋势判断
4.1 把气象时间序列改造成学习样本
气候统计是看“过去”,机器学习建模则是想用“过去”推“未来”。我选了两个方向:降雨有无的短临预测,以及气温趋势的模拟推演。
第一步是把时间序列变成监督学习样本。气象观测数据本质是序列数据,不能直接丢给分类器,需要构造滞后特征。我的做法是,用过去1天、3天、7天的气压、湿度、温度、降水量作为特征,预测未来24小时是否有降雨。核心代码是窗口函数的lag操作:
from pyspark.sql import functions as F from pyspark.sql.window import Window w = Window.partitionBy("station_id").orderBy("timestamp") feature_df = weather_clean.withColumn("temp_lag1", F.lag("temperature", 1).over(w)) feature_df = feature_df.withColumn("pres_lag1", F.lag("pressure", 1).over(w)) feature_df = feature_df.withColumn("hum_lag1", F.lag("humidity", 1).over(w)) feature_df = feature_df.withColumn("precip_lag3", F.lag("precipitation", 3).over(w)) # 标签:未来24小时是否有降水 w_label = Window.partitionBy("station_id").orderBy("timestamp").rowsBetween(1, 24) feature_df = feature_df.withColumn( "rain_label", F.when(F.sum("precipitation").over(w_label) > 0.1, 1).otherwise(0) )这里有个很容易踩的坑:滞后特征用过去预测未来,但窗口排序不当会造成数据泄漏。比如标签窗口如果包含了当前时刻,模型就会直接看到答案。我反复检查了窗口边界,确保预测窗口起点严格在当前观测之后。
4.2 训练一个随机森林分类器
特征构造完成后,用VectorAssembler把特征列组装成向量,然后训练随机森林分类器:
from pyspark.ml.feature import VectorAssembler from pyspark.ml.classification import RandomForestClassifier from pyspark.ml.evaluation import BinaryClassificationEvaluator feature_cols = ["temp_lag1", "pres_lag1", "hum_lag1", "precip_lag3"] assembler = VectorAssembler(inputCols=feature_cols, outputCol="features") data = assembler.transform(feature_df).select("features", "rain_label") train_data, test_data = data.randomSplit([0.8, 0.2], seed=42) rf = RandomForestClassifier( numTrees=200, maxDepth=10, labelCol="rain_label", featuresCol="features" ) model = rf.fit(train_data) pred = model.transform(test_data) evaluator = BinaryClassificationEvaluator(labelCol="rain_label", metricName="areaUnderROC") auc = evaluator.evaluate(pred)在西南气象数据上,这个模型的AUC达到了0.82左右,对“有无降雨”这种强不平衡问题来说已经很不错。我也对比了逻辑回归,AUC明显低不少,原因是气象要素和降雨之间是非线性关系,随机森林能捕捉到气压骤降、湿度陡增这些组合特征带来的信号。
4.3 MLlib和Python生态的协作方式
做机器学习的读者可能习惯用sklearn,Spark MLlib和它并不是竞争关系。我的做法是用Spark做大规模特征工程和初筛,把最终训练集采样导出到本地,再用sklearn做超参数细调和模型解释性分析。这种方式两边优势都用上了。
具体实现是,用Spark跑完特征构建后,调用toPandas把聚合后的样本拉回本地。注意样本量不能太大,我会先做类别平衡采样,然后再导出,避免单机内存爆掉。整个过程反馈很快,试错成本很低。
5. 分析结果怎么落地:从图表到业务决策
5.1 可视化:数据出集群的几种方式
分析做得再深,最终还是要让人看得懂。可视化这块我用了三种方式,按场景灵活切换。
第一种是把Spark聚合结果写到PostgreSQL,再用Superset做仪表盘,适合展示给业务部门反复查看。第二种是直接用PySpark的结果抽样后,配合Matplotlib画轻量级图表,适合快速探索性分析,比如画站点温度聚类散点图、降水分布直方图。第三种是面向实时监测场景,用Spark Structured Streaming做微批处理,结果推送到近实时看板,用于天气数据的实时监控和告警。
需要提醒的是,直接把几十亿行数据toPandas再画图必挂无疑。最稳妥的做法是,先用Spark把维度聚合到很小规模,比如每天每站一条记录,再出图。这个心智模型贯穿了我的整个可视化开发流程。
5.2 面向业务的几个落地场景
数据分析不是为了自我满足,最终要落到业务上。这个项目里,我最满意的是三个应用方向:
第一个是农业气象服务。根据积温分析和降水趋势预测,给特定作物种植区提供播种窗口建议。比如某海拔区间春季积温达到阈值且未来一周无强降雨时,判定适合播种,这类信息对种植户很有价值。
第二个是电力负荷预测辅助。西南部分区域夏季高温和冬季湿冷都会带来用电高峰,把极端温度预测结果作为负荷预测模型的输入特征,比单纯用历史负荷拟合效果更好。气候分析在这里变成上游特征工厂。
第三个是城市内涝风险管理。短时强降水过程的识别结果,和历史积水点数据进行叠加分析,可以辅助排水部门提前布防。虽然我这里只做到了离线分析,但结合实时数据源的潜力很大。
6. 性能调优与踩坑实录
6.1 Spark作业的初始参数给我上的第一课
项目初期,我直接用默认配置跑,结果极其难看。某个聚合任务执行到一半,一个executor直接内存溢出,整个stage失败重试。问题出在默认的spark.sql.shuffle.partitions是200,但我的数据分布极不均匀,热门时段的shuffle数据全部砸到了极少数的task上。
调参之后,作业稳定性明显提升。下面是我的实测配置:
| 参数 | 初始值 | 调优后 | 说明 |
|---|---|---|---|
| spark.executor.memory | 4g | 8g | 聚合和窗口函数需要更多堆内存 |
| spark.executor.cores | 2 | 4 | 提高单executor并行度 |
| spark.sql.shuffle.partitions | 200 | 800 | 减少单task处理的数据量 |
| spark.sql.autoBroadcastJoinThreshold | 10m | 50m | 小表自动广播,避免shuffle join |
| spark.sql.files.maxPartitionBytes | 128m | 256m | 减少分区数量,降低调度开销 |
调参没有一个万能配方,必须结合数据量和集群资源来试。我每次调整都会记录作业执行时间和稳定性,定向优化瓶颈环节。
6.2 “热点站点”引起的数据倾斜
气象数据的时间分布相对均匀,但空间分布却高度不均衡。有些区域的关键站点数据量极多,有些高山站点几乎无人维护数据稀疏。做groupBy聚合时,数据量大的站点会集中到同一个task,造成严重的任务倾斜,表现为绝大多数task几秒跑完,个别task要跑十分钟。
解决数据倾斜,我用了三个手段。第一,设计合理分区键,避免单站点单分区;第二,对热点站点加盐拆分,比如给station_id拼接随机后缀做中间聚合,再合并结果;第三,调整spark.sql.shuffle.partitions,让shuffle后的分区更细。实际效果最明显的还是加盐方案,热点task的耗时从十分钟降到了三十秒以内。
6.3 一次长达四小时的任务排查经历
最后分享一次印象深刻的排查。某天跑全量历史数据重算,一个作业卡了整整四个小时没结束,我看日志发现某个stage一直在重试,原因不是资源不够,而是某个分区里的数据存在严重的时间戳乱序,导致窗口函数计算时产生了异常膨胀的中间结果。
我一开始以为是数据格式问题,后来手动抽样才发现,某批导入的历史数据时间戳格式不一致,有一部分是字符串,一部分是时间对象,排序逻辑完全乱掉。处理方式是把数据先做统一的类型转换和排序,再重新分区,最后重跑任务,四小时直接降到了四十五分钟。
这次之后我养成了一个习惯:大任务跑之前,先对数据做一次轻量级“体检”,检查分区数、空值率、时间戳有序性。这几个检查在Spark里都是秒级完成,却能避免绝大多数无谓的长时间运行。
最后再说一点我自己的实际感受。气象数据分析从单机搬到Spark,表面上是用了一个更“重”的技术栈,但本质上是被数据规模和业务深度逼出来的。我到现在仍然觉得,分布式计算不是为了炫技,而是让你能从海量数据里真正提取出有业务价值的规律。如果你想做类似的气象或时序数据分析,我的建议是,不要一上来就追求复杂的模型和花哨的架构,先把数据清洗做扎实,把滞后特征构造的逻辑想清楚,再用Spark把全量数据跑一遍,你会看到完全不一样的风景。