简介:这是一套面向大数据与气象交叉领域学习者的Hadoop实战代码包,围绕分布式计算处理气象数据展开,适合已掌握Java基础、希望理解HDFS与MapReduce实际应用的学生或开发者。资源以MapReduce为核心,涵盖数据清洗、分组、聚合等环节,可计算平均气温、最高最低温度、降雨量等指标,并借助SSM框架将分析结果通过Web界面展示,涉及Spring Boot、Spring MVC与MyBatis的整合思路。压缩包共562个文件,约34.88MB,包含124个js、113个png、78个jpg、69个css等前端资源,60个xml与28个jar支撑工程配置,另有15个java、26个class、10个jsp及1个war体现完整项目结构,并附sql脚本便于数据落地。目前已有10587人学习下载,读者可从中获取从分布式存储、并行计算到结果可视化的完整链路参考,理解气象数据预处理、统计分析与Web呈现的衔接方式,为课程设计或大数据入门项目提供可复用的代码骨架与排错思路。
1. 从一份气象数据代码包说起:Hadoop 处理日均温的真实门槛
很多人第一次拿到「Hadoop分析气象数据完整版代码」这类资源,脑子里想的是解压、编译、跑通、出图。但真正动手才会发现,卡住你的不是 MapReduce 本身,而是气象数据那种「半结构化、按行定长、字段靠位置切」的原始格式。这份代码包解决的核心问题,是把气象站历史观测记录里的日均温、最高温、最低温,用 Hadoop MapReduce 跑出年度极值和月度均值。它适合两类人:一是正在学 Hadoop 但找不到真实数据练手的开发者,二是手里有气象观测数据、想用分布式思路做批量统计的从业者。代码本身不复杂,难的是数据格式解析和分区逻辑,这两块踩坑最多。
2. 气象数据格式拆解:从定长记录到 MapReduce 输入
2.1 气象原始数据的字段布局
常见的气象观测数据来自地面站逐日观测,格式通常是每行一条记录,字段按固定宽度排列。以典型的 GHCN 风格数据为例,一行里包含站号、年份、月份、要素类型、若干天数值。要素类型用四个字符标识,比如 TMAX 表示日最高温、TMIN 表示日最低温、PRCP 表示降水量。每个日值占 5 个字符,前 4 位是数值,最后一位是标志位,表示该值是否有效、是否估算、是否缺测。
这种格式对 MapReduce 来说有个天然优势:每一行独立,不需要跨行关联,天然适合按行切分的 InputFormat。但劣势也很明显——字段没有分隔符,不能直接用逗号或制表符切。你得按字符位置去截取,一旦位置算错,整个统计结果就是错的。我见过有人直接把整行按空格 split,结果站号里带空格的行全乱了。
2.2 为什么选 MapReduce 而不是 Spark
这份代码包用的是 MapReduce,不是 Spark。有人会问,现在 Spark 不是更快吗?从技术选型角度,气象数据年度统计这个场景有几个特点:数据量大但计算逻辑简单、不需要迭代计算、不需要低延迟交互。MapReduce 的磁盘落盘机制在这种「一次扫描、批量聚合」的场景下反而更稳,尤其是集群资源有限、内存不够大的时候,Spark 容易 OOM,MapReduce 慢慢跑总能出结果。
另外,这份代码包的定位是教学和练手,MapReduce 的编程模型更直观地体现了「分而治之」的思想。Mapper 负责解析每条记录、提取有效温度值,Reducer 负责按站号和年份聚合。整个流程清晰,适合理解分布式计算的基本范式。如果你已经熟悉 MapReduce,想迁移到 Spark,核心逻辑不变,只是 API 换一下。
2.3 输入格式的代码实现
代码包里自定义了一个 InputFormat,继承自 FileInputFormat,RecordReader 按行读取,每行作为一个完整记录传给 Mapper。这样做的好处是不依赖默认的 TextInputFormat 对行长的限制,气象数据里偶尔有超长行(比如某天补录了大量数据),默认的 LineRecordReader 在极端情况下会截断。
// 自定义 InputFormat,确保每行完整读取 public class WeatherInputFormat extends FileInputFormat<LongWritable, Text> { @Override public RecordReader<LongWritable, Text> createRecordReader( InputSplit split, TaskAttemptContext context) { return new WeatherLineRecordReader(); } } // RecordReader 实现,按行读取,不设行长上限 public class WeatherLineRecordReader extends RecordReader<LongWritable, Text> { private LineReader lineReader; private LongWritable key = new LongWritable(); private Text value = new Text(); private long pos = 0; @Override public void initialize(InputSplit split, TaskAttemptContext context) throws IOException { FileSplit fileSplit = (FileSplit) split; Path path = fileSplit.getPath(); FileSystem fs = path.getFileSystem(context.getConfiguration()); FSDataInputStream in = fs.open(path); // 关键参数:缓冲区大小,默认 64KB,气象数据行较短,64KB 足够 lineReader = new LineReader(in, context.getConfiguration()); // 跳过 split 起始位置之前的内容,避免重复读取 if (fileSplit.getStart() != 0) { in.seek(fileSplit.getStart() - 1); lineReader.readLine(new Text()); } } @Override public boolean nextKeyValue() throws IOException { // 每次读取一行,返回 true 表示还有数据 int newSize = lineReader.readLine(value); if (newSize == 0) { return false; } key.set(pos); pos += newSize; return true; } // 省略 getCurrentKey/getCurrentValue/close 等标准方法 }这段代码的关键点在于initialize里的 seek 逻辑。Hadoop 会把大文件切成多个 split,每个 split 从一个字节偏移量开始。如果直接从这个偏移量读,可能读到半行。所以要先回退一个字节,读掉不完整的那行,再从下一行开始正式处理。这个细节在默认的 LineRecordReader 里已经处理了,但自定义 RecordReader 时很容易漏掉,漏掉就会导致数据重复或丢失。
3. Mapper 与 Reducer 核心逻辑:温度提取和年度聚合
3.1 Mapper 的解析逻辑与容错
Mapper 的任务很明确:读一行气象记录,判断要素类型是不是 TMAX 或 TMIN,如果是,提取年份、站号和每天的温度值,输出<站号+年份, 温度>的键值对。但实际数据里有一堆脏情况:缺测值用 -9999 表示、标志位标记为无效的值要跳过、某些行要素类型不在关注范围内直接丢弃。
public class TemperatureMapper extends Mapper<LongWritable, Text, Text, IntWritable> { private Text outKey = new Text(); private IntWritable outValue = new IntWritable(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); // 气象数据行长度至少 21 字符(站号11+年份4+月份2+要素4) if (line.length() < 21) { return; // 太短的行直接跳过,可能是文件头或损坏行 } String stationId = line.substring(0, 11).trim(); String year = line.substring(11, 15).trim(); String element = line.substring(17, 21).trim(); // 只处理最高温和最低温 if (!"TMAX".equals(element) && !"TMIN".equals(element)) { return; } // 从第 21 位开始,每 5 个字符一个日值 for (int i = 21; i + 5 <= line.length(); i += 5) { String tempStr = line.substring(i, i + 4).trim(); String flag = line.substring(i + 4, i + 5); // 标志位为 9 表示缺测,直接跳过 if ("9".equals(flag)) { continue; } try { int temp = Integer.parseInt(tempStr); // 缺测值 -9999 跳过 if (temp == -9999) { continue; } outKey.set(stationId + "_" + year); outValue.set(temp); context.write(outKey, outValue); } catch (NumberFormatException e) { // 解析失败的行不中断任务,记录日志后跳过 System.err.println("解析失败: " + line.substring(i, i + 5)); } } } }这里有几个参数需要根据实际数据调整。substring(0, 11)里的 11 是站号宽度,不同数据集可能不同,GHCN 是 11 位,但有些国内数据集是 5 位站号。substring(17, 21)里的 17 是要素类型起始位置,这个偏移量取决于年份和月份字段的宽度。如果数据格式有差异,这两个数字必须改,否则提取出来的全是乱码。
3.2 Reducer 的聚合策略
Reducer 收到的是同一个站号+年份下的所有温度值,需要算出最高温、最低温和平均温。这里有个容易翻车的地方:TMAX 和 TMIN 在 Mapper 阶段没有区分,都输出成了同一个键。Reducer 拿到的一堆值里既有最高温也有最低温,直接取 max 和 min 会混淆。
代码包里的做法是在 Mapper 输出时把要素类型拼进 key,比如站号_年份_TMAX,这样 Reducer 就能分开处理。但这样会导致 Reduce 任务数翻倍。另一种做法是在 Reducer 里根据值的范围做二次判断,但气象数据里最高温和最低温的范围有重叠,不可靠。所以推荐第一种,key 里带要素类型。
public class TemperatureReducer extends Reducer<Text, IntWritable, Text, Text> { @Override protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException { int maxTemp = Integer.MIN_VALUE; int minTemp = Integer.MAX_VALUE; long sum = 0; int count = 0; for (IntWritable val : values) { int temp = val.get(); maxTemp = Math.max(maxTemp, temp); minTemp = Math.min(minTemp, temp); sum += temp; count++; } if (count > 0) { double avgTemp = (double) sum / count; // 输出格式:站号_年份 最高温/最低温/平均温 String result = maxTemp + "/" + minTemp + "/" + String.format("%.1f", avgTemp); context.write(key, new Text(result)); } } }Reducer 里的sum用 long 而不是 int,是因为一个站号一年的温度值累加可能超过 int 范围。虽然单个温度值不大,但累加几千次后 int 有溢出风险。这个细节在数据量小的时候看不出来,数据量一大就出负数,属于典型的「小数据测试通过、大数据翻车」问题。
3.3 Driver 配置与分区数设置
Driver 类负责组装 Job,设置 Mapper、Reducer、输入输出路径。这里最关键的参数是 Reduce 任务数。默认是 1,所有数据涌到一个 Reducer,数据量大的时候慢得离谱。一般设置为集群可用 Reduce slot 数的 0.9 倍左右,或者根据输出文件大小目标来定。
public class WeatherDriver { public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); Job job = Job.getInstance(conf, "weather-annual-stats"); job.setJarByClass(WeatherDriver.class); job.setInputFormatClass(WeatherInputFormat.class); job.setMapperClass(TemperatureMapper.class); job.setReducerClass(TemperatureReducer.class); job.setMapOutputKeyClass(Text.class); job.setMapOutputValueClass(IntWritable.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(Text.class); // 关键参数:Reduce 任务数,根据集群规模调整 job.setNumReduceTasks(4); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); System.exit(job.waitForCompletion(true) ? 0 : 1); } }setNumReduceTasks(4)这个值不是随便写的。如果集群有 4 个 Reduce slot,设 4 刚好跑满;设 8 会导致部分任务排队等待,反而增加总时间。另外,Reduce 数决定了最终输出文件的数量,设 4 就会生成 4 个 part-r-00000 到 part-r-00003。如果后续要合并结果,文件数太多也麻烦。
4. 避坑与排查:气象数据 MapReduce 的五个血泪教训
4.1 现象:任务跑完输出为空,日志无报错
原因:Mapper 里的字段偏移量写错了。比如站号实际是 5 位,代码里按 11 位截取,substring(0, 11)把年份和月份也截进去了,导致element字段取到的是空字符串或乱码,所有记录都被if (!"TMAX".equals(element))过滤掉。
解决:先用head -5看原始数据的前几行,数清楚每个字段的起始位置和宽度。不要凭记忆写偏移量,不同数据集格式差异很大。可以在 Mapper 里加一行context.getCounter("DEBUG", "TOTAL_LINES").increment(1),跑完后看计数器,确认 Mapper 到底读了多少行、过滤了多少行。
4.2 现象:Reduce 阶段卡在 99%,迟迟不结束
原因:数据倾斜。某个站号的数据量特别大,或者某个年份的记录异常多,导致一个 Reducer 处理了大部分数据,其他 Reducer 早就跑完了,就等这一个。气象数据里这种情况常见于热门站点或特殊年份。
解决:在 Driver 里设置自定义 Partitioner,把数据均匀打散。或者对 key 加随机前缀,先做一次局部聚合,再去掉前缀做全局聚合。代码包里用的是默认 HashPartitioner,如果数据倾斜严重,需要换成 RangePartitioner 或自定义实现。
4.3 现象:输出结果里温度值全是 -9999
原因:缺测值判断逻辑写反了。代码里写的是if (temp == -9999) continue;,但如果数据里的缺测值不是 -9999 而是 9999 或其他值,这个判断就失效了。另外,有些数据集的缺测标志位不是 9,而是其他字符。
解决:先统计一下数据里所有出现的标志位字符,用cut命令截取标志位列,sort | uniq -c看分布。确认缺测标志到底是哪个字符,再改代码。不要假设所有数据集都用同一套缺测约定。
4.4 现象:本地模式跑得通,集群上报 ClassNotFoundException
原因:自定义的 InputFormat 和 RecordReader 类没有打包进 Job 的 jar 里。本地模式跑的时候,类在 classpath 下直接加载;提交到集群后,只有 job jar 里的类才能被各节点加载。
解决:确保job.setJarByClass(WeatherDriver.class)指向的类所在的 jar 包含了所有自定义类。用jar tf your-job.jar | grep WeatherInputFormat确认一下。如果用的是 Maven,检查 pom 里的打包配置,确保自定义类没有被排除。
4.5 现象:输出文件数量太多,后续处理麻烦
原因:Reduce 任务数设得太大,或者没有设置输出合并。每个 Reducer 生成一个 part 文件,Reduce 数设成 100 就生成 100 个文件。
解决:根据最终数据量调整 Reduce 数。如果输出结果不大,设 1 个 Reducer 就够了,虽然慢一点但文件少。或者用FileOutputFormat.setCompressOutput开启压缩,减少磁盘占用。另外,Hadoop 的getmerge命令可以把多个 part 文件合并成一个,但要注意合并后的文件可能很大,后续处理要相应调整。
5. 进阶技巧:用 Combiner 和自定义计数器提升效率
5.1 Combiner 的正确用法与边界
Combiner 的本质是在 Map 端做一次本地 Reduce,减少网络传输。对于温度统计这种场景,Combiner 可以直接复用 Reducer 的逻辑,因为求最大值、最小值、累加和都是可结合的操作。但要注意,Combiner 的输出类型必须和 Mapper 的输出类型一致,否则会报类型转换错误。
// 在 Driver 里设置 Combiner job.setCombinerClass(TemperatureReducer.class);这行代码看起来简单,但有个前提:Reducer 的输入和输出类型必须一致。TemperatureReducer 的输入是<Text, IntWritable>,输出是<Text, Text>,类型不一致,不能直接当 Combiner 用。需要单独写一个 Combiner,输出<Text, IntWritable>,把最大值、最小值、累加和打包成一个自定义 Writable 传递。
代码包里没有用 Combiner,因为教学场景下数据量不大,网络传输不是瓶颈。但如果你的数据量到了 TB 级,Combiner 能减少 60% 以上的网络传输,效果非常明显。
5.2 自定义计数器监控数据质量
Hadoop 的计数器是个被低估的调试工具。除了内置的 Map input records、Reduce output records,你可以自定义计数器来统计脏数据比例。比如统计缺测值数量、解析失败行数、超出合理范围的温度值数量。
// 在 Mapper 里定义计数器 enum WeatherStats { MISSING_VALUE, // 缺测值数量 PARSE_ERROR, // 解析失败数量 OUT_OF_RANGE, // 超出合理范围(-60~60度) VALID_RECORD // 有效记录数 } // 在 map 方法里使用 if ("9".equals(flag)) { context.getCounter(WeatherStats.MISSING_VALUE).increment(1); continue; } if (temp < -60 || temp > 60) { context.getCounter(WeatherStats.OUT_OF_RANGE).increment(1); continue; } context.getCounter(WeatherStats.VALID_RECORD).increment(1);跑完任务后,在控制台输出里能看到这些计数器的值。如果 MISSING_VALUE 占比超过 30%,说明数据质量有问题,可能需要换数据源或调整清洗策略。如果 PARSE_ERROR 数量不为零,说明字段偏移量还有问题,需要回去检查。
5.3 输出结果的验证方法
MapReduce 跑完后,不要直接拿结果去用。先做几层验证:第一,用hdfs dfs -cat output/part-r-00000 | head -20看前几行,确认格式正确、温度值在合理范围内。第二,随机挑一个站号,用grep在原始数据里找到该站的所有记录,手工算一遍年度极值,和 MapReduce 输出对比。第三,统计输出行数,和原始数据里的唯一站号+年份组合数对比,确认没有漏算。
我一般会写一个简单的 Python 脚本做交叉验证,从原始数据里抽样 100 个站号,用 pandas 算一遍,再和 Hadoop 输出对比。如果两者一致,说明逻辑没问题;如果不一致,差异点就是排查方向。这个习惯帮我抓过好几次字段偏移量错误和缺测值判断遗漏。
从那以后我每次跑气象数据统计,都强制走一遍「计数器检查 + 抽样交叉验证」的流程,不管数据量大小。希望帮到你。
本文还有配套的精品资源,点击获取