做全国降水分析可视化这个项目,算是我今年写过最“磨人”但也最值钱的一个系统。名字听起来很唬人,剥开来看其实就三层事:Spring Boot搭后端接口、MySQL存全国各站的降水数据、前端用ECharts把地图和图表渲染出来。真做起来,每一层都有暗坑,尤其是数据清洗和地图渲染这两块,稍不注意就要返工。如果你正准备搞一个类似的Spring Boot数据可视化项目,或者是第一次碰大屏类系统,这篇基本能帮你把整条链路的路提前蹚平。
这个系统的核心价值就一句话:把枯燥的降水观测数据,变成业务方能直接看懂的全国降水分布图、趋势曲线和省份排行。系统面向的是气象数据管理人员、农业或水利相关业务分析人员,也适合作为Spring Boot技术栈的综合性练手项目。我会按数据层、后端、可视化、部署、排障这个顺序,把关键设计和实操过程完整讲一遍。
1. 项目背景与整体设计思路
1.1 降水数据可视化到底在做什么
很多人一听“降水分析”就往气象模型上想,实际做系统没那么玄乎。降水分析可视化,本质上是一个典型的数据汇聚、统计、展示流程:把分散在各气象站点的降水观测记录收集起来,按省份、日期、站点维度做聚合统计,再通过地图热力、时间趋势、柱状排行等形式呈现给用户。
这个系统的核心功能可以拆成五块:全国降水总览、分省降水量统计、单站历史趋势分析、降水排行榜、时间维度对比。每一块都对应后端至少一个统计接口,前端至少一张图表。我曾见过不少项目把功能拆得过细,接口写了几十个,但前端只用到五六个,维护成本全浪费在无效设计上。更合理的做法是,先确定页面原型需要哪几张图,再反推接口设计,这是这个项目里我认为最值得坚持的思路。
从业务角度说,用户最关心的通常是三件事:全国哪些地方最近下了大雨、某个省历年的降水趋势是怎样的、今年和往年比是偏旱还是偏涝。系统设计时就要把这三类查询做成毫秒级响应,否则大屏上转圈圈,业务方第一句话就是“系统卡了”。
1.2 技术选型:Spring Boot不是唯一答案,但最稳
选型的时候我认真对比过几套方案。第一套是Python Flask加PyECharts,优点是处理气象数据有现成的科学计算库,缺点是前端展示能力弱,登录、权限、接口规范这些企业级能力基本要从零搭。第二套是Spring Cloud全家桶,能力确实全,但对于一个降水分析系统来说太重了,微服务拆分反而增加部署和调试成本。
最终我选了Spring Boot单体应用。理由很直接:Spring Boot内嵌Tomcat,打包即跑,官方生态在Web开发、MyBatis集成、Redis缓存这几块都相当成熟,项目里要用的东西基本都有稳定方案。更重要的是,大部分单位现有的技术栈都是Java系,后续交给别人维护时不会出现“这系统是个没人会的语言写的”这种尴尬。
技术栈我列一下,都是经过实际验证的组合:
| 模块 | 选型 | 理由 |
|---|---|---|
| 后端框架 | Spring Boot 2.7 | 稳定、生态全、社区资料多 |
| 持久层 | MyBatis-Plus + PageHelper | 单表CRUD省代码,分页查询方便 |
| 数据库 | MySQL 8.0 | 关系型数据存储,气象数据天然是结构化数据 |
| 缓存 | Redis | 统计类接口高并发场景下扛压 |
| 前端可视化 | Vue 3 + ECharts 5 | 大屏图表能力最强,配置灵活 |
| 地图数据 | GeoJSON + ECharts Map | 中国省市边界绘制标准方案 |
| 部署 | Docker + docker-compose | 一键编排MySQL、Redis、应用和前端 |
这套组合的思路是,每一层都用“最主流且最小化”的工具,不追求新奇特,保证项目能按时交付。你可能会问为什么不直接用PostGIS做空间数据库,因为这里的空间维度只需要到“省份”级别,用普通表的省份字段和GeoJSON里的name字段做关联就够了,引入PostGIS属于杀鸡用牛刀。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据层:降水数据从哪来、怎么存
2.1 数据来源与预处理
降水数据是这个系统的心脏,没有真实可靠的数据,可视化做得再炫也是空中楼阁。实际项目中,数据通常来自中国气象数据网公开的中国地面气象站逐日观测数据集,或者单位内部的气象数据库,很多时候交付给你的就是一个CSV或者Excel文件。
我当时拿到的原始数据,长这样:
code复制站号,站名,省份,纬度,经度,日期,降水量
54511,北京,北京,39.80,116.47,2024-05-01,12.5
58362,上海,上海,31.40,121.47,2024-05-01,8.2
看着简单,实际处理起来问题一堆。原始CSV里日期格式可能是“2024/5/1”而不是“2024-05-01”,降水量可能带单位、带引号,还有一些站点的观测值是“32700”这类表示缺测的特殊编码。这些脏数据如果直接灌进数据库,后端的SUM、AVG全部要出问题。
我用的预处理策略是两步走。第一步用Python脚本做格式标准化和异常值剔除,脚本逻辑很简单:把日期统一成yyyy-MM-dd格式,降水量转成float,遇到负数、大于500毫米的极端值、缺测标记码直接置空,再按“站号+日期”去重。第二步再把清洗后的数据导出成UTF-8编码的标准CSV,最后通过后端批量导入接口写入MySQL。
提示:CSV文件编码这一步很容易翻车。Windows下Excel导出的CSV默认是GBK编码,直接用Java读取会中文乱码。统一转成UTF-8后再处理,能省一堆麻烦。
2.2 数据库表设计与索引
我设计了四张核心表:站点表、降水事实表、省份字典表、统计汇总表。这里最有讲究的是降水事实表,它存的是明细数据,全国几百个站点、十年数据就能到百万级甚至千万级,是最容易拖垮查询性能的地方。
明细表的建表语句大致是:
sql复制CREATE TABLE precip_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
station_code VARCHAR(20) NOT NULL COMMENT '站点编号',
province_code VARCHAR(10) NOT NULL COMMENT '省份编码',
record_date DATE NOT NULL COMMENT '观测日期',
precipitation DECIMAL(6,1) DEFAULT NULL COMMENT '降水量,单位mm',
UNIQUE KEY uk_station_date (station_code, record_date),
KEY idx_province_date (province_code, record_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
唯一索引和联合索引是关键。业务上最常做的查询是“某段时间内某省份的总降水量”,所以province_code + record_date联合索引必须建。station_code + record_date唯一索引保证同一站点同一天只有一条记录,数据导入重复时可以直接用它做去重。
还有一个设计决策值得说:我额外建了一张月统计汇总表,按“省份+月份”预聚合。大屏总览页看的是全国近30天的降水总量,如果每次实时去明细表里SUM几百万条记录,即使有索引也要几百毫秒。汇总表把明细数据按月份压缩,查询只扫几十条记录,响应时间直接降到几十毫秒。实时性要求不高的统计场景,用这种“空间换时间”的预聚合方式非常划算。
2.3 数据清洗时最容易翻车的三个点
这里分享几个真实踩过的坑。
第一是“暴雨值”误杀。有一次统计结果里海南的降水量异常偏大,查了半天发现是原始数据里某站点的日降水量灌入了“580.0”这种明显错误值。自动清洗脚本当时把大于500的先删了,但有些月份的累计值混进了明细表。后来我改成:先按站点的历史降水量做箱线图分析,超出3倍四分位距的才标记为异常,而不是简单用固定阈值一刀切。
第二是站点迁移问题。气象站的经纬度不是永远不变的,有些站会因为城市规划搬迁,站名和站号可能不变但坐标变了。如果只按站号关联经纬度,会把太原的降水柱状图画到临汾去。稳妥的做法是把“站点历史位置表”单独拆出来,按“站号+生效日期”记录位置,界面展示时取最新位置。
第三是港澳台数据的处理。公开数据集里的站点基本都在内地,但GeoJSON地图里包含港澳台区域,如果地图有这些区域而数据里没有,ECharts里对应的区域就会显示为“no data”,颜色空着很难看。我建议在省份字典表里把这些区域补上,降水量默认填全国平均值或null,前端用visualMap的noData样式处理成淡灰色,视觉上更干净。
3. 后端接口:Spring Boot的核心实现
3.1 接口怎么拆才合理
降水分析系统的后端接口设计,我坚持“按页面拆、按场景聚合”的原则。页面上一张图对应一个接口,宁可多写两个小接口也不做一个所有参数堆在一起的万能接口。
我最终定了这几个核心接口:
| 接口 | 方法 | 功能 | 返回数据 |
|---|---|---|---|
| /api/precip/overview | GET | 全国近30天降水总量、站点数 | 汇总对象 |
| /api/precip/province | GET | 指定时间段各省降水量 | 省份数组 |
| /api/precip/trend | GET | 指定省份逐月降水趋势 | 月份数组 |
| /api/precip/toplist | GET | 降水量TOP10省份 | 排行榜数组 |
| /api/precip/station | GET | 指定站点历史降水明细 | 明细数组 |
每个接口都返回统一的结果对象,这是Spring Boot项目里的标准动作:
java复制@Data
public class Result<T> {
private Integer code;
private String msg;
private T data;
public static <T> Result<T> success(T data) {
Result<T> r = new Result<>();
r.code = 200;
r.msg = "success";
r.data = data;
return r;
}
}
统一返回体的好处前端拿数据时逻辑极其简单:先看code是不是200,是就直接渲染data,省去每个接口单独处理异常结构。后端的全局异常处理器把参数校验异常、业务异常、系统异常分别转成不同的code,前端根据code做统一提示,联调效率高了很多。
3.2 聚合统计SQL与MyBatis分页
后端最核心的一段逻辑,是按省份和时间范围做降水聚合。最开始我手写SQL,代码里散落着一堆GROUP BY province_code,后来全部收敛成Mapper里的一个统计方法:
xml复制<select id="selectProvinceSum" resultType="map">
SELECT p.province_name AS name,
ROUND(SUM(r.precipitation), 1) AS value
FROM precip_record r
JOIN province_dict p ON r.province_code = p.province_code
WHERE r.record_date BETWEEN #{startDate} AND #{endDate}
GROUP BY r.province_code, p.province_name
ORDER BY value DESC
</select>
这个SQL的要点在JOIN和GROUP BY的顺序,先按日期范围过滤再去关联省份字典,比先JOIN再过滤效率高不少。MySQL的优化器对大数据量的JOIN顺序很敏感,实际压测中,这套写法比先JOIN后WHERE快了近一倍。
分页需求主要出现在站点明细列表和区域明细表里。MyBatis的PageHelper是业界最常见的分页方案,用法非常简单:
java复制PageHelper.startPage(pageNum, pageSize);
List<StationPrecipVO> list = stationMapper.selectPageByCondition(query);
PageInfo<StationPrecipVO> pageInfo = new PageInfo<>(list);
但有一个细节必须注意:PageHelper.startPage必须紧接着Mapper方法调用,中间不能插入其他查询,否则分页插件会把错误的SQL当作COUNT语句去执行,直接报“unknown column”之类的错误。这是PageHelper被吐槽最多的坑,本质上是ThreadLocal机制导致的,严格遵守“startPage后立即查”的规矩就能避开。
3.3 Redis缓存为什么能救大屏接口
大屏系统的特点是“高频读、低频写”。全国降水总览这个数字,数据源每天更新一次,但大屏上每5秒可能就刷新一次。如果每次都去MySQL里SUM整张明细表,数据库压力非常大,一旦并发上来,接口响应时间从几十毫秒飙到几秒,大屏直接转圈圈。
用Redis把统计接口的JSON结果缓存起来,是这类系统的通用解法。我当时的缓存设计是这样:
java复制@Service
public class PrecipOverviewService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String CACHE_KEY = "precip:overview:30d";
public OverviewVO getOverview() {
String cached = redisTemplate.opsForValue().get(CACHE_KEY);
if (cached != null) {
return JSON.parseObject(cached, OverviewVO.class);
}
OverviewVO vo = queryFromDatabase();
redisTemplate.opsForValue().set(CACHE_KEY, JSON.toJSONString(vo), 30, TimeUnit.MINUTES);
return vo;
}
public void refreshOverview() {
redisTemplate.delete(CACHE_KEY);
}
}
缓存key的设计花了点心思。precip:overview:30d这种命名格式,冒号分隔业务模块、接口名、参数维度,在Redis可视化客户端里一眼就能看清是哪条数据的缓存。排查问题的时候,打开Redis客户端工具直接看key和TTL,比猜逻辑快得多。
缓存更新策略上,我没有用复杂的双删或者消息队列,就是“读时写缓存 + 数据更新时主动删缓存 + TTL兜底”。每次管理员导入新数据后,导入接口里调用一次refreshOverview删除相关缓存,下次读取自动回源数据库并重建缓存。TTL设置成30分钟,即使哪天主动删缓存的逻辑漏掉了,缓存也会自动过期,不会长期展示脏数据。
4. 可视化:ECharts全国降水大屏落地
4.1 中国地图GeoJSON与ECharts适配
全国降水图的核心是地图。ECharts 5之后官方不再内置中国地图数据,需要自己准备中国省份的GeoJSON文件。我从阿里云DataV的地理数据工具里取了标准GeoJSON,这个算是目前国内社区用得最多的方案。
拿到GeoJSON后的第一步是注册地图,初始化ECharts实例前先注册:
javascript复制import * as echarts from 'echarts';
import chinaGeoJson from '@/assets/china.json';
echarts.registerMap('china', chinaGeoJson);
然后地图配置的核心是series里的map类型和visualMap组件:
javascript复制const option = {
tooltip: {
trigger: 'item',
formatter: params => `${params.name}<br/>降水量:${params.value ?? '暂无数据'} mm`
},
visualMap: {
min: 0,
max: 200,
inRange: {
color: ['#dce8f5', '#a6c8e8', '#5ea4d6', '#2f6fb2']
},
text: ['高', '低'],
calculable: true
},
series: [{
name: '降水量',
type: 'map',
map: 'china',
roam: true,
label: { show: true, fontSize: 10 },
data: provinceData
}]
};
这里最容易踩的坑是“地名对不上”。数据库里的省份字段可能是“内蒙古”,而GeoJSON里的name是“内蒙古自治区”,ECharts匹配不到就显示为空白。我用一个前端映射表解决:
javascript复制const provinceNameMap = {
'内蒙古': '内蒙古自治区',
'广西': '广西壮族自治区',
'西藏': '西藏自治区',
'宁夏': '宁夏回族自治区',
'新疆': '新疆维吾尔自治区',
'香港': '香港特别行政区',
'澳门': '澳门特别行政区'
};
在把后端数据结构化成{name, value}数组时,统一把短名替换成GeoJSON的完整名称。这个细节不处理,地图上一眼就能看出几个省份是灰的,非技术人员看到第一反应就是“系统有问题”。
提示:地图GeoJSON务必使用带标准审图号的公开版本,这是合规要求,也是地理信息系统的底线。不要用来源不明的非标准数据。
4.2 地图、趋势图、柱状图的联动逻辑
一个能打动业务方的可视化系统,绝不是几张图孤立堆在那里。我当时给大屏设计了联动逻辑:点击地图上某个省份,右侧的趋势图和柱状图自动切换成该省份的数据。
ECharts地图自带点击事件,通过chart.on('click', params => ...)拿到点击的省份名称,再把名称传回后端接口查询该省的趋势数据:
javascript复制mapChart.on('click', async params => {
const provinceName = params.name;
const trendRes = await fetch(`/api/precip/trend?province=${encodeURIComponent(provinceName)}&months=12`);
const trendData = await trendRes.json();
lineChart.setOption({
xAxis: { data: trendData.map(i => i.month) },
series: [{ data: trendData.map(i => i.value) }]
});
});
为了让趋势图看起来更专业,我在折线图里叠加了“去年同期降水量”的对比曲线。用户一眼就能看出今年比去年高还是低,业务方非常吃这一套。给折线图设置areaStyle渐变填充,视觉层次也更好。
柱状图展示的是降水量TOP10省份,点击柱状图也可以联动地图高亮。两边的联动核心是维护好selectedProvince这个状态变量,避免多次点击产生重复请求。我用一个简单的防抖函数限制500毫秒内的重复点击,实测大屏操作的流畅度提升明显。
4.3 大屏布局和渲染性能优化
大屏和小屏的视觉设计逻辑完全不同。大屏的常规做法是1920x1080基准下的栅格布局,用CSS Grid把页面切成多个卡片区域:左侧放趋势图和站点明细,中间放全国地图,右侧放TOP榜和统计卡片。
适配不同分辨率是必须处理的。我用的是vw/vh单位配合rem,把设计稿的像素尺寸转成相对单位,实测在1366x768和3840x2160的屏幕上都能保持比例。这个方案比媒体查询省事得多,一套代码适配全场景。
性能优化方面有三个动作值得分享。第一是ECharts实例的复用,切换数据时用setOption更新而不是销毁重建,新建实例的初始化开销很大,频繁销毁重建会导致页面卡顿。第二是图表数据的懒加载,首页只请求总览和省图数据,趋势图、站点明细这些“第二屏”的数据在用户点击时才请求,首屏打开速度能快1.5秒左右。第三是定时刷新的合理频率,大屏右下角的“最后更新时间”用setInterval每30秒刷新一次,而不是5秒一次,既满足实时性又不会把后端接口打成筛子。
5. 前后端联调与Docker部署
5.1 前后端联调的沟通成本都在哪
前后端分离的项目里,联调阶段最容易出幺蛾子的是三个地方:跨域、日期格式、空值处理。
跨域问题我用后端CorsFilter统一解决,而不是在前端配代理:
java复制@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
日期格式的问题更隐蔽。后端返回的LocalDate默认序列化出来是"2024-05-01"还好,但如果用了Date类型,序列化出来可能就是时间戳或者带时分秒的长字符串,前端解析各种崩。统一在application.yml里配置好Jackson的日期格式:
yaml复制spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
空值处理的原则是:后端不返回null字段,统一给默认值。Java对象里的BigDecimal字段如果为空,序列化时前端拿到null,ECharts的数值轴会直接渲染异常。我在VO类里给数值字段全部设默认值0或者null并配合前端?? 0兜底。双方的约定写进接口文档,比靠自觉高效得多。
5.2 Docker部署Spring Boot项目的标准玩法
这个项目最终部署用的是Docker Compose,编排MySQL、Redis、后端应用三个容器。后端镜像用多阶段构建,先Maven打包再JRE运行,镜像体积能从400MB压到180MB左右:
dockerfile复制FROM maven:3.8-openjdk-11 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package -DskipTests
FROM openjdk:11-jre-slim
COPY --from=builder /app/target/precip-system.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
docker-compose配置里有一个关键细节:后端容器依赖MySQL和Redis,不能只写depends_on,要额外做健康检查,否则MySQL还没初始化完成,后端就开始连库然后无限重启。我用mysql镜像自带的mysqladmin ping做healthcheck:
yaml复制services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: precip
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
retries: 10
app:
build: .
ports:
- "8080:8080"
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
部署完成后还有一个容易被忽视的事:时区。MySQL容器默认UTC时区,Java应用默认系统时区,两边一错配,查询“近30天降水”这种逻辑就会差出8个小时的边界,统计结果看起来就莫名其妙少了几天的数据。我在MySQL连接串上强制指定serverTimezone=Asia/Shanghai,并把JVM时区也设成GMT+8,彻底杜绝了这类问题。
6. 常见问题与排查技巧实录
6.1 接口超时:数据量大了之后的第一杀手
项目联调阶段,前端同事提了一个问题:“总览接口偶尔要3秒才返回”。我看了一眼SQL,SUM(precip_record.precipitation)全表扫描,百万级数据量下耗时必然上来了。
排查链路是这样的:先开MySQL慢查询日志,发现precip_record表的全表扫描占了99%的耗时。加上idx_province_date联合索引后,扫描范围从全表缩小到目标日期段。再把总览接口的结果用Redis缓存,响应时间从秒级降到50毫秒以内。
这里想多说一句,慢SQL排查别凭感觉。我习惯用EXPLAIN SELECT ...看执行计划,重点关注type字段是不是ALL,如果是ALL说明没走索引,rows字段可以预判扫描行数。type至少要达到range或者ref才算健康。
6.2 地图空白或边界显示异常的排查
地图区域空白,十有八九是地名匹配不上。ECharts的map类型会拿data里的name和GeoJSON的properties.name做严格匹配,多一个空格少一个字都匹配不上。排查时我写了一个脚本,把GeoJSON里所有省份名和数据库里省份字典全量输出,人工对照一遍,超过一半的空白问题当场就能定位。
还有一种情况是地图整体显示异常,比如南海诸岛位置不对、台湾消失。这种基本是GeoJSON文件本身问题。我的处理方式是把GeoJSON下载到本地后用VS Code打开,搜索“南海诸岛”和“台湾”,确认文件完整再使用。另外,ECharts注册地图时echarts.registerMap('china', geoJson)的key是'china',和series里的map: 'china'必须完全一致,大小写不一致也会白屏。
6.3 几个容易被忽视但致命的坑
最后整理几个我这次项目里印象最深的小坑,都是代码层面看不到的“隐形杀手”。
第一是Spring Boot版本过高导致的依赖冲突。我一开始顺手用了Spring Boot 3.2,结果MyBatis-Plus和PageHelper的兼容版本还没完全跟进,启动就报NoSuchMethodError。后来退回2.7.x,一切恢复正常。如果你的项目也是老牌框架组合,别追新版本,稳定压倒一切。
第二是Redis键的序列化乱码。用RedisTemplate默认的JdkSerializationRedisSerializer存中文,在Redis可视化客户端里看到的是\xAC\xED\x00\x05t\x00...一串乱码。配置成StringRedisSerializer序列化JSON字符串,既解决乱码又方便排查。
第三是前端地图文件打包体积过大。china.json有接近2MB,直接打包进JS会让首屏加载慢。我用Vite的import静态引入配合build配置,或者干脆放到public目录下按需加载,实测首屏速度提升明显。
做这个系统的过程中,我最深的一个体会是:可视化项目表面比的是图表酷不酷,实际比的是数据链路稳不稳。地图渲染得再花哨,后端接口一个超时一分钟的SQL就能让整个大屏失去意义。所以我的建议是,做这类Spring Boot可视化项目,先把数据清洗和索引优化做扎实,再去折腾大屏动效;先把接口文档和前端对齐,再去调渐变色和动画时长。这套顺序走完,项目交付的时候你会省下很多返工的力气。最后再分享一个小技巧:开发阶段遇到图表显示异常,不要急着怀疑代码,先在浏览器F12里看Network面板,确认接口数据到底回来没有、字段名对不对,八成以上的“图表Bug”都是数据格式问题。
