☰
水质检测系统源码实战:Spring Boot+MyBatis Plus+RDS MySQL数据链路解析
2026/10/27 6:00:35 网站建设 项目流程

简介:这是一套基于Java与阿里云数据库的水质检测系统源码,面向Java开发者、物联网及环境监测方向的初学者和项目实践者,适用于学习传感器数据接入、云数据库远程交互、用户管理及简单设备控制的完整实现流程。资源包共97个文件,压缩包仅1.71MB,主要包含27个Java源文件(负责数据库连接、用户信息管理、传感器数据读取与换水电机控制)、35个XML配置文件(用于数据库连接参数与系统运行环境设定)、19张PNG界面素材,以及Gradle构建脚本、JAR依赖包、属性文件等工程运行所需内容,目录结构清晰、便于按模块检索。系统实现了连接MySQL获取水质数据、用户注册登录修改注销、温度与水质传感器数值展示,并支持发送指令操控换水电机,同时可配合NB开发板将下位机采集数据上传至阿里云数据库,形成从采集、存储到展示与控制的完整物联网监测闭环。目前已有329人浏览学习,适合需要参考真实项目源码并在此基础上二次开发的读者,也可直接用于相关课程设计或小型水质监测项目的功能扩展与改造。

1. 从设备上报到告警通知:这套水质检测系统源码到底解决了什么

“水质检测系统”听起来像是个偏硬件的项目,真正让开发团队熬夜的往往是数据从传感器走到数据库这一小段路:设备按分钟上报 pH、浊度、溶解氧、COD、氨氮等指标,几十上百个站点同时打过来,接口既要保证数据稳定落库,又要在几秒内判断是否越限、有没有触发告警。这套基于 Java 和阿里云数据库的水质检测系统设计源码,把这段路完整串了起来——Spring Boot 负责接收上报,MyBatis Plus 负责数据访问,阿里云 RDS MySQL 负责存储,源码里连模拟设备和告警去重都做了。它适合想快速搭建监测类业务、又不想在数据写入和告警链路上翻车的人,对 Java 基础要求不高,能把 Spring Boot 和 MyBatis Plus 用熟就能跑通。

2. Spring Boot 与阿里云 RDS MySQL:水质检测系统的技术骨架

2.1 设备上报到前端展示:整条数据链路先跑通

拿到源码之后,我建议先别急着点开业务代码,而是把整条运行链路在脑子里过一遍。水质检测的场景有一个明显特征:站点多,但每个站点的指标种类固定,上报频率也固定。常见做法是 DTU 或数采仪定时通过 HTTP 把一批数据推给后端,后端校验后写入数据库,再交给告警引擎去判断是否越限,最后前端从库里查出来做展示。

这条链路里最容易出问题的就是“批量写入”这一段。很多人第一次写这类系统时会用循环逐条 insert,开发环境感觉不到区别,等到几十个站点同时上报,数据库连接被占满,接口响应时间直接拉长。源码里的上报接口接收的是一个 JSON 数组,一次请求就带多个站点、多个指标,落到 Service 层也走的是批量保存,而不是单条写库。

整个源码工程的模块划分大致是这样的:device-simulator 负责模拟设备上报,water-service 处理业务逻辑和告警,water-admin 暴露查询接口。模拟器这个小模块对调试非常有用,不用真的买传感器,本地跑起来就能看到数据在流动,这也是我接手这类项目时第一个会去看的地方。

2.2 为什么是阿里云 RDS MySQL:对比自建 MySQL 与 PolarDB

这套系统的存储选型,我一般会默认放在阿里云 RDS MySQL 上,而不是一上来就上 PolarDB 或自建 MySQL。原因很简单:水质监测的写入模式是典型的短连接小事务,数据量到千万级时 MySQL 的批量提交和二级索引已经够用,RDS 把主备切换、自动备份、监控告警都托管掉了,不用自己熬夜搭主从。

方案适合场景运维成本扩展性
RDS MySQL中小规模 IoT、事务型业务低,托管主备/备份/监控只读实例、升配
PolarDB高并发 OLTP、读多写少低计算存储分离,弹性更强
自建 MySQL成本敏感、有专职 DBA高,主从/备份自己扛自己搞

PolarDB 的强项是计算和存储分离,适合读多写少、需要快速弹性扩缩容的场景。水质检测系统的读请求主要是大屏展示和历史曲线,压力远没到需要 PolarDB 的程度,硬上只会让成本翻倍。自建 MySQL 也不是不行,但备份恢复、参数调优、磁盘告警这些事会占据大量精力,团队里没有专职 DBA 的话,我劝你慎重。

这里有个容易忽略的点:RDS 实例创建后默认给的是内网地址,生产环境一定要用 VPC 内网连接后端服务,不要图方便把公网地址暴露出去。JDBC URL 里那串东西看着长,每段参数都有它存在的意义,下面细说。

2.3 JDBC 与 Druid 连接池参数:application.yml 是最该细看的地方

这套源码的数据库配置集中在 application.yml,你拿到手后第一件事应该是把它改成自己的 RDS 实例地址。先看一个我常用的最小配置:

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://rm-xxxx.mysql.rds.aliyuncs.com:3306/water_quality?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true&useAffectedRows=true&allowPublicKeyRetrieval=true username: water_app password: ${DB_PASSWORD} type: com.alibaba.druid.pool.DruidDataSource druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 6000 validation-query: SELECT 1 test-while-idle: true test-on-borrow: false test-on-return: false time-between-eviction-runs-millis: 60000 min-evictable-idle-time-millis: 300000

先解释 URL 里的关键参数。serverTimezone=Asia/Shanghai 指定了客户端时区,MySQL 8.0 驱动对时区敏感,不写这一项很容易在连接阶段就报错。rewriteBatchedStatements=true 是批量写入的加速开关,后面讲坑的时候还会专门提到。useAffectedRows=true 让 JDBC 返回真正被修改的行数,而不是匹配的行数,做更新判断时更准确。allowPublicKeyRetrieval=true 是为了兼容 MySQL 8.0 的 caching_sha2_password 认证方式,连不上的时候加上这个参数往往就好了。

Druid 连接池这边,initial-size=5 表示启动时就建立 5 个连接,避免第一个请求到来时再去慢慢建连接。max-active=20 是上限,水质检测这种小事务场景 20 个连接足够。max-wait=6000 表示拿不到连接时最多等 6 秒,超过就报错,防止请求无限排队。validation-query 用 SELECT 1 做探活,test-while-idle 只对空闲连接做校验,test-on-borrow 保持 false,不然每次拿连接都多一次查询,性能会明显掉。

密码那行我习惯用${DB_PASSWORD}从环境变量读取,源码里这个占位符也保留着。把密码硬编码在配置文件里再提交到仓库,这是我最不推荐的做法,尤其当你把源码放到公开仓库时。

3. 数据模型先行:水质检测系统的建表 SQL 与 MyBatis Plus 实体

3.1 五张核心表:从站点到告警记录

水质检测系统看业务很简单,但表结构设计不好,后面写代码会特别别扭。我梳理下来需要五张核心表:站点表 water_station、指标字典表 water_index、监测数据表 water_quality_record、告警规则表 warn_rule、告警记录表 warn_record。

water_station 存站点基础信息,字段包括站点名称、编号、经纬度、状态,经纬度我建议用 DECIMAL(9,6) 而不是 DOUBLE,原因后面讲精度坑的时候会说到。water_index 是指标字典,把 pH、DO、COD、氨氮这类指标统一编码管理,前端展示单位、告警配置都要依附这张表。

water_quality_record 是核心中的核心,存每次上报的指标值。设计上有两个关键点:一是分表或分区要提前考虑,二是索引要围绕“按站点查时间范围”这个最常见的查询来建。warn_rule 存告警阈值,一条规则对应一个指标、一个阈值方向、一个重复告警间隔。warn_record 存每次触发的告警实例,处理状态由后台人员维护。

这套设计其实遵循一个原则:业务数据、字典数据、过程数据分开存。站点和指标是相对静止的字典,告警规则是会调整的配置,监测数据是无脑增长的流水。如果把它们混在一张表里,后面做归档、做权限控制都会非常别扭。

3.2 建表 DDL:直接落库的 SQL

下面是这套系统最关键的一张表的建表语句,直接复制到 RDS 控制台就能执行:

CREATE TABLE water_quality_record ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '记录ID', station_id BIGINT NOT NULL COMMENT '站点ID', index_code VARCHAR(32) NOT NULL COMMENT '指标编码,PH/DO/COD/NH3N', index_value DECIMAL(10,4) NOT NULL COMMENT '指标值', collect_time DATETIME(3) NOT NULL COMMENT '采集时间,毫秒精度', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间', PRIMARY KEY (id, collect_time), KEY idx_station_time (station_id, collect_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='水质监测数据表';

注意到我把主键设计成了联合主键 (id, collect_time),而不是单独的自增 id。这是刻意为之:第 6 章要按 collect_time 做 RANGE 分区,而 MySQL 分区表要求分区列必须包含在主键或唯一索引里,如果这里只把 id 设为主键,后面ALTER分区时会直接报错,这是很多人上了生产才发现的问题。

index_value 用 DECIMAL(10,4) 而不是 DOUBLE,是因为水质指标经常出现 6.87、7.53 这样的小数,DOUBLE 是浮点数,存储时会有误差,页面上显示一长串 6.8699999 就是它造成的。index_code 用 VARCHAR(32) 存的是指标编码而不是名称,因为名称会变,编码不会,界面展示时再关联 water_index 表翻译成中文。

索引 idx_station_time 的字段顺序也有讲究,它是 (station_id, collect_time),不是反过来。水质检测最常见的查询是“查某个站点的某段时间数据”,station_id 等值匹配走索引左前缀,collect_time 做范围过滤,这样一条 SQL 就能同时利用两个字段。

3.3 从实体类到建表语句:别把 MyBatis Plus 当成 Hibernate

很多人在网上搜“mybatisplus根据java实体类生成创建表的sql语句”,这里我得先泼一盆冷水:MyBatis Plus 官方能力是代码生成器和 CRUD 增强,它不会像 Hibernate 那样根据实体类自动建表。实体类定义的是字段映射和类型转换,DDL 还是得自己写,或者用 Flyway、Liquibase 这类工具把建表脚本管起来。

这套源码的做法更实在——手写 DDL 维护在 sql 脚本里,实体类只负责映射。对应的实体大概长这样:

@Data @TableName("water_quality_record") public class QualityRecordDO { @TableId(type = IdType.AUTO) private Long id; private Long stationId; private String indexCode; private BigDecimal indexValue; private LocalDateTime collectTime; private LocalDateTime createTime; }

字段命名走的是“数据库下划线、Java驼峰”的常规映射,MyBatis Plus 默认开启驼峰转换,所以 station_id 自动映射到 stationId,不用写一堆 @TableField。只有等真正出现字段名对不上的情况,才需要显式加注解。

特别注意 indexValue 的类型是 BigDecimal,对应数据库的 DECIMAL。如果这里图省事写成 Double,数据库里存的 DECIMAL 在查询回填时会被 JDBC 转成 Double,精度照样丢。实体类型和数据库字段类型必须一一对应,这是 MyBatis Plus 使用里最容易踩的隐性问题。

实际项目中建表脚本建议交给 Flyway 管理,每次变更加一个版本脚本,而不是直接改旧的 DDL。这样团队里每个人拉下来代码一执行就是同一套表结构,不用靠口头同步。

4. 核心业务实现:上报写入、批量入库与告警去重

4.1 模拟设备上报与写入服务:从单个 insert 改成批量

水质检测系统里最基础的接口是设备上报。真实场景中 DTU 会按固定周期把一批数据推过来,因此这个接口天然是批量语义。先看写入服务怎么写:

@Service @RequiredArgsConstructor public class ReportService { private final QualityRecordService qualityRecordService; @Transactional(rollbackFor = Exception.class) public void saveReport(List<QualityRecordDO> list) { if (list == null || list.isEmpty()) { return; } // 先做业务校验,再批量入库 list.forEach(this::checkParam); qualityRecordService.saveBatch(list, 1000); } }

注意 @Transactional 里我写了 rollbackFor = Exception.class,而不是省略不写。Spring 默认只在抛出 RuntimeException 时回滚,如果校验逻辑抛的是受检异常,事务不会回滚,数据就会缺一半。这是把事务注解当默认配置抄过来的常见问题。

saveBatch 是 MyBatis Plus 的 IService 自带方法,第二个参数 1000 是批次大小。它内部按批次循环执行批量 insert,比循环单条 insert 的 SQL 交互次数少一个数量级。批次大小需要根据单条记录的字段数调整,水质检测一条记录几十个字节,1000 条一个批次比较安全;如果单条记录字段很多,建议降到 500。

模拟设备上报用的是 Spring 的定时任务:

@Scheduled(fixedDelay = 5000) public void mockReport() { List<StationDO> stations = stationMapper.selectList( new LambdaQueryWrapper<StationDO>().eq(StationDO::getStatus, 1)); List<QualityRecordDO> list = new ArrayList<>(); stations.forEach(s -> { list.add(buildRecord(s.getId(), "PH", 6.5 + ThreadLocalRandom.current().nextDouble())); list.add(buildRecord(s.getId(), "DO", 6.0 + ThreadLocalRandom.current().nextDouble() * 2)); }); saveReport(list); }

这里用了 LambdaQueryWrapper 而不是把条件写成字符串,目的是避免拼 SQL 时手滑写错列名。fixedDelay = 5000 表示上一次执行完再过 5 秒执行下一次,如果你的数据量小、想看得更频繁,改成 2000 也没问题。

4.2 告警规则判定:阈值判断与抖动处理

告警引擎是这套系统的灵魂。水质告警不能简单写成“每来一条数据就判断一次”,因为传感器数据存在抖动,瞬时越限不代表真的出了问题。更稳妥的做法是定时任务周期扫描,结合告警规则做状态判定。

@Scheduled(fixedDelay = 10_000) public void checkAlarm() { List<WarnRuleDO> rules = warnRuleMapper.selectList( new LambdaQueryWrapper<WarnRuleDO>().eq(WarnRuleDO::getStatus, 1)); for (WarnRuleDO rule : rules) { QualityRecordDO latest = recordMapper.selectLatest(rule.getIndexCode()); if (latest == null) { continue; } boolean over = judge(latest, rule); handleAlarm(latest, rule, over); } }

judge 方法做的事情很直接:取该指标最新一条数据,和规则的阈值比较。如果是 HIGH 类型就判断是否大于阈值,LOW 类型就判断是否小于阈值。比较时注意用 BigDecimal 的 compareTo,不要用 equals,因为 BigDecimal 的 equals 会连精度一起比较,6.5 和 6.50 会被判定为不相等。

handleAlarm 里是去重的关键。它先去 warn_record 查这个站点这个指标有没有一条没处理完的告警记录,如果有,说明已经处于告警状态,只需要更新最后触发时间,不再插入新记录;如果没有,才插入一条新的告警记录。这样即使传感器连续半小时越限,也只产生一条待处理告警,而不是几十条重复记录。

这里有一个架构层面的建议:告警判定不要放在上报接口的事务里同步执行。上报接口的压力是瞬时的,告警扫描是周期性的,混在一起会让接口响应变慢,还会拉长数据库事务时间。源码里把它们拆成两个独立的定时任务,是我比较推荐的做法。

4.3 查询接口:给前端最新数据与历史趋势

查询接口相对简单,但最容易写坏。给前端展示站点最新数据的接口一般长这样:

@GetMapping("/station/{stationId}/latest") public Result<List<QualityRecordDO>> latest(@PathVariable Long stationId) { LambdaQueryWrapper<QualityRecordDO> qw = new LambdaQueryWrapper<>(); qw.eq(QualityRecordDO::getStationId, stationId) .orderByDesc(QualityRecordDO::getCollectTime) .last("limit 20"); return Result.ok(recordMapper.selectList(qw)); }

orderByDesc(collectTime) 配合 3.2 节建的 (station_id, collect_time) 索引,走的是索引倒序扫描,性能没问题。最容易踩的坑是把排序条件换成别的字段,或者只按 collect_time 排序而忘了加 station_id 的等值条件,那样索引就用不上了。

.last("limit 20") 是直接拼接 SQL,虽然 MyBatis Plus 允许这么写,但如果有外部参数拼进去就有注入风险。这里 limit 是固定值,风险可控。如果要做分页,更规范的做法是用 Page 对象传给 selectPage,让 MyBatis Plus 自己生成分页 SQL。

这类系统还常有一个数据权限需求:不同账号只能看自己所属站点的数据。我一般会在查询前根据登录用户查出可见的 station_id 集合,手动拼到条件里。这也是“行级权限 java”实现里最简单、最可控的一种方式,比引入复杂的数据权限框架划算得多。

5. 避坑清单:从开发到上线的 5 个常见问题

5.1 雪花 ID 的无序写入让热表性能掉一半

现象:开发环境一切正常,压测时发现 water_quality_record 的写入 TPS 上不去,数据库 IO 等待很高,主从延迟一度拉到几十秒。去看慢查询日志,发现全是 insert,单条 SQL 本身非常快。

原因:实体类主键默认用了 MyBatis Plus 的 ASSIGN_ID 策略,也就是雪花 ID。雪花 ID 是有序的吗?同一毫秒内是递增,但跨毫秒后整体无序。InnoDB 的聚簇索引是按主键顺序组织的,无序主键写入会导致页分裂和随机 IO,数据量越大越明显。

解决:在 RDS MySQL 单实例场景下,直接用数据库自增主键,实体类改成 @TableId(type = IdType.AUTO)。如果以后有分布式多写需求,再用号段模式之类的方式替换,不要一上来就上雪花 ID。这是我在这类 IoT 系统上最深刻的一次翻车,后来凡是入库频率高的表,我都默认自增主键。

5.2 时区不一致:数据对不上,按天查询还“丢数据”

现象:设备上报后,查数据库发现 collect_time 比设备本地时间少了 8 小时。前端按天统计“昨天”的数据,发现每天下午的数据算到了前一天,一大早就有人来问系统是不是坏了。

原因:application.yml 里配了 serverTimezone=Asia/Shanghai,但 RDS MySQL 参数组里的 time_zone 默认是 +00:00。写入时 JDBC 按 Asia/Shanghai 转了一次,数据库存的是标准时间,读出时又转了一次,两边时区不一致,数据自然对不上。

解决:在 RDS 控制台的参数组里把 time_zone 显式设置为 +08:00,不要写 CST,因为 CST 在 MySQL 里有歧义,不同版本解释不一样。改完参数组要重启实例生效。JDBC URL 侧的 serverTimezone=Asia/Shanghai 保持不变,两边统一后数据就正常了。这个坑非常隐蔽,排查时先看时区,再看代码。

5.3 saveBatch 批量插入没提速:差一个 JDBC 参数

现象:把循环 insert 改成 saveBatch 后,接口响应时间几乎没有变化,数据库压力也没有明显下降。甚至并发高的时候,接口直接报错提示 packet 太大。

原因:MySQL 的 JDBC 驱动默认不会把批量语句真正合并执行,saveBatch 内部发出的多个 INSERT 还是逐条发到数据库。想要驱动把多条 INSERT 重写成一条多 VALUES 语句,必须在 JDBC URL 里追加 rewriteBatchedStatements=true。如果批量很大,还要注意 max_allowed_packet 参数,合并后的 SQL 超过这个限制就会报错。

解决:在前面那份 application.yml 的 URL 里已经加了 rewriteBatchedStatements=true,这是这套系统的血泪经验。另外把 RDS 参数组里的 max_allowed_packet 调到 64M 以上,批次大小控制在 1000 以内。改完这两处再压测,写入性能会有质的提升。

5.4 告警风暴:半夜被短信电话连环轰炸

现象:某站点溶解氧数值持续偏低,从凌晨到早上 6 点,值班人员收到两百多条告警短信。每条数据都触发一次通知,手机直接被打静音。

原因:告警逻辑是“来一条判断一条”,只要当前值超过阈值就 insert 一条告警记录并触发通知。没有状态机,没有去重,传感器连续越限多久就发多久。

解决:按 4.2 节的状态机思路改造。告警记录表里增加 handle_status 字段,0 表示待处理,1 表示已恢复或已确认。定时任务扫描时,如果该站点该指标已存在待处理告警,只更新最后触发时间,不重复插入;直到数据恢复正常才把状态置为已恢复。这样一次超标事件始终只有一条告警,等值班人员处理完再重置状态。

5.5 DECIMAL 用成 DOUBLE:pH 值变成 6.86999999

现象:前端详情页显示 pH 值为 6.86999999,溶解氧显示 6.9999999,数据库里看也是这一长串小数。用户截图反馈,看着像系统有 bug。

原因:数据库字段用的是 DOUBLE,Java 实体类用的是 Double,浮点数在二进制存储下无法精确表示,每次计算都有一点点误差,累积起来就显示成一长串。

解决:数据库字段改成 DECIMAL(10,4),Java 实体类改成 BigDecimal,两者一一对应。这套源码里所有指标值字段从一开始就设计成 DECIMAL,就是为了避开这个坑。如果你接手的老系统已经是 DOUBLE,可以在实体类里用 BigDecimal 接收,让 JDBC 做一次转换,也能临时止血。

6. 数据量上来以后:分区表、慢查询定位与数据验证

6.1 按月分区:让历史数据不拖慢写入

水质监测数据是典型的时序数据,跑半年之后单表几千万条,按站点查最新数据的 SQL 也开始变慢。我一般会在数据量上来之前就按 collect_time 做 RANGE 分区。由于 3.2 节建表时用了联合主键 (id, collect_time),现在直接执行分区即可:

ALTER TABLE water_quality_record PARTITION BY RANGE COLUMNS(collect_time) ( PARTITION p202501 VALUES LESS THAN ('2025-02-01'), PARTITION p202502 VALUES LESS THAN ('2025-03-01'), PARTITION p202503 VALUES LESS THAN ('2025-04-01') );

如果你建表时主键只用了 id,这一步会直接报错,提示所有唯一键必须包含分区列。这也是我坚持在建表时就预留联合主键的原因。分区后还有个配套工作:每月要提前加下一个月的分区,否则数据插到月底就会失败。我一般写个定时任务,每月 25 号自动检查下个月分区是否存在,不存在就补建。

6.2 断档检查与延迟监控:用两条 SQL 给自己上一道保险

系统上线后最怕的不是功能 bug,而是设备悄悄离线了没人知道。我习惯用两条 SQL 定期做体检。第一条检查每天每个站点的上报条数,哪一天突然变成 0,说明那天设备断档了:

SELECT station_id, DATE(collect_time) AS day, COUNT(*) AS cnt FROM water_quality_record GROUP BY station_id, DATE(collect_time) ORDER BY station_id, day;

第二条检查每个站点的最新上报时间。如果某个站点超过 15 分钟没有新数据,基本可以判定设备离线或上报链路出问题:

SELECT station_id, MAX(collect_time) AS last_time FROM water_quality_record WHERE collect_time >= NOW() - INTERVAL 1 DAY GROUP BY station_id HAVING last_time < NOW() - INTERVAL 15 MINUTE;

这条 SQL 在数据量大时要留意,HAVING 是在分组后过滤,没法走索引。可以先查出所有站点最新时间,在 Java 内存里做判断,或者套一层子查询再优化,压力不大时直接用问题也不大。

做这类系统久了,我最大的感受是稳定性比花哨功能重要得多。刚接手那会儿我被告警风暴折腾得半夜爬起来改代码,后来把时区、批量参数、告警去重三件事一一改完,系统安静到让我一度怀疑是不是没在运行。现在每次接手类似的源码工程,我都会先查这三处配置,再谈业务功能怎么写。希望帮到你。

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

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

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

立即咨询