简介:一款基于Java与阿里云数据库的水质检测系统完整设计源码,面向物联网、环境监测及Java Web开发者,可用于快速搭建从数据采集、云端存储到展示控制的全流程水质监测应用。包体共97个文件、约1.71MB,涵盖27个Java源文件(实现JDBC数据库连接、用户注册登录、传感器数据读取及电机控制)、35个XML配置(定义数据库与系统参数)、19张PNG界面/状态图、3个Gradle构建脚本、3个JAR依赖包及2个属性文件,结构清晰易于导入改造。该资源已有329人学习下载,源码不仅实现MySQL与阿里云数据库双路数据接入,还支持下位机通过NB开发板远程上传数据,并可在移动端显示温度与水质传感器数值、远程操控换水电机,是学习Java物联网实战与云数据库集成的优质案例。
1. 水质检测系统这件事:Java 和阿里云 RDS 把监测数据真正用起来
搞水质监测的人应该都有这个经历:检测仪的数据堆在 Excel 里,周报靠人肉汇总,领导临时想查某个站点某天下午的 pH 曲线,得翻半天原始记录。这套基于 Java 和阿里云数据库的水质检测系统设计源码,就是把这一摊子事收进一个工程里——设备数据上报、阈值预警、站点查询、统计图表,全部走数据库落库和接口输出。它解决的不是“怎么测水质”,而是“测出来的数据怎么存、怎么查、怎么变成能用的信息”。适合两类人:一是刚接手监测类项目的 Java 后端开发,想看看一个真实的业务体系怎么组织;二是已经在用阿里云 RDS MySQL、但只把它当本地 MySQL 使的工程师,这份源码里的连接配置、表设计和定时任务写法值得直接抄。
2. 项目骨架与选型:为什么是 Spring Boot + MyBatis Plus + RDS MySQL
2.1 技术栈判断:这套源码的选型逻辑
水质检测系统本质上是一个“设备数据采集 + 数据存储 + 查询预警”的物联网后端,它和普通 CRUD 系统的区别在于:写入频率高、数据带时间戳、查询经常按站点和时间段聚合。如果用原生 JDBC 硬写,连接管理、分页、事务都要自己兜底,项目维护成本会失控;如果引入太重的工作流引擎或大数据组件,对单机部署又明显过剩。
这套源码选的是 Spring Boot + MyBatis Plus + MySQL 这条路上最常见的一组组合。Spring Boot 负责把 HTTP 接口、定时任务、连接池装配好;MyBatis Plus 把单表 CRUD 和分页从手写 SQL 里解放出来,但复杂聚合查询仍然留了 XML 自定义 SQL 的口子;数据库落到阿里云 RDS MySQL,是因为它和自建 MySQL 在 SQL 层面完全兼容,迁移成本几乎为零,同时白名单、备份、监控这些运维动作都可以在控制台点掉。换句话说,这套选型没有追求新奇,而是把“能稳定跑起来、能快速改需求”放在了第一位,对大多数中小规模监测项目是合理的。
2.2 模块划分与目录结构
拿到源码先别急着跑,先把目录吃透。它没有把所有类都堆在 controller 里,而是按业务边界拆成了几块,我拆解下来大概是这个结构:
| 包名/目录 | 职责说明 |
|---|---|
| controller | 对外 HTTP 接口,负责参数接收和响应封装 |
| service / service.impl | 业务逻辑层,数据校验、预警判断、事务控制都在这里 |
| mapper | MyBatis Plus 的 Mapper 接口,继承 BaseMapper 获得基础 CRUD |
| entity | 数据库表对应的实体类,字段名与表结构一一对应 |
| dto / vo | 接口入参和出参对象,避免实体类直接暴露给前端 |
| config | MyBatis Plus 分页插件、Cors 跨域、定时任务等配置 |
| job | 定时任务,比如定期清理过期数据、重新计算超标记录 |
新手容易犯的错是:直接把 entity 当返回对象用,字段一多接口文档就乱。这套源码里 entity 只负责映射表,对外传输用 dto/vo,两个端互不污染,这个习惯建议保留。另外注意 config 包里单独拆了分页插件配置,这和后面图表查询能不能顺利跑起来直接相关。
2.3 阿里云 RDS MySQL 的初始化:数据库连接配置与建表脚本
无论你是新建 RDS 实例还是复用已有的,先确认三件事:实例版本选了 MySQL 8.x(兼容性最好,窗口函数和 JSON 能力都有)、账号权限足够、白名单里加了自己的出口 IP。这一步做完,再把连接串换成 RDS 提供的内网地址或公网地址,写进 application.yml:
spring: datasource: url: jdbc:mysql://rm-xxxxxxx.mysql.rds.aliyuncs.com:3306/water_quality?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&connectTimeout=5000&socketTimeout=60000 username: water_app password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000这里有几个参数值得单独拎出来说。serverTimezone=Asia/Shanghai不写,你查出来的时间大概率比实际慢 8 小时,这和 RDS 默认时区有关;useSSL=false在公网连接时能省掉一层握手开销,但如果你所在企业强制要求加密传输,可以改成 true 并配 CA 证书;allowPublicKeyRetrieval=true是 MySQL 8 用 caching_sha2_password 认证时必需的,不加会报Public Key Retrieval is not allowed。连接池方面,maximum-pool-size设 20 对中小系统够用,调太大反而会让 RDS 的连接数被打满,因为云数据库实例有连接数上限,超了直接报Too many connections。
然后是建表脚本。这套源码里最核心的是三张表:设备表、水质监测记录表、预警记录表。监测记录表是数据量增长最快的一张,它的设计决定了查询性能上限:
CREATE TABLE `t_water_quality_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `device_id` varchar(64) NOT NULL COMMENT '设备编号', `station_id` varchar(64) NOT NULL COMMENT '站点编号', `ph` decimal(5,2) DEFAULT NULL COMMENT 'pH值', `turbidity` decimal(8,3) DEFAULT NULL COMMENT '浊度 NTU', `dissolved_oxygen` decimal(6,3) DEFAULT NULL COMMENT '溶解氧 mg/L', `temperature` decimal(5,2) DEFAULT NULL COMMENT '水温 ℃', `collect_time` datetime NOT NULL COMMENT '采集时间', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_device_collect` (`device_id`, `collect_time`), KEY `idx_station_time` (`station_id`, `collect_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='水质监测记录表';这个表设计有几个要点。第一,device_id和collect_time建了唯一索引,这是防重的基础——设备重复上报同一时间点的数据,第二次直接插入失败,应用层捕获这个冲突即可实现幂等。第二,idx_station_time复合索引专门服务“查某站点某时间段数据”这个高频查询,注意字段顺序是 station_id 在前、collect_time 在后,这匹配查询条件的等值+范围模式。第三,指标字段全部用 decimal 而不是 float,水质数据对精度敏感,float 在累加平均时会出现偏差,虽然界面看不出来,但报表对不上的时候很折磨人。
3. 核心业务实现:从设备上报到水质预警
3.1 设备数据上报接口:接收检测仪的 JSON 数据并幂等入库
检测仪或采集网关把数据以 JSON 形式 POST 到这个接口,Controller 只做参数接收,真正的校验和落库放在 Service 层。上报接口的第一个坑是重复数据:设备断网重连后会把缓存的数据重推一遍,如果不做幂等,记录表里会塞满重复行,统计平均值时直接翻车。源码里的做法是“唯一索引 + 捕获 DuplicateKeyException”,简单有效:
@PostMapping("/api/device/report") public Result<Void> report(@RequestBody @Validated WaterQualityReportDTO dto) { WaterQualityRecord record = new WaterQualityRecord(); record.setDeviceId(dto.getDeviceId()); record.setStationId(dto.getStationId()); record.setPh(dto.getPh()); record.setTurbidity(dto.getTurbidity()); record.setDissolvedOxygen(dto.getDissolvedOxygen()); record.setTemperature(dto.getTemperature()); record.setCollectTime(dto.getCollectTime()); return waterQualityService.saveRecordWithIdempotency(record); }@Service public class WaterQualityServiceImpl implements WaterQualityService { @Override @Transactional(rollbackFor = Exception.class) public Result<Void> saveRecordWithIdempotency(WaterQualityRecord record) { try { waterQualityRecordMapper.insert(record); } catch (DuplicateKeyException e) { // 同一设备同一采集时间的数据已存在,直接忽略重复上报 log.warn("duplicate report ignored, device={}, time={}", record.getDeviceId(), record.getCollectTime()); return Result.ok("重复数据已忽略"); } return Result.ok(); } }service 层用@Transactional保证单次插入要么成功要么抛异常,重复数据依赖数据库的唯一索引挡住,比先 select 再 insert 的方式更可靠——因为 select 和 insert 之间存在时间窗,并发上报时仍然可能插重。DuplicateKeyException是 Spring 对数据库唯一键冲突的统一封装,不要自己去抓SQLIntegrityConstraintViolationException,那样就把 MyBatis 的异常实现细节泄漏到了业务层。
这里还有一层参数校验:collect_time不能是未来时间、ph必须在 0 到 14 之间。前端传来的数据不一定靠谱,检测仪偶尔会出负值或 null,这种脏数据入库后会把整段曲线拉歪。建议在 DTO 上用@NotNull、@DecimalMin、@DecimalMax做基础校验,业务规则再在 service 里补一道。
3.2 预警逻辑:数据入库后立刻判断是否超标
水质监测系统光存数据没有意义,核心价值在于“指标超了能马上知道”。预警判断放在上报流程里同步做,还是异步做?这套源码选择同步做,因为上报频率不算高(一般几十秒一条),在事务里顺带插入预警记录可以保证数据一致性。阈值配置不是硬编码在代码里,而是读数据库配置表,这样调整标准不用重新发版:
@Override @Transactional(rollbackFor = Exception.class) public Result<Void> saveRecordWithIdempotency(WaterQualityRecord record) { try { waterQualityRecordMapper.insert(record); } catch (DuplicateKeyException e) { log.warn("duplicate report ignored, device={}, time={}", record.getDeviceId(), record.getCollectTime()); return Result.ok("重复数据已忽略"); } // 判断是否触发预警,阈值从配置表读取 checkAndSaveAlarm(record); return Result.ok(); } private void checkAndSaveAlarm(WaterQualityRecord record) { QualityThreshold threshold = qualityThresholdMapper.selectById(record.getStationId()); if (threshold == null) { return; // 该站点未配置阈值,不预警 } List<AlarmRecord> alarms = new ArrayList<>(); if (record.getPh() != null && record.getPh().compareTo(threshold.getPhMin()) < 0 || record.getPh().compareTo(threshold.getPhMax()) > 0) { alarms.add(buildAlarm(record, "ph", "pH超标", record.getPh().toString())); } if (record.getDissolvedOxygen() != null && record.getDissolvedOxygen().compareTo(threshold.getDoMin()) < 0) { alarms.add(buildAlarm(record, "dissolved_oxygen", "溶解氧偏低", record.getDissolvedOxygen().toString())); } if (!alarms.isEmpty()) { alarmRecordMapper.insertBatch(alarms); } }这段判断逻辑看着简单,但有一个隐蔽的坑:decimal字段的compareTo而不是>=。因为ph、dissolved_oxygen都是 BigDecimal 类型,用>=比较的是对象引用而不是数值,编译不会报错但结果永远不对,这个坑我见过不止一次。另外注意insertBatch是 MyBatis Plus 的批量插入方法,一次拼多条 SQL,减少与数据库的交互次数。
预警记录不直接推送短信或钉钉,而是落库,由定时任务去消费。这个设计的理由是:预警是重要事件,如果推送服务挂了或网络抖动了,数据不能丢,先落库再推送才有重试的余地。源码里 jeecg 风格的通知逻辑我没去细追,但“先写库、后通知”这个顺序是所有监测系统都应该遵守的。
3.3 历史查询与图表统计:分页查询和聚合 SQL
前端大屏要展示“某站点最近 24 小时的 pH 变化曲线”,底层就是一条聚合 SQL。这里如果用 MyBatis Plus 的 QueryWrapper 去按时间分组,SQL 写不出来,所以源码里在 XML 中手写了统计查询:
<select id="selectAvgIndicatorByHour" resultType="map"> SELECT DATE_FORMAT(collect_time, '%Y-%m-%d %H:00:00') AS time_point, ROUND(AVG(ph), 2) AS avg_ph, ROUND(AVG(turbidity), 2) AS avg_turbidity, ROUND(AVG(dissolved_oxygen), 2) AS avg_do FROM t_water_quality_record WHERE station_id = #{stationId} AND collect_time BETWEEN #{startTime} AND #{endTime} GROUP BY DATE_FORMAT(collect_time, '%Y-%m-%d %H:00:00') ORDER BY time_point ASC </select>GROUP BY 里用DATE_FORMAT把时间截断到小时,这是时间序列聚合最常用的做法。但要注意:如果这张表数据量已经上百万,这个查询会走全表扫描,因为DATE_FORMAT(collect_time)导致索引失效。正确做法是利用idx_station_time先按station_id + collect_time范围过滤,再对过滤后的结果做格式化聚合,上面的 SQL 已经这么写了——WHERE 条件里用的是原始字段,所以索引能命中,GROUP BY 的表达式只作用于中间结果集。
分页查询这块,源码配置了 MyBatis Plus 的分页插件:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }PaginationInnerInterceptor是 MyBatis Plus 3.x 里新版分页插件,旧版的PaginationInterceptor已经废弃了。setMaxLimit(100L)是硬性保护,前端传pageSize=9999也不会一次性拉全表;setOverflow(false)表示页数越界时直接返回空,而不是自动跳回第一页,这个行为更符合 API 的直觉。用的时候注意不要和 PageHelper 同时引入,两个分页插件会在 SQL 上加两套 limit,查出来的数据是错的。
4. 阿里云 RDS 避坑:五个让人半夜爬起来查日志的问题
4.1 连接失败:明明密码没错,为什么报 Communications link failure
- 现象:本地跑得好好的,部署到服务器上就连不上 RDS,报
Communications link failure或Connection timed out。 - 原因:RDS 控制了访问白名单,你的服务器出口 IP 不在白名单里。本地能连是因为本地 IP 加过了,换台机器就漏了。
- 解决:在阿里云 RDS 控制台找到“数据安全性 > 白名单设置”,把应用服务器的公网 IP 或内网 IP 加进去。如果应用部署在阿里云 ECS 上,优先用内网地址连接,不走公网,延迟和稳定性都更好。还有一个低频原因:VPC 和交换机的网络类型不匹配,ECS 和 RDS 必须处在同一个 VPC 下才能用内网地址。
4.2 时间差八小时:hibernate 和 mybatis 查出来的时间都不对
- 现象:数据库里存的时间是 14:00,应用查出来变成 06:00,用日期做统计时每天的数据都对不上。
- 原因:RDS 实例的默认时区是 UTC,而中国在东八区。JDBC 连接串里没指定
serverTimezone,驱动就用服务器默认时区去解释数据库返回的时间。 - 解决:连接串加
serverTimezone=Asia/Shanghai,同时把 JVM 的时区也对齐,启动参数加-Duser.timezone=Asia/Shanghai。另外可以在 RDS 控制台的“参数设置”里把default_time_zone改成+08:00,双保险。这个问题最坑的地方在于,某些环境下代码里能看到的部分是对的,只有报表和定时任务对不上,排查起来很费劲。
4.3 MySQL 8 认证插件导致的连接报错
- 现象:本地 MySQL 5.7 连接正常,换到 RDS MySQL 8 后启动报
Public Key Retrieval is not allowed。 - 原因:MySQL 8 默认的认证插件
caching_sha2_password在非 SSL 连接时需要先获取 RSA 公钥,而 JDBC 驱动出于安全考虑默认不允许自动获取。 - 解决:连接串加
allowPublicKeyRetrieval=true&useSSL=false,两个参数成对出现。如果企业网络强制要求 SSL,那就useSSL=true并配置证书。这是把本地库迁到 RDS 时最常撞见的第一个报错,提前写进配置里能省一晚上的折腾时间。
4.4 批量插入报 PacketTooBigException,或连接数被占满
- 现象:往上爬历史数据时一次插入几千条,报
Packet for query is too large;或者系统运行几天后日志频繁出现Too many connections。 - 原因:
max_allowed_packet参数限制单次 SQL 包大小,默认值偏小,一次性批量插入超出限制就会中断。连接数打满则是连接池没控制住,HikariCP 的maximum-pool-size配到 100,但 RDS 实例规格可能只允许 50 个连接。 - 解决:在 RDS 控制台把
max_allowed_packet调大到 64M,这可以按需设置;连接池参数要结合 RDS 实例规格来定,应用侧maximum-pool-size控制在 20 以内,同时确认没有别的地方在用手动创建的数据库连接(比如某些老代码用DriverManager.getConnection,用完不还,这是连接池打满的主要原因)。
4.5 监控告警缺失:数据库磁盘满了才反应过来
- 现象:某天写入突然报错,
The table is full,查监控发现磁盘使用率已经 100%。 - 原因:RDS 磁盘是按量付费的,默认不会自动扩容。监测数据每天都在涨,如果没人盯磁盘阈值,满了就写不进去,数据直接断档。
- 解决:在 RDS 控制台打开“自动扩容”功能,设定触发阈值(比如 80%),空间不够会自动扩展。同时给实例配好基础告警:CPU、内存、磁盘使用率、慢查询数,告警渠道至少接短信或钉钉机器人,避免全靠人肉巡检。数据备份也确认一下自动备份策略是开启的,不然扩容之后再想恢复到误删前的状态就难了。
5. 把断点数据找回来:SQL 回放核对与补采脚本
数据入库后,工作还没有结束。水质监测系统跑久了,总有那么几次设备离线、网络抖动、上报接口超时,导致某个时间段的数据缺了一块。曲线图上的一段空缺,看的人不会太在意,但做平均值统计、算达标率时,这段空缺就是整套报表的对不上的来源。我的习惯是:定期用 SQL 做一次“数据回放核对”,把设备一周的理论采集点数(按上报频率估算)和实际入库条数做对比,差距超过阈值就把缺失清单导出来,走补采。
第一步,先算每个设备的理论应采点和实际落库数:
SELECT device_id, COUNT(*) AS actual_count, TIMESTAMPDIFF(MINUTE, MIN(collect_time), MAX(collect_time)) / 30.0 AS expected_count FROM t_water_quality_record WHERE collect_time >= #{startTime} AND collect_time < #{endTime} GROUP BY device_id HAVING actual_count < expected_count * 0.95;这个查询假设检测仪每 30 分钟上报一次,如果你那边上报频率是 10 分钟,把30.0换成10.0即可。expected_count是一个估算值,用最大时间减最小时间除以上报间隔算出来,再乘 0.95 的容忍系数——允许丢 5% 以内的数据,因为部分时段设备可能是正常停机的。HAVING 子句的作用是在分组计算后过滤出“缺得比较多”的设备,这些就是需要人工看一眼的。
第二步,对那些确实有缺失的设备,生成一份缺失时间段清单,写到临时表或直接导出。第三步才是补采,我一般在服务里写一个定时任务,每天凌晨拉一遍近 24 小时的数据,凡是发现缺失的,调用设备侧的 HTTP 接口重新拉取,拉到的数据走正常的上报接口入库——幂等机制保证了就算补采的数据和已存在的数据重复了,也只是被忽略,不会污染记录表。
补采逻辑里有一个细节:拉数据时要用设备侧的时间作为collect_time,而不是补采时的系统时间。不然这段数据虽然补上了,但时间戳全变成补采时刻,曲线直接错位。从那以后,我每次部署完这套上线流程,都会强制跑一遍上面的核对 SQL,顺手检查一下磁盘容量和备份状态。这样比任何监控告警都让人安心,至少数据断档不会等到报表端发现时才暴露。希望帮到你。
本文还有配套的精品资源,点击获取