☰
基于Java的物联网环境监测系统:从设备接入到告警的完整实现
2026/10/9 8:09:46 网站建设 项目流程

简介:这份源码面向Java开发者与物联网初学者,提供一套数据中心环境监测系统的完整实现方案,可用于课程设计、毕业设计或二次开发参考。项目共49个文件,以37个Java源文件为核心,涵盖环境数据实体、采集接口与具体实现等模块,另有9个XML配置文件负责数据库连接、网络与消息队列等运行参数,2个properties文件管理日志级别与监测频率等全局项,压缩包约98KB,结构清晰、便于按模块阅读。系统围绕温度、湿度、烟雾等传感器数据的采集、存储与预警通知展开,通过接口抽象与实现分离体现面向对象设计,并借助XML配置提升可维护性。目前已有97人学习下载,适合希望理解Java在物联网领域落地方式、掌握模块化环境监测系统搭建思路的读者参考借鉴。

1. 从一堆传感器到一块看板:Java 物联网环境监测系统到底在做什么

机房、温室大棚、档案库房、小型实验室,这些场景有个共同点:温湿度、烟雾、光照一旦失控,损失往往不可逆。很多团队第一反应是买现成网关加云平台,但真到落地时会发现数据要留在自己服务器、告警逻辑要按业务改、历史曲线要能导出给审计——这时候一套自己可控的物联网环境监测系统就成了刚需。标题里的「基于 Java 语言开发」不是随便选的:Java 生态里 Spring Boot 做后端接口、MyBatis-Plus 管数据、Netty 或 MQTT 客户端接设备,这套组合在物联网平台开发里已经被验证过很多轮,招人也好招。

这套系统要解决的核心链路其实就四段:设备侧采集、网络传输、服务端存储与告警、前端可视化。它适合两类人:一类是物联网毕业设计或课程设计阶段的学生,需要一套能跑通、能讲清架构的完整源码;另一类是小团队的后端Java 工程师,被派去做一个内部环境监控工具,不想从零造轮子。后面几章我会按「数据怎么进来 → 怎么存 → 怎么告警 → 怎么排错」的顺序,把每个环节的选型理由、关键参数和踩过的坑讲清楚,代码能抄就抄,参数能调就调。

2. 设备接入与协议选型:MQTT、Modbus 还是 HTTP 轮询

2.1 三种接入方式的实际差别

环境监测设备五花八门,常见的有带 4G 模块的温湿度变送器、RS485 输出的烟感、走 WiFi 的 ESP32 节点。它们上报数据的方式直接决定了服务端怎么写。我一般把接入方式分成三类来看:

接入方式典型设备实时性服务端复杂度适用规模
MQTTESP32、4G DTU秒级中,需 Broker几十到上万节点
Modbus TCP/RTURS485 传感器+网关秒级高,需协议解析中小规模、工业现场
HTTP 轮询简易 WiFi 模块分钟级低,直接写接口几个到几十个点

选型逻辑很简单:设备数量少、上报频率低,HTTP 轮询最省事,一个@PostMapping就收完了;设备多、要下行控制、要断线重连,就上 MQTT。无源物联网这类靠反向散射通信的方案目前还偏研究,工程落地里基本见不到,别被概念带偏。至于「物联网的交换机与路由器连接」这种网络层问题,本质是设备所在网段能不能路由到 Broker 所在服务器,配好静态路由和端口放行即可,跟应用层代码无关。

2.2 用 Spring Boot 搭一个 MQTT 接入层

下面这段是接入层的核心,用 Eclipse Paho 客户端订阅设备主题,收到消息后解析成统一的数据对象再入库。Broker 我一般用 EMQX 或 Mosquitto,本地测试 Mosquitto 足够。

@Component public class MqttSubscriber { private static final String BROKER = "tcp://127.0.0.1:1883"; // 主题格式:env/{deviceId}/data,用通配符订阅所有设备 private static final String TOPIC = "env/+/data"; @Autowired private EnvDataService envDataService; @PostConstruct public void subscribe() throws MqttException { MqttClient client = new MqttClient(BROKER, "server-sub-" + UUID.randomUUID()); MqttConnectOptions options = new MqttConnectOptions(); options.setCleanSession(true); options.setAutomaticReconnect(true); // 断线自动重连,生产必开 options.setConnectionTimeout(10); // 连接超时 10 秒 options.setKeepAliveInterval(30); // 心跳 30 秒,小于 Broker 的 1.5 倍 client.connect(options); client.subscribe(TOPIC, (topic, message) -> { // topic 形如 env/DEV001/data,从中截出设备编号 String deviceId = topic.split("/")[1]; String payload = new String(message.getPayload(), StandardCharsets.UTF_8); EnvData data = JSON.parseObject(payload, EnvData.class); data.setDeviceId(deviceId); data.setCollectTime(LocalDateTime.now()); envDataService.save(data); }); } }

逻辑说明:cleanSession=true表示不保留会话,适合数据可丢的场景;如果要求断线期间的消息补发,改成false并给客户端固定 clientId。setAutomaticReconnect(true)是血泪经验,不加的话网络抖动一次就得重启服务。参数上keepAliveInterval必须小于 Broker 配置的keepalive上限,否则会被踢下线。设备侧上报的 JSON 建议固定字段名,比如temp、humi、smoke,别一会儿temperature一会儿temp,解析层会疯。

2.3 设备编号与主题设计

主题设计是接入层最容易翻车的地方。我见过有人用env/data一个主题收所有设备,结果设备一多根本分不清谁是谁。正确做法是把设备编号编进主题层级,env/{deviceId}/data和env/{deviceId}/cmd分开上行和下行。设备编号建议用 MAC 后六位或出厂序列号,别用自增 ID,否则设备换服务器后编号全乱。数据库里device_id字段加唯一索引,重复上报直接覆盖或按时间戳去重。

3. 数据存储与 MyBatis-Plus 建表:从实体类到 SQL 的落地

3.1 时序数据到底存哪里

环境监测的数据是典型时序数据:每条记录带时间戳,写入多、查询按时间段。选型上有三条路:MySQL 单表、MySQL 分表、专用时序库(InfluxDB、TDengine)。我的建议是——中小规模直接 MySQL,单表加时间索引,几百万行毫无压力;数据量上到千万级再考虑按月分表或迁 TDengine。别一上来就上时序库,运维成本会劝退小团队。

表结构设计上,一条环境数据记录包含:设备编号、温度、湿度、烟雾浓度、光照、采集时间、入库时间。温度和湿度用DECIMAL(5,2),别用FLOAT,浮点误差在告警阈值判断时会坑你。采集时间加索引,因为查询几乎都是「查某设备某时间段」。

3.2 用 MyBatis-Plus 从实体类生成建表 SQL

热搜里「mybatisplus 根据 java 实体类生成创建表的 sql 语句」是个高频需求,这里给一个能直接用的思路:用 MyBatis-Plus 的TableInfoHelper拿到实体元信息,再拼 DDL。下面是一个简化版工具类。

public class DdlGenerator { public static String generate(Class<?> entityClass) { TableInfo tableInfo = TableInfoHelper.getTableInfo(entityClass); StringBuilder sql = new StringBuilder(); sql.append("CREATE TABLE IF NOT EXISTS `") .append(tableInfo.getTableName()).append("` (\n"); for (TableFieldInfo field : tableInfo.getFieldList()) { sql.append(" `").append(field.getColumn()).append("` ") .append(mapType(field.getPropertyType())).append(" "); // 主键、非空、注释按需拼接 if (field.isKeyInsertStrategy()) { sql.append("NOT NULL "); } sql.append("COMMENT '").append(field.getColumn()).append("',\n"); } sql.append(" PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); return sql.toString(); } private static String mapType(Class<?> type) { if (type == String.class) return "VARCHAR(255)"; if (type == Integer.class) return "INT"; if (type == Long.class) return "BIGINT"; if (type == BigDecimal.class) return "DECIMAL(10,2)"; if (type == LocalDateTime.class) return "DATETIME"; return "VARCHAR(255)"; } }

逻辑说明:TableInfoHelper.getTableInfo会读取实体上的@TableName、@TableField注解,拿到表名和字段映射。mapType做 Java 类型到 MySQL 类型的映射,实际项目里建议用完整的映射表覆盖Boolean、Date、Double等。参数上注意DECIMAL(10,2)的精度要按业务定,温度范围 -50 到 100 用DECIMAL(5,2)就够。这个工具适合开发期快速建表,生产环境还是老老实实写 Flyway 或 Liquibase 迁移脚本,别让程序自动改表结构。

3.3 批量写入与索引的取舍

设备一多,逐条insert会把数据库打满。常见做法是攒一批再批量写,MyBatis-Plus 的saveBatch底层就是 JDBC 批处理。批大小我一般设 500,太大内存吃紧,太小没效果。索引方面,device_id和collect_time建联合索引,查询「某设备某时间段」能直接走索引。但索引不是越多越好,每个索引都会拖慢写入,环境监测这种写多读少的场景,索引控制在三个以内。

提示:批量写入时如果开了事务,批与批之间记得提交,否则长事务会锁表。用@Transactional时把批处理放在独立方法里,别和业务查询混在一个事务。

4. 阈值告警与规则引擎:别把判断逻辑写死在代码里

4.1 告警规则为什么不能硬编码

新手最容易犯的错是把「温度大于 30 就告警」直接写在 Service 里。等业务方说「夏天阈值调到 35」「湿度低于 20 也要告警」「烟雾超过 50 连续三次才告警」,你就得改代码重新发版。正确做法是把规则抽成配置,存数据库或配置中心,运行时动态加载。规则的核心要素:设备范围、监测指标、比较符、阈值、持续次数、告警级别。

4.2 一个轻量规则引擎的实现

下面这段用策略模式加配置表实现阈值判断,规则从数据库加载,支持动态调整。

@Service public class AlarmRuleEngine { @Autowired private AlarmRuleMapper ruleMapper; public void check(EnvData data) { // 每次查询该设备启用的规则,生产环境建议加缓存 List<AlarmRule> rules = ruleMapper.selectByDevice(data.getDeviceId()); for (AlarmRule rule : rules) { BigDecimal value = extract(data, rule.getMetric()); if (value == null) continue; boolean hit = compare(value, rule.getOperator(), rule.getThreshold()); if (hit) { // 连续次数判断:用 Redis 计数器记录连续命中次数 String key = "alarm:cnt:" + data.getDeviceId() + ":" + rule.getId(); Long cnt = redisTemplate.opsForValue().increment(key); redisTemplate.expire(key, 5, TimeUnit.MINUTES); if (cnt >= rule.getContinuousTimes()) { alarmService.raise(data, rule); redisTemplate.delete(key); // 触发后清零,避免重复告警 } } else { redisTemplate.delete("alarm:cnt:" + data.getDeviceId() + ":" + rule.getId()); } } } private BigDecimal extract(EnvData d, String metric) { switch (metric) { case "temp": return d.getTemp(); case "humi": return d.getHumi(); case "smoke": return d.getSmoke(); default: return null; } } private boolean compare(BigDecimal v, String op, BigDecimal t) { int c = v.compareTo(t); switch (op) { case ">": return c > 0; case "<": return c < 0; case ">=": return c >= 0; case "<=": return c <= 0; default: return false; } } }

逻辑说明:extract按指标名取值,compare做比较。连续次数用 Redis 计数器实现,命中就加一,没命中就清零,达到阈值触发告警后删除计数器。参数上expire设 5 分钟,意思是「5 分钟内的连续命中才算数」,避免设备偶尔抖一下就告警。BigDecimal比较必须用compareTo,用equals会因为精度不同返回 false,这是经典翻车点。

4.3 告警去重与恢复通知

告警最烦的是重复轰炸。同一个设备同一个规则,触发一次后应该进入「告警中」状态,直到数值恢复正常才发恢复通知。实现上给告警记录加status字段(0 正常、1 告警中、2 已恢复),触发时先查有没有未恢复的同规则告警,有就跳过。恢复判断就是反向比较,数值回到阈值内就更新状态并发恢复消息。通知渠道常见的是邮件、短信、企业微信机器人,用策略模式封装,加渠道不用改核心逻辑。

5. 避坑与排查:那些让系统半夜挂掉的细节

5.1 设备时间戳与服务端时间不一致

现象:历史曲线出现未来时间的数据点,或者同一秒涌入大量数据。原因:设备侧 RTC 没校准,或者设备用本地时间上报而服务端按 UTC 存。解决:统一约定上报时间戳用 UTC 毫秒数,服务端收到后校验,偏差超过 5 分钟的数据打标记或丢弃。别信设备的时间,服务端入库时间才是准的。

5.2 MQTT 消息重复导致数据翻倍

现象:数据库里同一设备同一秒有两条一模一样的记录。原因:QoS 设为 1 时 Broker 会重发,客户端没做幂等。解决:给数据表加device_id + collect_time唯一索引,插入用INSERT IGNORE或ON DUPLICATE KEY UPDATE。或者用 Redis 做去重,key 是设备编号加时间戳,setnx 成功才入库。

5.3 连接池耗尽导致接口全挂

现象:服务跑一段时间后所有接口超时,日志里全是Connection is not available。原因:MQTT 回调里直接调用了数据库,回调线程池和 HTTP 线程池抢连接,或者慢查询占着连接不放。解决:MQTT 回调只做解析,把数据丢进内存队列(Disruptor 或 BlockingQueue),另起线程消费入库。连接池大小按CPU 核数 * 2 + 磁盘数估算,别拍脑袋设 100。

5.4 阈值判断用了 float 导致边界误判

现象:温度设 30.0 告警,设备上报 30.0 却不告警。原因:float 存储 30.0 实际是 29.999999,比较时小于阈值。解决:数据库用DECIMAL,Java 用BigDecimal,比较用compareTo。这个坑我在两个项目里都踩过,现在看到 float 存传感器数据就条件反射。

5.5 前端轮询把后端打垮

现象:看板页面开着,后端 QPS 飙升。原因:前端用setInterval每秒请求一次全量数据。解决:改成 WebSocket 推送,或者轮询间隔拉到 10 秒以上,接口做缓存。数据变化没那么快,环境监测秒级刷新已经足够,别为了「实时」把服务器拖死。

6. 进阶技巧:用 Netty 自定义协议接非标设备

标准 MQTT 设备好接,但现场经常遇到只支持 TCP 私有协议的采集器,报文是十六进制字节流。这时候 Spring Boot 那套 HTTP 接口用不上,得用 Netty 写 TCP 服务端。核心是自定义ByteToMessageDecoder,按协议头长度拆包,再解析成业务对象。

public class EnvFrameDecoder extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { // 协议:2 字节魔数 0xAA55 + 1 字节长度 + N 字节数据 + 1 字节校验 if (in.readableBytes() < 4) return; in.markReaderIndex(); short magic = in.readShort(); if (magic != (short) 0xAA55) { ctx.close(); // 魔数不对,直接断开 return; } int len = in.readByte(); if (in.readableBytes() < len + 1) { in.resetReaderIndex(); // 数据不够,等下一批 return; } byte[] body = new byte[len]; in.readBytes(body); byte checksum = in.readByte(); if (checksum != calc(body)) { return; // 校验失败丢弃 } out.add(parse(body)); } private byte calc(byte[] data) { byte sum = 0; for (byte b : data) sum ^= b; return sum; } private EnvData parse(byte[] body) { // 按协议文档解析温度、湿度等字段 EnvData d = new EnvData(); d.setTemp(BigDecimal.valueOf(((body[0] & 0xFF) << 8 | (body[1] & 0xFF)) / 10.0)); return d; } }

逻辑说明:markReaderIndex和resetReaderIndex配合实现「数据不够就等」,这是 Netty 拆包的固定套路。魔数校验防止乱连,校验和用异或最简单,实际项目按设备文档来。解析时注意字节序,大端小端搞反了温度会变成离谱的值。Netty 的ByteBuf读取后要释放,用SimpleChannelInboundHandler会自动释放,别手动release两次。

验证方法上,我习惯用netcat或写个 Python 脚本模拟设备发十六进制报文,先确认拆包正确再对接真实设备。真实设备往往有各种非标行为,比如心跳包、登录包,协议文档一定要拿到手,没有文档就抓包分析,别猜。

这套系统从接入到告警再到非标协议扩展,核心链路就这些。我自己的习惯是每接一类新设备,先写一个最小可用的解析器跑通数据入库,再补告警和前端,别一上来就搭大框架。环境监测这行,数据准不准比界面炫不炫重要得多。希望帮到你。

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

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

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

立即咨询