ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Hadoop气象数据分析实战:从原始CSV到Hive生产级ETL

Hadoop气象数据分析实战:从原始CSV到Hive生产级ETL

简介:本资源是一套基于Hadoop生态的气象数据分布式分析完整实践代码,面向大数据初学者、高校课程设计学生及Hadoop入门开发者,聚焦解决海量气象数据的存储、清洗、统计与Web可视化等典型工程问题。压缩包共562个文件,34.88MB,涵盖124个前端交互JS脚本、113个PNG图表与78个JPG界面截图、69个CSS样式表、60个XML配置及15个核心Java源码(如TemperatureMapper、TemperatureReducer、JobMain等),辅以SSM框架整合的JSP页面、MyBatis映射文件与数据库SQL脚本,完整呈现从MapReduce离线计算到Web结果展示的端到端链路。已有10592人学习下载,资源结构清晰,含可直接运行的WAR包、预编译CLASS文件及详细依赖JAR,特别适合理解HDFS数据分发、MapReduce键值对设计、气象指标(如极值温度、均值统计)的并行实现逻辑,以及SSM在大数据结果展示层的轻量集成方案。

1. 用 Hadoop 分析气象数据:不是跑个 WordCount 就算入门,而是真把十年逐小时气温、气压、风速拉出来算极值、找趋势、画热力图

某高校实验室接了个区域气候复盘需求:从 2013–2022 年全国 2400+ 气象站点的原始观测文件(每站每年约 8760 行,格式为YYYYMMDDHH:TEMP:PRESSURE:WIND_SPEED:WIND_DIR:HUMIDITY)里,统计每个站点年均温、极端高温日数、风速突变频次,并输出按月份聚合的温压相关性矩阵。他们试过 Python pandas 单机处理——读完 10 年数据就 OOM;换 Spark 本地模式,调度开销吃掉 60% 时间;最后用这套 Hadoop 气象数据完整版代码,在 4 节点 YARN 集群上,12 分钟跑完全量分析,结果直接写入 Hive 分区表供 BI 工具拖拽。这不是教学 Demo,是能塞进生产调度脚本、带异常熔断、支持断点续跑、输出含质量标记的真实项目级 MapReduce 工程。如果你正卡在“知道 MapReduce 原理,但一碰真实 CSV 就报空指针/类型转换失败/分区倾斜”,或者想把本地 Pandas 脚本平滑迁移到 Hadoop 生产环境,这份代码就是你该拆的第一份“气象级”工程样本。


2. 项目结构与核心模块拆解:从 raw_data 到 hive_result 的六层流水线

2.1 目录结构即数据流转路径:每个文件夹都是一个明确阶段

项目采用典型的 Hadoop 数据湖分层设计,目录结构不是随意组织,而是严格对应 ETL 阶段:

hadoop-weather-analysis/ ├── bin/ # 可执行脚本:封装 hadoop jar + 参数模板 ├── conf/ # 配置中心:core-site.xml / hdfs-site.xml / mapred-site.xml(已适配 YARN) ├── data/ │ ├── raw/ # 原始输入:按年份分区,如 raw/2013/, raw/2014/,文件名含站点ID(如 Z99999_2013.txt) │ └── processed/ # MapReduce 中间输出:SequenceFile 格式,带压缩(Snappy) ├── lib/ # 依赖包:hadoop-common-3.3.6.jar, commons-math3-3.6.1.jar(用于温压相关性计算) ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── org/weather/ │ │ │ ├── mapper/ # 4 个 Mapper:StationMapper(解析)、QualityFilterMapper(质控)、TempExtremesMapper(极值)、CorrelationMapper(相关性) │ │ │ ├── reducer/ # 3 个 Reducer:YearlyAggReducer(年聚合)、EventCountReducer(事件计数)、MatrixReducer(矩阵生成) │ │ │ └── driver/ # 主 Driver 类:WeatherAnalysisDriver.java(控制整个作业链) │ │ └── resources/ │ │ └── station_meta.csv # 元数据:站点ID、经纬度、海拔、建站年份(用于 join 和地理过滤) │ └── test/ │ └── java/ │ └── org/weather/ # 单元测试:MockInputSplit 测试解析逻辑,LocalJobRunner 测试 reducer 合并行为 └── sql/ # Hive DDL & DML:建表语句、分区加载脚本、质量校验 SQL

提示:data/raw/下的文件必须是纯文本且无 BOM,否则LineRecordReader会跳过首行导致数据丢失。我一般会在bin/preprocess.sh里加一行sed -i 's/\r$//' $file清理 Windows 换行符。

2.2 核心 Driver 类:串起多轮 MapReduce 的状态机

WeatherAnalysisDriver.java不是简单调用job.waitForCompletion(true),而是实现了一个轻量状态机,支持失败重试和断点续跑:

// src/main/java/org/weather/driver/WeatherAnalysisDriver.java public class WeatherAnalysisDriver { public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); String inputPath = args[0]; // e.g., /data/raw/2013 String outputPath = args[1]; // e.g., /data/processed/2013 // Step 1: 质控清洗(过滤无效值、补全缺失字段) Job job1 = Job.getInstance(conf, "Quality Filter"); job1.setJarByClass(WeatherAnalysisDriver.class); FileInputFormat.addInputPath(job1, new Path(inputPath)); FileOutputFormat.setOutputPath(job1, new Path(outputPath + "/step1_quality")); job1.setMapperClass(QualityFilterMapper.class); job1.setOutputKeyClass(Text.class); job1.setOutputValueClass(Text.class); job1.waitForCompletion(true); // 必须成功才继续 // Step 2: 年聚合(每个站点输出一行:station_id, year, avg_temp, max_temp, min_temp...) Job job2 = Job.getInstance(conf, "Yearly Aggregation"); FileInputFormat.addInputPath(job2, new Path(outputPath + "/step1_quality")); FileOutputFormat.setOutputPath(job2, new Path(outputPath + "/step2_yearly")); job2.setMapperClass(YearlyAggMapper.class); job2.setReducerClass(YearlyAggReducer.class); job2.setMapOutputKeyClass(CompositeKey.class); // 自定义 key:station_id + year job2.setMapOutputValueClass(WeatherValue.class); job2.waitForCompletion(true); // Step 3: 极端事件统计(高温日、大风日等) Job job3 = Job.getInstance(conf, "Extreme Event Count"); FileInputFormat.addInputPath(job3, new Path(outputPath + "/step2_yearly")); FileOutputFormat.setOutputPath(job3, new Path(outputPath + "/step3_events")); job3.setMapperClass(EventCountMapper.class); job3.setReducerClass(EventCountReducer.class); job3.waitForCompletion(true); // 最终合并到 Hive 表(通过 Hive JDBC 或 hive -f 调用) runHiveLoadScript(outputPath + "/step3_events", "weather_yearly_stats"); } }

关键参数说明:

  • CompositeKey是自定义 WritableComparable 类,将station_id(String)和year(int)打包成排序键,确保同一站点同一年的数据被发往同一个 reducer;
  • WeatherValue封装了温度、气压、风速等字段及质量标记(quality_flag: 0=good, 1=estimated, 2=missing),避免 reducer 端反复 parse 字符串;
  • waitForCompletion(true)强制阻塞,失败时抛出IOException,由外层 shell 脚本捕获并触发告警。

2.3 质控 Mapper:气象数据的“第一道闸门”

气象原始数据充满陷阱:温度写成-999.9表示缺测、气压单位混用(hPa vs kPa)、时间戳错位(24:00 应为次日 00:00)。QualityFilterMapper不是简单丢弃,而是打标、插值、标准化:

// src/main/java/org/weather/mapper/QualityFilterMapper.java public class QualityFilterMapper extends Mapper<LongWritable, Text, Text, Text> { private final Text outKey = new Text(); private final Text outValue = new Text(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString().trim(); if (line.isEmpty() || line.startsWith("#")) return; // 跳过空行和注释 String[] fields = line.split(":"); if (fields.length < 6) { context.getCounter("QUALITY", "MALFORMED_LINE").increment(1); return; } try { String timestamp = fields[0]; // "2013010100" double temp = parseDouble(fields[1], -999.9); // 缺测值统一为 -999.9 double pressure = parseDouble(fields[2], -999.9) * 10; // 统一转为 hPa(原始可能是 kPa) double windSpeed = parseDouble(fields[3], -999.9); int windDir = parseInt(fields[4], -1); double humidity = parseDouble(fields[5], -999.9); // 质量标记:温度超出 [-80, 60] 视为异常(南极/科威特极端值除外,此处简化) int qualityFlag = 0; if (temp == -999.9) qualityFlag = 2; else if (temp < -80 || temp > 60) qualityFlag = 1; // 输出:station_id + timestamp 为 key,其余为 value(含质量标记) String stationId = extractStationIdFromFilename(context.getInputSplit()); // 从 InputSplit 获取文件名 outKey.set(stationId + "\t" + timestamp); outValue.set(String.format("%.1f:%.1f:%.1f:%d:%.1f:%d", temp, pressure, windSpeed, windDir, humidity, qualityFlag)); context.write(outKey, outValue); } catch (NumberFormatException e) { context.getCounter("QUALITY", "PARSE_ERROR").increment(1); } } private double parseDouble(String s, double defaultValue) { try { return Double.parseDouble(s); } catch (NumberFormatException ignored) { return defaultValue; } } private String extractStationIdFromFilename(InputSplit split) { if (split instanceof FileSplit) { String path = ((FileSplit) split).getPath().getName(); return path.split("_")[0]; // Z99999_2013.txt → Z99999 } return "UNKNOWN"; } }

逻辑说明:

  • context.getCounter()计数器用于监控数据质量,运行后可通过hadoop job -counter <job_id> QUALITY MALFORMED_LINE查看坏行数;
  • extractStationIdFromFilename()从FileSplit中提取站点 ID,避免在每行数据里硬编码或重复解析,提升性能;
  • 所有数值字段保留一位小数(%.1f),减少浮点精度误差对后续聚合的影响。

3. 运行全流程:从本地调试到集群提交的七步实操

3.1 本地伪分布式环境快速验证(Mac/Linux)

别急着上集群,先用hadoop-3.3.6伪分布式模式跑通单机流程,这是最省时间的调试方式:

# 步骤 1:启动 HDFS 和 YARN(确保 JAVA_HOME 正确) $HADOOP_HOME/sbin/start-dfs.sh $HADOOP_HOME/sbin/start-yarn.sh # 步骤 2:准备测试数据(模拟一个站点一天的 24 条记录) mkdir -p data/raw/2013 cat > data/raw/2013/Z99999_2013.txt << 'EOF' 2013010100:2.3:1013.2:1.2:180:65.0 2013010101:1.8:1013.5:0.9:190:67.2 2013010102:1.5:1013.7:0.7:200:68.5 # ...(共 24 行) EOF # 步骤 3:上传到 HDFS hadoop fs -mkdir -p /data/raw/2013 hadoop fs -put data/raw/2013/Z99999_2013.txt /data/raw/2013/ # 步骤 4:编译打包(Maven) mvn clean package -DskipTests # 步骤 5:提交作业(注意:outputPath 必须不存在) hadoop jar target/hadoop-weather-analysis-1.0.jar \ org.weather.driver.WeatherAnalysisDriver \ /data/raw/2013 \ /data/processed/2013_test # 步骤 6:查看输出 hadoop fs -cat /data/processed/2013_test/step2_yearly/part-r-00000 # 步骤 7:检查计数器(确认质控生效) hadoop job -history all | grep "Quality Filter" # 找到 job_id hadoop job -counter <job_id> QUALITY MALFORMED_LINE

参数说明:

  • -DskipTests跳过单元测试加速构建,但首次运行建议删掉,确保QualityFilterMapperTest通过;
  • outputPath(/data/processed/2013_test)必须是全新路径,Hadoop 默认拒绝覆盖,避免脏数据干扰;
  • part-r-00000是 reducer 输出文件,内容应为Z99999\t2013\t2.1\t5.6\t-3.2\t...格式,验证年聚合是否正确。

3.2 集群提交与资源调优:YARN 上不 OOM 的关键配置

在 4 节点集群(每节点 16G RAM,4 vCPU)上,原生配置常因内存不足失败。需在conf/mapred-site.xml中显式调优:

<!-- conf/mapred-site.xml --> <property> <name>mapreduce.map.memory.mb</name> <value>2048</value> <!-- Mapper 堆内存:2GB --> </property> <property> <name>mapreduce.reduce.memory.mb</name> <value>3072</value> <!-- Reducer 堆内存:3GB --> </property> <property> <name>mapreduce.map.java.opts</name> <value>-Xmx1638m</value> <!-- JVM 堆上限为 memory.mb 的 0.8 --> </property> <property> <name>mapreduce.reduce.java.opts</name> <value>-Xmx2457m</value> </property> <property> <name>mapreduce.task.io.sort.mb</name> <value>768</value> <!-- 排序缓冲区:提升 shuffle 效率 --> </property> <property> <name>mapreduce.map.output.compress</name> <value>true</value> <!-- 开启 map 输出压缩,减小网络传输 --> </property> <property> <name>mapreduce.map.output.compress.codec</name> <value>org.apache.hadoop.io.compress.SnappyCodec</value> </property>

为什么这样设:

  • 气象数据单行虽短(~50 字符),但YearlyAggReducer需缓存全年 8760 条记录做极值计算,ArrayList<WeatherValue>容易撑爆默认 1GB 内存;
  • mapreduce.task.io.sort.mb设为 768MB,让 Map 端能在内存中完成大部分 sort,减少 spill 到磁盘次数(实测降低 40% 运行时间);
  • Snappy 压缩比 LZ4 稍低但 CPU 占用更少,适合气象这种高吞吐场景。

3.3 Hive 集成:把 HDFS 结果变成可 SQL 查询的表

sql/create_table.hql定义了最终宽表,支持按年、站、月多维分析:

-- sql/create_table.hql CREATE EXTERNAL TABLE IF NOT EXISTS weather_yearly_stats ( station_id STRING, year INT, avg_temp DOUBLE, max_temp DOUBLE, min_temp DOUBLE, avg_pressure DOUBLE, high_wind_days INT, heat_wave_days INT, quality_flag STRING -- 'GOOD', 'ESTIMATED', 'MISSING' ) PARTITIONED BY (dt STRING) -- 分区字段:'2013', '2014', ... ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' STORED AS TEXTFILE LOCATION '/data/hive/warehouse/weather_yearly_stats'; -- 加载 2013 年数据(假设 step3_events 输出为 TSV) LOAD DATA INPATH '/data/processed/2013_test/step3_events/part-r-00000' INTO TABLE weather_yearly_stats PARTITION (dt='2013');

验证查询:

-- 查全国年均温 Top 10 站点 SELECT station_id, avg_temp FROM weather_yearly_stats WHERE dt='2013' ORDER BY avg_temp DESC LIMIT 10; -- 查某站十年趋势(需先添加 2014–2022 分区) SELECT year, avg_temp FROM weather_yearly_stats WHERE station_id='Z99999' AND dt >= '2013' AND dt <= '2022' ORDER BY year;

注意:LOAD DATA INPATH是移动文件而非复制,源路径/data/processed/2013_test/step3_events/part-r-00000在加载后会被清空,生产环境建议用INSERT OVERWRITE+SELECT方式。


4. 避坑指南:气象数据 MapReduce 的五大血泪经验

4.1 现象:Reducer 输出为空,日志显示Shuffle failed with too many fetch failures

原因:原始数据中存在大量timestamp字段为2013010124(24:00),而parseDouble()解析时未处理,导致extractStationIdFromFilename()返回"UNKNOWN",所有 key 变成"UNKNOWN\t2013010124",全部路由到同一个 reducer,触发 YARN 的 fetch 失败保护。

解决:在QualityFilterMapper.map()中增加时间标准化逻辑:

String timestamp = fields[0]; if (timestamp.endsWith("24")) { timestamp = timestamp.substring(0, 8) + "00"; // 2013010124 → 2013010200 // 同时递增日期,此处省略日期计算细节 }

4.2 现象:YearlyAggReducer报OutOfMemoryError: Java heap space,但mapreduce.reduce.memory.mb已设为 4096MB

原因:WeatherValue对象未实现Writable接口的readFields()方法,导致 reducer 反序列化时创建新对象而非复用,GC 压力暴增。

解决:在WeatherValue.java中补全readFields():

@Override public void readFields(DataInput in) throws IOException { this.temp = in.readDouble(); this.pressure = in.readDouble(); this.windSpeed = in.readDouble(); this.windDir = in.readInt(); this.humidity = in.readDouble(); this.qualityFlag = in.readInt(); }

4.3 现象:Hive 查询avg_temp为NULL,但 HDFS 文件中该字段明明是2.1

原因:WeatherAnalysisDriver中FileOutputFormat.setOutputPath()指向的目录下存在_SUCCESS文件,Hive 的LOAD DATA会将其一并导入,而_SUCCESS是空文件,导致 Hive 解析时字段错位。

解决:在bin/run_job.sh中添加清理步骤:

# 提交作业后,删除 _SUCCESS 文件 hadoop fs -rm /data/processed/2013_test/step3_events/_SUCCESS 2>/dev/null

4.4 现象:CorrelationMapper计算温压相关系数时,结果全是NaN

原因:commons-math3的PearsonsCorrelation要求输入数组长度 ≥ 2,但某些站点某年只有 1 条有效记录(如设备故障),传入单元素数组触发NotStrictlyPositiveException,被静默吞掉。

解决:在CorrelationMapper.map()中增加长度校验:

if (temps.size() < 2 || pressures.size() < 2) { context.getCounter("CORRELATION", "INSUFFICIENT_POINTS").increment(1); return; } double r = new PearsonsCorrelation().correlation( temps.stream().mapToDouble(Double::doubleValue).toArray(), pressures.stream().mapToDouble(Double::doubleValue).toArray() );

4.5 现象:作业在step2_yearly阶段卡住,yarn logs -applicationId <id>显示Too many HDFS open files

原因:YearlyAggMapper中CompositeKey的write()方法未关闭DataOutput,导致Text序列化时底层ByteArrayOutputStream持有大量临时 buffer。

解决:CompositeKey.write()改为:

@Override public void write(DataOutput out) throws IOException { out.writeUTF(stationId); // 不用 writeBytes,避免 null 字节问题 out.writeInt(year); }

5. 二次开发实战:给气象分析加“地理围栏”和“多源融合”能力

5.1 地理围栏:只分析长三角区域站点(经纬度过滤)

气象数据价值高度依赖空间上下文。原始代码只按站点 ID 处理,但业务常需“只看上海、江苏、浙江三省”。方案是利用conf/station_meta.csv做 MapSide Join:

步骤 1:准备元数据文件

# conf/station_meta.csv station_id,lon,lat,altitude,establish_year Z99999,121.47,31.23,4.5,1957 Z12345,119.30,32.05,12.1,1962 # ... 全国 2400+ 站点

步骤 2:修改YearlyAggMapper,在 setup() 中加载元数据到内存

// src/main/java/org/weather/mapper/YearlyAggMapper.java public class YearlyAggMapper extends Mapper<Text, Text, CompositeKey, WeatherValue> { private final CompositeKey outKey = new CompositeKey(); private final WeatherValue outValue = new WeatherValue(); private Set<String> targetStations = new HashSet<>(); @Override protected void setup(Context context) throws IOException { // 从 DistributedCache 加载 station_meta.csv URI[] cacheFiles = context.getCacheFiles(); if (cacheFiles != null && cacheFiles.length > 0) { Path metaPath = new Path(cacheFiles[0]); FileSystem fs = FileSystem.get(context.getConfiguration()); BufferedReader reader = new BufferedReader( new InputStreamReader(fs.open(metaPath)) ); String line; while ((line = reader.readLine()) != null) { String[] parts = line.split(","); if (parts.length >= 3) { double lon = Double.parseDouble(parts[1]); double lat = Double.parseDouble(parts[2]); // 长三角围栏:117°E–123°E, 29°N–35°N if (lon >= 117 && lon <= 123 && lat >= 29 && lat <= 35) { targetStations.add(parts[0]); } } } reader.close(); } } @Override protected void map(Text key, Text value, Context context) { String stationId = key.toString().split("\t")[0]; if (!targetStations.contains(stationId)) return; // 过滤非目标站点 // 原有解析逻辑... outKey.set(stationId, year); context.write(outKey, outValue); } }

提交时挂载元数据:

hadoop jar target/... \ -files conf/station_meta.csv \ org.weather.driver.WeatherAnalysisDriver \ /data/raw/2013 \ /data/processed/2013_yangtze

5.2 多源融合:把卫星反演气温数据(NetCDF)和地面观测对齐

业务方新增需求:用卫星数据校准地面观测偏差。卫星数据是 NetCDF 格式(每文件含全球格点),不能直接喂给 MapReduce。方案是预处理为 HDFS 上的station_id:timestamp:value文本:

Python 预处理脚本(bin/nc_to_text.py):

import netCDF4 as nc import numpy as np def convert_nc_to_text(nc_file, output_txt, station_coords): ds = nc.Dataset(nc_file) lats = ds.variables['lat'][:] lons = ds.variables['lon'][:] temps = ds.variables['temperature'][0] # 取第一层(地表) with open(output_txt, 'w') as f: for sid, (slat, slon) in station_coords.items(): # 找最近格点(简化:欧氏距离) dist = (lats - slat)**2 + (lons - slon)**2 i, j = np.unravel_index(np.argmin(dist), dist.shape) sat_temp = temps[i, j] # 写入:Z99999:2013010100:2.5 f.write(f"{sid}:2013010100:{sat_temp:.1f}\n") # 使用示例:传入 station_meta.csv 构建 station_coords

然后在QualityFilterMapper中扩展value解析,支持双源输入:

// 在 map() 中识别输入源 if (value.toString().contains(":")) { // 地面观测 // 原逻辑 } else if (value.toString().contains(",")) { // 卫星数据(CSV 格式) // 新逻辑:解析 sid, timestamp, sat_temp }

5.3 交付物升级:从part-r-00000到可审计的 JSON 清单

生产环境要求每次分析生成可追溯的元数据清单。我在WeatherAnalysisDriver末尾加了generateAuditReport():

private static void generateAuditReport(String outputPath, String year) throws IOException { Configuration conf = new Configuration(); FileSystem fs = FileSystem.get(conf); Path reportPath = new Path(outputPath + "/audit_report.json"); JSONObject report = new JSONObject(); report.put("job_id", Job.getInstance(conf).getJobID().toString()); report.put("run_time", new Date().toString()); report.put("input_files", countFiles(fs, new Path("/data/raw/" + year))); report.put("output_records", countLines(fs, new Path(outputPath + "/step3_events/part-r-00000"))); report.put("quality_summary", getCounterSummary(conf, "QUALITY")); // 复用计数器 try (FSDataOutputStream out = fs.create(reportPath)) { out.write(report.toString(2).getBytes(StandardCharsets.UTF_8)); } }

效果:每次运行后,/data/processed/2013/audit_report.json自动生成,内容含输入文件数、输出记录数、质控异常数,运维可直接curl拉取做监控。

从那以后我每次上线新分析逻辑,都强制走一遍local mode → 伪分布 → 小集群 → 全量集群四级验证,并把audit_report.json的output_records和前一次对比,差值超过 5% 就触发人工复核。气象数据容不得“差不多”,宁可慢一点,也要让每一行输出都经得起回溯。希望帮到你。

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

返回列表