每年六到八月,我都要帮气象和农业口的同事处理一堆降水数据。以前他们的工作方式是:导出Excel、拉透视表、再贴几张图,折腾一天就为回答“今年华北地区雨季降水量比去年多了还是少了”。后来我接了一个“springboot全国降水分析可视化系统”的活,把整条链路从数据采集、清洗、存储、接口聚合,到前端大屏展示全部打通。这篇文章就把这个系统的设计和实现过程完整拆一遍,重点讲清楚每一环为什么这么做、踩过哪些坑,以及代码层面的关键实现。如果你正打算做气象数据分析类的毕设,或者想练手Spring Boot + Vue + ECharts前后端分离项目,这篇内容可以直接拿来当参考方案。
项目本身不复杂,但胜在链路完整:后端用Spring Boot做数据接口和聚合统计,MySQL存历史降水数据,Redis扛住高频查询的缓存,前端用Vue 3 + ECharts画全国地图热力、折线趋势、柱状对比,最后拼成一张16:9的可视化大屏。我从技术选型开始讲,慢慢展开每个核心细节,最后附上部署和排坑记录。
1. 系统整体设计:先想清楚怎么拆,再动手写代码
1.1 为什么选 Spring Boot + Vue 前后端分离
做这个系统之前,我也纠结过要不要用传统的 Thymeleaf 模板方案。那套方案简单,一个 Spring Boot 应用直接把页面和数据一起吐出来,部署也省事。但我最终还是选了前后端分离,原因有三个。
第一,可视化大屏的前端交互非常重。地图下钻、图表联动、定时刷新,这些逻辑用模板引擎写起来会非常痛苦,Vue 的响应式数据和组件化开发体验明显更好。第二,前后端分离意味着接口可以被其他系统复用。比如气象内部的其他平台想调用“按省市查询降水统计”的接口,直接请求后端 API 就行,不需要依赖页面。第三,分开部署之后,前端用 Nginx 托管静态文件,后端独立跑在 Spring Boot 内置的 Tomcat 里,哪边出问题就重启哪边,互不拖累。
我一直觉得,选型不是比谁新,而是看你面对的场景重不重。如果只是内部查表,模板引擎足够;但要做大屏这种密集交互的可视化,前后端分离是更省心的路。
1.2 功能模块怎么拆:从全国总览到单站详情
拿到需求之后,我没有急着建表,而是把功能画成了一张脑图。核心分四块。
第一块是全国降水总览,用户打开系统先看到的是一张中国地图,每个省份用颜色深浅表示某一时段的累计降水量,旁边挂着全国均值、最大值、最大值站点这些核心指标卡。第二块是区域统计分析,用户可以选择省、市维度,查看月降水量、季度降水量,以及与去年同期的增减幅度。第三块是历史趋势,针对某个站点或者某个省份,用折线图展示多年降水波动。第四块是数据管理,维护站点信息、导入降水记录、修正异常值。
这个拆法有一个好处,就是前端可以按模块独立开发,后端接口也能一一对应。实际上手的时候,我按照“总览一张图、分析两张表、管理一组增删改查”来安排工作量,整个项目大概三周就完成了主体功能。
1.3 技术栈清单与版本选择
技术栈的具体版本如下,我写的时候是按稳定版本选的,兼顾生态和踩坑成本。
| 层级 | 选型 | 版本/说明 |
|---|---|---|
| 后端框架 | Spring Boot | 2.7.x,避免过低版本带来的安全问题 |
| ORM | MyBatis-Plus | 3.5.x,配合自带分页插件 |
| 数据库 | MySQL | 8.0,InnoDB 引擎 |
| 缓存 | Redis | 6.x,用于统计结果缓存 |
| 前端框架 | Vue 3 | Composition API |
| 图表库 | ECharts | 5.x,地图依赖 GeoJSON |
| 构建工具 | Vite | 4.x |
| 部署 | Docker + Nginx | 前后端容器化 |
这里说下为什么用 MyBatis-Plus 而不是原生 MyBatis。聚合统计 SQL 虽然要手写,但日常的站点维护、记录分页查询用 MyBatis-Plus 的 LambdaQueryWrapper 写起来非常快,尤其是动态条件拼接,比如“时间范围非必填、省份非必填”,传统 MyBatis 的 XML 得写一大段<if>,而 QueryWrapper 就是一行链式调用。
2. 数据链路:降水数据的采集、清洗与落库
2.1 数据源怎么选:免费公开接口与离线数据
可视化系统最怕的不是画图,而是没数据。我开始时想直接对接气象部门实时接口,但公开的实时细粒度降水数据往往需要申请权限,而且接口频率有限制。后来我用的方案是组合数据源。
历史数据从公开气象数据集里下载,常见的来源包括中国气象数据网提供的站点日值数据,里面包含区站号、经纬度、海拔、逐日降水量。自己搭的一套定时抓取脚本,则负责每天拉取部分公开接口的天气汇总数据做增量更新。如果只是做毕设或者演示,直接用一份近五年的全国站点历史降水 CSV 就够用了,完全没有必要强行接实时接口。
2.2 清洗规则:不能拿脏数据直接做可视化
降水数据比一般业务数据脏得多,这是我最想强调的一点。原始数据里常见几类问题。
一是缺失值,某个站点某天没有观测记录,常见编码为 32766、9999 这类缺测值,直接拿来画图会把平均值拉偏。二是异常值,降水量不可能为负,可原始文件里偶尔会出现 -1 这种用于占位的值。三是逻辑矛盾,比如日降水量 800 mm,已经超过历史极值,这类数据大概率是仪器故障。四是站点漂移,部分站点经纬度调整过,导致同一站号出现在不同位置。
我的清洗方案分三步。第一步,把缺测值替换为 NULL,不做插值。第二步,过滤负值和超过阈值的数据,阈值我用的是该站历史 99.9% 分位数,这样不会误删极端暴雨记录。第三步,对重复记录去重,依据是“站号 + 日期 + 时间”三字段组合。
用 pandas 做这些清洗当然可以,但我最终把清洗逻辑写成了 Java 定时任务,因为后续新数据进来也要走同一套规则。做成 Spring Boot 的 Job,维护起来只改一处逻辑,比手工跑脚本靠谱得多。
2.3 表结构设计:站点表、降水记录表、区域维度表
数据表我建了四张,核心是站点表和降水记录表。站点表字段包括站点编号、站点名称、省份、城市、纬度、经度、海拔。降水记录表字段包括主键、站点编号、观测日期、降水量、数据来源。
为了让按省聚合的 SQL 不写大段关联,我单独拆了一张站点地区映射表,存“站点编号、省份编码、城市编码、省份名称、城市名称”。这样统计维度就直接 join 地区表,不需要每次去解析经纬度。索引方面,降水记录表我建了联合索引(site_code, obs_date),所有按站点查询历史的请求都走这个索引;如果按日期范围查全国数据,就是(obs_date)索引。表建完之后,我导入了大约五年、六百多个站点的数据,规模大概 80 万行,MySQL 处理起来很轻松。
建表 SQL 核心片段如下:
CREATE TABLE t_precipitation_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, site_code VARCHAR(32) NOT NULL COMMENT '站点编号', obs_date DATE NOT NULL COMMENT '观测日期', precipitation DECIMAL(6,1) DEFAULT NULL COMMENT '降水量,单位mm', source_type TINYINT DEFAULT 1 COMMENT '1-历史导入 2-定时采集', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_site_date (site_code, obs_date), INDEX idx_date (obs_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;3. 后端核心实现:统计接口怎么做才有分析味
3.1 项目分层与初始配置
Spring Boot 后端我按经典四层结构来组织:controller 接参数、service 写业务、mapper 操作数据库、entity 映射表。另外加了 dto 和 vo 两层,dto 负责接收前端传参,vo 负责返回给前端的聚合结果。为什么不直接复用 entity?因为聚合查询的结果跟表的字段对不上,比如要返回“省份名称、总降水量、平均降水量、站点数量”,硬套 entity 很别扭,加一个 vo 反而干净。
启动类之外的配置只关心三件事。第一是 MySQL 数据源,用 Druid 连接池,配置了初始连接数和最大连接数。第二是 MyBatis-Plus 的 mapper 扫描路径和驼峰映射。第三是 Redis 的连接配置,注意设置序列化器,否则默认 JDK 序列化会把可读性搞得很差,排查缓存数据时费眼睛。
3.2 核心聚合接口:全国降水分布、区域同比环比、历史趋势
这个系统里最重要的接口是“全国最近 N 天降水分布”,前端地图就是靠它渲染的。请求参数是开始日期和结束日期,后端按省份分组聚合,返回每个省的累计降水、平均降水、站点数、增长趋势。实现上我写了一个自定义 SQL,因为 MyBatis-Plus 的 QueryWrapper 不太好表达“按地区表 join 再分组”的逻辑。
关键代码如下:
@Mapper public interface PrecipitationStatMapper { List<ProvincePrecipVO> sumByProvince(@Param("startDate") String startDate, @Param("endDate") String endDate); List<CityPrecipVO> sumByCity(@Param("provinceName") String provinceName, @Param("startDate") String startDate, @Param("endDate") String endDate); }对应的 XML 里,SQL 大致是这样:
<select id="sumByProvince" resultType="com.example.vo.ProvincePrecipVO"> SELECT r.province_name AS provinceName, SUM(d.precipitation) AS totalPrecip, AVG(d.precipitation) AS avgPrecip, COUNT(DISTINCT d.site_code) AS stationCount FROM t_precipitation_detail d JOIN t_site_region r ON d.site_code = r.site_code WHERE d.obs_date BETWEEN #{startDate} AND #{endDate} GROUP BY r.province_name ORDER BY totalPrecip DESC </select>区域同比环比接口,我用的是“今年区间 + 去年同区间”两次聚合再在内存里做差值比对。为什么不一条 SQL 搞定?因为同比环比的可读性优先于性能,两次查询各走索引,Spring 层面拿两个 map 做减法,代码看得明白,后续加“较多年均值变化率”也容易。
历史趋势接口相对简单,前端传一个站点编号或省份名称,后端按月份聚合返回["2023-01", 23.5]格式的数组。需要注意的一点是,如果按周聚合,日期处理上要小心跨年问题,我用了 MySQL 的DATE_FORMAT配合YEARWEEK函数处理,避免周一归属错年份。
3.3 缓存与分页:Redis 和 MyBatis 分页插件的正确使用
全国降水总览这种接口,每次回表聚合的代价并不高,但大屏页面会每隔 30 秒重新拉一次数据,同时可能有几十个人轮询,累计压力就不小了。我这里把统计结果缓存到 Redis,key 设计为precip:province:stat:{startDate}:{endDate},value 是 JSON 字符串,过期时间设置为 60 到 90 秒之间的随机值。
加随机过期时间有两个原因。一是所有大屏用户的 key 大概率相同,如果同一时刻过期,所有请求会同时穿透到数据库,造成毛刺;二是这个系统后续如果扩容成多实例,固定过期时间会导致缓存同时失效,随机化能让流量平滑。
分页功能用在了“降水明细查询”和“站点管理列表”上。MyBatis-Plus 的分页插件用法很简单,写一个配置类加载PaginationInnerInterceptor即可,业务代码里调用Page<User> page = new Page<>(current, size),然后把 page 传给 mapper 方法。但我必须提醒一下:分页插件生效的前提是 mapper 方法的返回值是 IPage 类型,而且不能先list()再手动截取,那样就不是 SQL 层分页了,数据量大时页面直接卡死。
3.4 统一返回、参数校验与全局异常处理
接口设计如果不做统一包装,前端处理后端返回值会非常痛苦。我用了Result<T>结构,包含 code、message、data 三个字段,成功时 code 是 200,失败时 code 是业务异常码。所有 Controller 方法直接返回Result.success(data),无需每个方法单独处理错误。
参数校验我用了@Validated加注解,比如查询接口的时间参数都加了@NotBlank和@Pattern限制日期格式,日期范围的长短也在 service 层做了判断,避免用户传一个跨十年的区间导致聚合慢。
全局异常处理是最容易被新手忽略的部分。我用@RestControllerAdvice统一捕获三类异常:参数校验异常、业务异常、通用异常。业务异常是自定义的BizException,比如站点编号不存在时抛出来,前端能统一弹提示。还有一类空指针和 SQL 异常,我建议不要在全局处理器里对外暴露堆栈信息,日志记录完整堆栈,返回给前端的消息只保留“系统繁忙,请稍后重试”。
4. 可视化大屏的落地:ECharts 地图、图表联动与刷新
4.1 全国降水分布图:GeoJSON 地图 + visualMap
前端最有技术含量的是全国降水分布图。ECharts 本身不带中国地图数据,5.x 版本之后需要自己加载 GeoJSON。我用的方式是:项目启动时把中国省份 GeoJSON 文件放到public/geo/目录下,在组件里通过 fetch 加载,然后echarts.registerMap('china', geoJson)。
地图的 visualMap 配置是整张图的核心。我用了连续型 visualMap,降水量从小到大对应从浅蓝到深蓝的颜色渐变。这种表达方式符合直觉——蓝色越深,雨越大。如果你给非专业用户看,千万不要用红绿色系,很多人会下意识把红色当危险,对降水图来说很违和。
散点层的叠加也是关键。地图底色已经能反映省份聚合值,但我想让用户看到具体站点,所以在地图上叠加了effectScatter,站点坐标从后端返回。降水量超过阈值的站点会带涟漪动画效果。这个细节对气象行业的人来说非常有用,他们一眼就能定位极端降水发生在哪些站点。
4.2 图表联动与下钻:点击省、联动两个图表
大屏如果只是地图孤零零放着,那叫展示屏,不叫分析系统。我给系统加了交互联动。用户点击地图上的某个省份,右侧柱状图会自动切换成该省份下属城市的降水量排名,同时下方折线图展示该省近六个月降水趋势。
实现联动最关键的是地图点击事件。ECharts 本体事件里提供了georoam是地图缩放,但省份点击要用chart.on('click', params),此时params.name就是省份名称。拿到省份名称后,我用 Vue 的响应式状态保存当前选中省,再调用城市聚合接口,更新柱状图和折线图的数据源。
这里有个细节:注册地图后,地图上的名字可能和数据库里的省份名称不完全一致,比如“内蒙古自治区”“广西壮族自治区”。前端传参数给后端时,后端按全称去匹配省名,容易对不上。我的处理是后端建了一张地区表,字段用省份标准全称,同时画图时在 GeoJSON 里把name字段做成和数据库一致的名称,前端再配置一个nameMap做映射兜底。
4.3 大屏布局、实时刷新与性能优化
大屏布局我采用 16:9 比例,整体分三栏。左侧放“全国降水总览地图”,中间上放核心指标卡,中间下放趋势折线图,右侧放城市柱状图和数据排行表格。大屏使用vw和vh单位做适配,避免不同分辨率下元素错位,transform: scale()方案在复杂大屏场景里反而容易出问题。
实时刷新用的方案是轮询。为什么不用 WebSocket?如果只是每 30 秒拉一次统计数据,WebSocket 属于杀鸡用牛刀,还得维护长连接状态。我用setInterval每 30 秒调用一次总览接口,拿到数据后直接替换 ECharts 的 series 数据,图表会自动平滑动画过渡。后来在用户使用过程中,他们在某个极端暴雨时段想尽快看到数据更新,我把刷新间隔缩短到了 10 秒,同时把后端查询走了 Redis 缓存,总览接口的响应时间稳定在 50ms 以内,没有给数据库造成压力。
性能上还有两个小细节。第一,全国站点几千个,如果直接把几千个点全部喂给 ECharts,初始化会卡顿。我在后端做了抽样,超过 800 个站点时按降水排序取前 400 个并把其余聚合到省份值,前端不感知。第二,ECharts 的large模式要配合largeThreshold使用,散点数量多时尽量开这个选项,能明显减少绘制耗时。
5. 部署上线与常见问题排查
5.1 Docker 部署:从前端镜像到后端容器
开发完成之后要部署,我全程用的 Docker。前端是最简单的,因为 Vite 打包出来的都是静态文件,用 Nginx 镜像托管即可。我先写了一个nginx.conf,核心配置是静态资源访问和 API 反向代理。
server { listen 80; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://backend:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里有个比较隐蔽的坑:Spring Boot 后端接口如果做了 context-path 配置,比如统一前缀/api,那么 Nginx 的proxy_pass末尾是否带斜杠,会直接影响路径拼接。proxy_pass http://backend:8080/api/;这种情况下,请求/api/province/stat转发到后端时是/api/province/stat,后端能正确处理。
后端 Dockerfile 也简单,先用 Maven 镜像打包,再用 JRE 镜像跑 jar。我用了多阶段构建,避免在最终镜像里残留编译工具。Redis 和 MySQL 直接用官方镜像,一条docker-compose.yml把四个服务编排起来。
version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: precip_db volumes: - ./mysql-data:/var/lib/mysql ports: - "3306:3306" redis: image: redis:6 ports: - "6379:6379" backend: build: context: ./backend depends_on: - mysql - redis ports: - "8080:8080" frontend: build: context: ./frontend ports: - "80:80" depends_on: - backend部署完之后,我遇到一个问题:后端容器里连接 MySQL 的地址不能写localhost,因为容器之间是互相隔离的网络,要写服务名mysql。很多第一次用 Docker Compose 的人在这里浪费半天时间,其实就是 host 填错了。
5.2 常见问题排查表
我把开发到部署阶段遇到的典型问题整理成一张表,这比任何教程都实用。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| ECharts 地图空白 | GeoJSON 未注册或路径 404 | 检查registerMap是否在 setOption 之前执行,Network 面板看 geo 文件是否加载成功 |
| 中文乱码,问号显示 | 数据库连接字符集不对 | JDBC URL 加useUnicode=true&characterEncoding=utf8,表结构用 utf8mb4 |
| 前后端联调跨域报错 | 没配置 CORS | 后端加CorsFilter或者用 Nginx 统一同源 |
| Redis 缓存显示乱码 | 默认 JDK 序列化 | 配置GenericJackson2JsonRedisSerializer |
| 聚合接口较慢 | 缺索引 | 确认site_code和obs_date联合索引生效,用EXPLAIN检查 |
| 大屏点击省份无响应 | GeoJSON 名称与数据库名称不一致 | 用 nameMap 映射或统一名称字段 |
| 部署后刷新页面 404 | 前端路由是 history 模式 | Nginx 添加try_files配置 |
其中跨域问题多说一句。如果前后端通过 Nginx 反代成同源,就不需要后端开 CORS;但如果本地开发直接请求http://localhost:8080,那必须在后端配置跨域。我用的是一个全局的 CorsFilter,允许本地开发域名和部署域名通过,不推荐allowedOrigins("*")全开,容易把接口暴露给任何站点。
5.3 后续还能怎么扩展
系统做完之后,我一直在想还能加什么。目前的版本是“离线历史统计为主、定时更新为辅”,后续至少有四个方向可以扩展。
第一个方向是接入更细粒度的实时降水数据。气象行业有很多分钟级数据源,可以做成 Kafka 消费,后端把每分钟数据写入 ClickHouse,再用 WebSocket 把增量数据推给前端,实现秒级刷新。第二个方向是增加空间插值功能,把离散站点数据插值成全国网格,用 ECharts 的等值线图展示,视觉上更像专业的天气图。第三个方向是历史文件管理,导出报告的时候文件会很大,可以用 MinIO 存储报表文件,避免挂在本地磁盘上。第四个方向是权限体系,目前管理员账号直接存在数据库,后续可以接 Spring Security 做角色控制,区分“游客可看大屏、管理员可改数据、气象审核员可修正异常值”三种角色。
我自己更想优先做第二个方向,因为降水分布图中站点散点始终不够美观,如果能插值成覆盖全国的连续色块,展示效果会有一个质的提升。技术上 ECharts 5 已经支持visualMap分段和网格图,难点落在后端插值算法上,接下来打算用反距离权重插值先跑一版试试。
这个项目做下来,我最大的一个感受是:可视化系统真正的成本,并不在于画图本身,而在于“把数据洗干净、把聚合逻辑做对、把缓存用好”这一整条数据链路。ECharts 的配置项学一天就能上手,但把 80 万行降水数据处理得让用户挑不出毛病,需要耐心和细心。如果你也在做类似的项目,遇到问题可以对照上面的排查表逐个找原因,尤其是数据清洗和地图名称映射那两步,解决了这两个地方,整个系统基本就稳了。