Java气象数据可视化:SpringBoot+Redis+Hadoop全链路
2026/9/18 9:57:22 网站建设 项目流程

简介:《基于Java的河南省气象数据可视化系统的论文》是一份面向计算机专业应届生与Java毕业设计选题者的docx论文文档,围绕河南省气象数据可视化系统,梳理从需求分析、功能模块划分到系统实现与论文成文的完整过程。压缩包内仅含1个docx文件,大小约2.09MB,内容预览可见中英文摘要、目录、绪论及后续章节,属于毕业论文类资料。已有214人浏览学习,适合需要参考同类选题结构、技术选型与写作框架的读者。文档以SpringBoot、MyBatis、Redis、MySQL、Bootstrap为核心技术栈,并引入Hadoop、SSM与MapReduce处理气候数据;前台涉及天气详情、报警预警、天气上报、监测个性化等模块,后台涵盖天气管理、天气分类、气候数据、异常监测、账户与上报管理。读者可借此了解系统功能设计、数据库关系分析、缓存应用及大数据统计思路,为毕业设计开题、论文撰写和答辩准备提供参考。

1. 一套气象可视化毕设里,真正值得抄的是哪部分

很多人拿到「基于 Java 的河南省气象数据可视化系统」这类题目,第一反应是前端挂个 ECharts 大屏就算交差。但把论文里的表结构和模块清单摊开看,能撑起「企业级数据可视化」这几个字的,其实是后台那条从气象数据采集、落库、缓存、阈值判定到离线统计的完整链路:SpringBoot 负责接口编排与事务边界,MyBatis 把 MySQL 里的天气表、气候数据表、报警表映射成对象,Redis 顶住高频的城市天气查询,Hadoop 与 MapReduce 做历史气候的离线聚合,最后才是 Bootstrap + ECharts 把结果画成大屏。

这套技术组合放到真实的数据可视化项目里也不违和,做毕业设计、想补 Java 后端完整成长路线、或者准备把「免费数据可视化大屏」改成能跑业务的原型,都能从里面拆出可复用的模块。下面按工程落地顺序,把数据接入、缓存预警、离线统计、联调排错逐层拆开,代码和参数都给到能直接抄的程度。

2. 气象数据落库:MySQL 表结构与 MyBatis 映射的落地细节

2.1 先清理论文物理模型表里的模板残留

论文给出的表结构有明显的电商模板痕迹:天气信息表里躺着Weather_Price(价格)、Weather_Num(库存),气候数据表里有Monitor_ExpressNo(物流号码)、Monitor_Price(金额)。这显然是从商城类毕设改过来的,字段名没清干净。落到真实气象业务,这几列要么删掉,要么改成语义正确的字段。我一般的做法是先把字段做一轮对齐:

论文原字段存在的问题建议处理
Weather_Price气象要素没有价格概念删除
Weather_Num库存语义错位改为 rainfall DECIMAL(7,2)
Monitor_ExpressNo物流号与气象无关删除
Good_Id命名来自商品表改为 city_code VARCHAR(12)
Monitor_DateDATE 精度只到天改为 collect_time DATETIME

对齐原则有三条:时间字段必须到秒,否则同一天多次观测会互相覆盖;城市一律用行政区划码做关联键,中文城市名只作展示,避免改名后历史数据对不上;所有气象要素值统一用DECIMAL而不是VARCHAR,论文里把气温、湿度都定义成Varchar(20),一旦要做区间查询和排序就会出问题。

提示:改造表结构时不要直接改原表,先建新表再INSERT ... SELECT迁移,答辩演示时旧数据还在手里。

2.2 weather_data 时序表与联合唯一键

气象数据本质是「城市 + 时间 + 多要素」的时序数据,表设计的核心就是把幂等做在数据库层,而不是靠应用层判断。

CREATE TABLE `weather_data` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `city_code` VARCHAR(12) NOT NULL COMMENT '城市行政区划码,如 410100', `city_name` VARCHAR(32) NOT NULL COMMENT '城市名称,仅展示用', `temp` DECIMAL(5,2) DEFAULT NULL COMMENT '气温,单位摄氏度', `humidity` DECIMAL(5,2) DEFAULT NULL COMMENT '相对湿度,单位 %', `pressure` DECIMAL(7,2) DEFAULT NULL COMMENT '气压,单位 hPa', `rainfall` DECIMAL(7,2) DEFAULT NULL COMMENT '降雨量,单位 mm', `collect_time` DATETIME NOT NULL COMMENT '采集时间,精确到秒', `data_source` TINYINT DEFAULT 1 COMMENT '1 自动站 2 人工上报', PRIMARY KEY (`id`), UNIQUE KEY `uk_city_time` (`city_code`, `collect_time`), KEY `idx_collect_time` (`collect_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='气象要素时序表';

uk_city_time这个联合唯一键是整个表设计里最关键的一行。自动站定时上报、人工补报、接口重试都会产生重复数据,有了唯一键,入库统一走ON DUPLICATE KEY UPDATE,重复上报就自然变成覆盖更新,不需要在 Java 里先selectupdateidx_collect_time是给大屏服务的:按时间范围拉近 24 小时曲线时,没有这个索引会走全表扫描。

2.3 阈值表与指标字典

论文里提到「气候数据阀值管理」,但没给出独立的阈值表,实际都被塞进了Monitors表。阈值应该单独建表,并且按「城市 + 指标」唯一,一个城市一个指标只能有一条生效配置。

CREATE TABLE `climate_threshold` ( `id` INT NOT NULL AUTO_INCREMENT, `city_code` VARCHAR(12) NOT NULL COMMENT '城市行政区划码', `metric` VARCHAR(16) NOT NULL COMMENT '指标名:temp/humidity/rainfall', `lower_limit` DECIMAL(7,2) NOT NULL COMMENT '下限', `upper_limit` DECIMAL(7,2) NOT NULL COMMENT '上限', `warn_level` TINYINT NOT NULL COMMENT '1蓝 2黄 3橙 4红', `enabled` TINYINT DEFAULT 1 COMMENT '是否启用', PRIMARY KEY (`id`), UNIQUE KEY `uk_city_metric` (`city_code`, `metric`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='气候数据阈值表';

metric用字符串而不是数字枚举,好处是排查问题时redis-cli和日志里一眼能看懂,代价只是一点存储空间。warn_level用 1 到 4 对应蓝黄橙红,前端大屏按等级换色时直接映射,不用再做字符串判断。

2.4 MyBatis 映射与批量上报的幂等实现

查询最新观测的 Mapper 里,参数全部走#{}预编译,避免拼接 SQL。LIMIT也做成参数,防止前端传个极大值把库打满。

<select id="selectLatestByCity" resultType="com.henan.meteo.entity.WeatherData"> SELECT city_code, city_name, temp, humidity, pressure, rainfall, collect_time FROM weather_data WHERE city_code = #{cityCode} AND collect_time &gt;= #{startTime} ORDER BY collect_time DESC LIMIT #{limit} </select>

批量入库依赖前面建立的唯一键,一条 SQL 搞定插入与更新:

<insert id="batchUpsert"> INSERT INTO weather_data (city_code, city_name, temp, humidity, pressure, rainfall, collect_time, data_source) VALUES <foreach collection="list" item="it" separator=","> (#{it.cityCode}, #{it.cityName}, #{it.temp}, #{it.humidity}, #{it.pressure}, #{it.rainfall}, #{it.collectTime}, #{it.dataSource}) </foreach> ON DUPLICATE KEY UPDATE temp = VALUES(temp), humidity = VALUES(humidity), pressure = VALUES(pressure), rainfall = VALUES(rainfall), city_name = VALUES(city_name) </insert>

服务层再做一次分批,避免单条 SQL 过大触发max_allowed_packet

@Service public class WeatherReportService { private static final int BATCH_SIZE = 500; @Autowired private WeatherDataMapper weatherDataMapper; /** 批量入库,靠 uk_city_time 唯一键实现幂等,重复上报即覆盖 */ @Transactional(rollbackFor = Exception.class) public int batchSave(List<WeatherData> list) { if (list == null || list.isEmpty()) { return 0; } int rows = 0; for (List<WeatherData> part : Lists.partition(list, BATCH_SIZE)) { rows += weatherDataMapper.batchUpsert(part); } return rows; } }

BATCH_SIZE取 500 是经验值,太小则网络往返次数多,太大则单条 SQL 文本过长。@TransactionalrollbackFor显式写成Exception.class,因为 Spring 默认只对运行时异常回滚,MyBatis 抛的PersistenceException属于运行时异常没问题,但业务校验抛的受检异常不会回滚,写全更安全。

2.5 三个高频踩坑点

第一,map-underscore-to-camel-case没开。数据库是collect_time,实体是collectTime,不配置这个开关查出来的字段全是null,而且不报错,最难查。在application.yml里加mybatis.configuration.map-underscore-to-camel-case: true即可。

第二,字符集用utf8而不是utf8mb4。MySQL 的utf8实际只有 3 字节,存城市名里的生僻字或后续扩展的 emoji 会报错,建库建表统一utf8mb4

第三,时区。JDBC 连接串里不写serverTimezone=Asia/ShanghaiDATETIME读出来会整体偏移 8 小时,大屏曲线会整体错位。写全连接串:jdbc:mysql://127.0.0.1:3306/meteo?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true,最后一个参数开启批处理重写,批量插入性能能提升数倍。

3. Redis 缓存与阈值预警链路

3.1 缓存粒度与 Key 设计

论文摘要里写「将各个城市相关的气候数据持久化到 Redis 缓存数据库,提高了系统的访问速度」,但没给 Key 设计。缓存如果按整表缓存,一个城市更新就要全量失效,实际会把 MySQL 压力放大。按查询维度拆 Key 才靠谱:

Key 模式类型内容TTL
meteo:city:{code}:latestString(JSON)该城市最新一条观测10 分钟
meteo:city:dictHash城市码 → 城市名映射不过期
meteo:warn:{code}List该城市待推送预警消息24 小时
meteo:rank:rainZSet当日降雨量排行,成员为城市码1 小时
meteo:stat:{code}:{month}String(JSON)月度统计结果30 分钟

Key 统一带meteo:前缀,方便用SCAN批量清理,也避免和同一个 Redis 实例上的其他业务撞名。TTL 不是拍脑袋定的:最新观测 10 分钟对应自动站上报频率,预警消息 24 小时对应「当天有效的告警不看就过期」,统计结果 30 分钟因为 MapReduce 任务本身就是按小时或按天跑的。

3.2 SpringBoot 中配置可读的 RedisTemplate

SpringBoot 默认的RedisTemplate用 JDK 序列化,redis-cli get出来是一串乱码,排查问题极其痛苦。换成 JSON 序列化:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); ObjectMapper om = new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.registerModule(new JavaTimeModule()); // 支持 LocalDateTime om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); Jackson2JsonRedisSerializer<Object> json = new Jackson2JsonRedisSerializer<>(Object.class); json.setObjectMapper(om); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(json); template.setHashValueSerializer(json); template.afterPropertiesSet(); return template; } }

setKeySerializerStringRedisSerializer是重点,Key 保持纯字符串,redis-cli keys 'meteo:*'才看得见。JavaTimeModule解决实体里LocalDateTime序列化报InvalidDefinitionException的问题,不加这一行启动后第一次写入缓存就炸。

3.3 阈值判定与预警消息生成

预警的核心逻辑是拿最新观测值去比阈值,超限就产生一条消息推到 Redis List。这里有个容易翻车的点:浮点数比较必须用BigDecimal

@Autowired private RedisTemplate<String, Object> redisTemplate; /** 校验一条观测数据是否越界,越界则写入预警队列 */ public void checkAndWarn(WeatherData data, List<ClimateThreshold> thresholds) { for (ClimateThreshold t : thresholds) { if (t.getEnabled() == 0) { continue; } BigDecimal value = readMetric(data, t.getMetric()); if (value == null) { continue; } boolean over = value.compareTo(t.getUpperLimit()) > 0; boolean under = value.compareTo(t.getLowerLimit()) < 0; if (!over && !under) { continue; } WarnMessage msg = new WarnMessage(); msg.setCityCode(data.getCityCode()); msg.setCityName(data.getCityName()); msg.setMetric(t.getMetric()); msg.setValue(value); msg.setWarnLevel(t.getWarnLevel()); msg.setReason(over ? "超过上限" : "低于下限"); msg.setCreateTime(LocalDateTime.now()); String key = "meteo:warn:" + data.getCityCode(); redisTemplate.opsForList().leftPush(key, msg); redisTemplate.expire(key, 24, TimeUnit.HOURS); } } private BigDecimal readMetric(WeatherData d, String metric) { switch (metric) { case "temp": return d.getTemp(); case "humidity": return d.getHumidity(); case "rainfall": return d.getRainfall(); default: return null; } }

compareTo而不是==equals,因为BigDecimalequals会比较 scale,new BigDecimal("30.0")new BigDecimal("30.00")是不相等的,用equals会出现阈值明明配了 30 却判断不出相等的诡异现象。leftPush配合前端lrange读取,天然按时间倒序。每次 push 后重新expire,保证队列不会因为长期有人写入而永不过期堆积。

3.4 缓存一致性:先更库再删缓存

气象数据写入 MySQL 后,meteo:city:{code}:latest必须失效,否则大屏会一直显示旧值。顺序很重要:先更新数据库,再删除缓存。反过来先删缓存再更库,在并发读的情况下,读线程可能把旧值重新加载进缓存,形成长期脏数据。删缓存失败时的兜底方案是给缓存本身的 TTL 设短一点,靠 10 分钟自动过期兜住极端情况,这比引入消息队列做缓存双删更适合毕设级别的项目。

注意:不要用@CacheEvict只清当前方法对应的 Key,批量上报场景下一次要清多个城市的缓存,注解表达不了,手写redisTemplate.delete(keys)更直接。

4. Hadoop MapReduce 离线统计与可视化大屏对接

4.1 为什么时序聚合不放在 MySQL 里做

论文提到用 MapReduce 做气候数据分析,这个选择在数据规模上说得通。假设河南省 18 个地市、每个地市 10 个自动站、每 10 分钟一条观测,一年就是 18 × 10 × 6 × 24 × 365 ≈ 946 万条。这个量级在 MySQL 里做「按城市按月求均温、求降雨累计」还能扛,但如果再加维度(按站点、按小时段、按要素组合),GROUP BY会开始走临时表加文件排序,大屏轮询接口就顶不住了。把月度、年度这种重聚合甩给 MapReduce,MySQL 只存结果,是合理的分层。

MapReduce 任务的输入输出约定要提前定死,不然后期格式一改就得重跑:

约定
输入路径/meteo/raw/{yyyyMM}/
输入格式逗号分隔:城市码,气温,湿度,气压,降雨量,采集时间
Map 输出 Key城市码 + 制表符 + 月份(如410100\t2021-07
Map 输出 Value气温数值
Reduce 输出城市码 + 月份,均温 + 样本数
输出路径/meteo/stat/monthly/{yyyyMM}/

4.2 Map 阶段:切分与脏数据过滤

public class MonthlyTempMapper extends Mapper<LongWritable, Text, Text, DoubleWritable> { private final Text outKey = new Text(); private final DoubleWritable outVal = new DoubleWritable(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString().trim(); if (line.isEmpty()) { return; } // 输入:城市码,气温,湿度,气压,降雨量,采集时间(yyyy-MM-dd HH:mm:ss) String[] f = line.split(","); if (f.length < 6) { return; // 脏数据直接丢弃,避免污染统计结果 } String temp = f[1]; if (temp == null || temp.isEmpty() || "-".equals(temp)) { return; // 缺测值不参与均值计算 } String month = f[5].substring(0, 7); // 截出 2021-07 outKey.set(f[0] + "\t" + month); try { outVal.set(Double.parseDouble(temp)); } catch (NumberFormatException e) { return; } context.write(outKey, outVal); } }

split(",")这里假设源数据里没有带逗号的字段,如果城市名是中文且没有引号包裹,这个假设成立;一旦后续改成分号或制表符分隔,split的正则要同步改,否则f.length判断会全部走到丢弃分支,任务跑完结果为空,而且日志里一条错误都没有,非常难查。

4.3 Reduce 阶段:求均值与 Combiner 优化

public class MonthlyTempReducer extends Reducer<Text, DoubleWritable, Text, Text> { @Override protected void reduce(Text key, Iterable<DoubleWritable> values, Context context) throws IOException, InterruptedException { double sum = 0D; long count = 0L; for (DoubleWritable v : values) { sum += v.get(); count++; } if (count == 0) { return; } // 输出:城市码\t月份 均温\t样本数 context.write(key, new Text(String.format("%.2f\t%d", sum / count, count))); } }

均值计算满足结合律,可以直接把 Reducer 同时注册成 Combiner,在 Map 端先做一次局部合并,减少 shuffle 数据量。做法是在 Driver 里加一行job.setCombinerClass(MonthlyTempReducer.class);。注意这个技巧只适用于可结合可交换的聚合,如果是求中位数、求去重后的众数,Combiner 会算出错误结果。样本数count一起输出,是为了后续校验:如果某个城市某月的样本数明显低于预期,说明该月有大量缺测,均温不具备代表性,大屏上应该标注数据完整度而不是直接画点。

Driver 端设置和输出:

public class MonthlyStatDriver extends Configured implements Tool { @Override public int run(String[] args) throws Exception { Configuration conf = getConf(); Job job = Job.getInstance(conf, "monthly-temp-stat"); job.setJarByClass(MonthlyStatDriver.class); job.setMapperClass(MonthlyTempMapper.class); job.setCombinerClass(MonthlyTempReducer.class); job.setReducerClass(MonthlyTempReducer.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(DoubleWritable.class); job.setNumReduceTasks(4); // 按数据量调整,太小并发不足 FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); return job.waitForCompletion(true) ? 0 : 1; } }

setNumReduceTasks(4)决定输出文件个数,4 个 reducer 会产出 4 个part-r-0000x文件。回写阶段要么在 Java 里遍历目录读取所有 part 文件,要么用getmerge合并成一个文件再导入。任务重跑前必须先删掉输出目录,Hadoop 默认不允许输出路径已存在,否则直接抛FileAlreadyExistsException

4.4 统计结果回写与大屏接口

MapReduce 的输出落到/meteo/stat/monthly/{yyyyMM}/part-r-*,由一个定时任务在凌晨读取并写入 MySQL 的stat_monthly表,同时清掉meteo:stat:*相关缓存。前端大屏的接口只查结果表:

@GetMapping("/screen/monthly") public Result<List<MonthlyVO>> monthly(@RequestParam String cityCode, @RequestParam String month) { String cacheKey = "meteo:stat:" + cityCode + ":" + month; Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return Result.ok((List<MonthlyVO>) cached); } List<MonthlyVO> list = statService.queryMonthly(cityCode, month); redisTemplate.opsForValue().set(cacheKey, list, 30, TimeUnit.MINUTES); return Result.ok(list); }

大屏侧用 ECharts 的双 Y 轴画「气温折线 + 降雨柱状」,X 轴直接吃城市列表,Y 轴吃对应的均温和累计降雨。刷新策略上,5 分钟轮询接口足够应付演示场景;真的要更实时,再换成服务端推送,把meteo:warn:{code}这个 List 作为消息源,新预警进来就推一次,不必重新拉全量曲线。

提示:ECharts 数据里如果混进null,折线会断开。接口返回前把null统一替换成'-',让 ECharts 显示为断点而不是从零开始画。

5. 联调阶段必查的几个点与一次压测

系统能启动不代表能演示,答辩现场翻车通常不是功能没写完,而是几个隐蔽配置问题。下面这几个现象我最常碰到:

现象定位方式常见根因
大屏气温全为 nullredis-cli get meteo:city:410100:latest序列化器不一致,旧数据是 JDK 序列化残留
上报后查询无变化比对collect_time入库值与页面值连接串缺少serverTimezone
阈值明明配了却不预警打印BigDecimal.compareTo结果用了equals,scale 不同导致不等
首页加载超过 5 秒EXPLAINtypecollect_time上没有索引,走全表
MapReduce 结果为空看 Counter 里的 Map input records分隔符与split不一致,全部走丢弃分支

排查顺序建议从缓存往外倒:先确认 Redis 里的值对不对,再确认接口返回的 JSON 对不对,最后才看前端渲染,能省掉大量「以为是前端 bug」的时间。redis-cli --bigkeys用来确认有没有大 Key 拖慢整体,MONITOR只适合短时间开,长时间开着会明显拉低实例性能。

上线演示前跑一次压测,确认接口在并发下不掉链子。用ab打最新观测接口,重点看失败请求数和 P99:

# -n 总请求数,-c 并发数;关注 Failed requests 是否为 0 ab -n 5000 -c 200 -H "Accept: application/json" \ "http://127.0.0.1:8080/api/weather/latest?cityCode=410100&limit=24"

如果Failed requests不为 0,先看是不是 Tomcat 的max-threads打满,再看 Redis 连接池max-active是否够用,最后才怀疑数据库。压测时同步观察 Redis 命中率,缓存接入后这个接口的 QPS 应明显高于直连数据库的版本,如果两者差不多,说明缓存 Key 没命中,多半是 Key 拼接时漏了城市码或者 TTL 设得过短。

最后一个容易被忽略的细节:Hadoop 任务产出的part-r-00000文件编码是 UTF-8,用 Excel 打开会中文乱码,回写 MySQL 时如果中间经过了LOAD DATA LOCAL INFILE,记得显式指定CHARACTER SET utf8mb4,否则城市名入库变成问号,大屏上就是一排???

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询